// // BookmarksService.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Favorites store: CRUD over bookmarks + folders, full-text search across // title/url/tags/notes, the bookmarks-bar subset, and Netscape-format HTML // import/export (the de-facto browser bookmark interchange). Persisted as JSON // under the Atlas data root; observable so the bar and manager update live. // import Foundation import Combine @MainActor final class BookmarksService: ObservableObject { @Published private(set) var bookmarks: [Bookmark] = [] @Published private(set) var folders: [BookmarkFolder] = [] private let storeURL: URL private var saveTask: Task? init(filename: String = "bookmarks.json") { storeURL = PersistenceService.shared.rootDirectory.appendingPathComponent(filename) load() } var barBookmarks: [Bookmark] { bookmarks.filter { $0.onBar } } func bookmarks(inFolder id: UUID?) -> [Bookmark] { bookmarks.filter { $0.folderID == id } } func isBookmarked(_ url: String) -> Bool { bookmarks.contains { $0.url == url } } // MARK: - Mutations @discardableResult func add(title: String, url: String, onBar: Bool = false, folderID: UUID? = nil) -> Bookmark { if let existing = bookmarks.first(where: { $0.url == url }) { return existing } let bm = Bookmark(title: title.isEmpty ? url : title, url: url, folderID: folderID, onBar: onBar) bookmarks.insert(bm, at: 0) persist() return bm } /// Adds if absent, removes if present (⌘D toggle). func toggle(title: String, url: String) { if let idx = bookmarks.firstIndex(where: { $0.url == url }) { bookmarks.remove(at: idx) } else { bookmarks.insert(Bookmark(title: title.isEmpty ? url : title, url: url, onBar: true), at: 0) } persist() } func update(_ bookmark: Bookmark) { guard let idx = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return } bookmarks[idx] = bookmark persist() } func delete(_ id: UUID) { bookmarks.removeAll { $0.id == id } persist() } @discardableResult func addFolder(_ name: String) -> BookmarkFolder { let f = BookmarkFolder(name: name) folders.append(f) persist() return f } func deleteFolder(_ id: UUID) { folders.removeAll { $0.id == id } for i in bookmarks.indices where bookmarks[i].folderID == id { bookmarks[i].folderID = nil } persist() } // MARK: - Search func search(_ query: String) -> [Bookmark] { let q = query.lowercased().trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return bookmarks } return bookmarks.filter { $0.title.lowercased().contains(q) || $0.url.lowercased().contains(q) || $0.notes.lowercased().contains(q) || $0.tags.contains { $0.lowercased().contains(q) } } } // MARK: - Import / export (Netscape bookmark HTML) func exportHTML(to url: URL) throws { var lines = ["", "", "Bookmarks", "

Zyquo Atlas Bookmarks

", "

"] for b in bookmarks { let ts = Int(b.createdAt.timeIntervalSince1970) lines.append("

\(escape(b.title))") } lines.append("

") try lines.joined(separator: "\n").data(using: .utf8)?.write(to: url, options: .atomic) } @discardableResult func importHTML(from url: URL) throws -> Int { let html = try String(contentsOf: url, encoding: .utf8) // Minimal, robust: pull every text. let pattern = #"]*HREF=\"([^\"]+)\"[^>]*>(.*?)"# let re = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .dotMatchesLineSeparators]) let ns = html as NSString var count = 0 re.enumerateMatches(in: html, range: NSRange(location: 0, length: ns.length)) { m, _, _ in guard let m, m.numberOfRanges == 3 else { return } let href = ns.substring(with: m.range(at: 1)) let title = unescape(ns.substring(with: m.range(at: 2))) guard href.hasPrefix("http"), !bookmarks.contains(where: { $0.url == href }) else { return } bookmarks.append(Bookmark(title: title.isEmpty ? href : title, url: href)) count += 1 } if count > 0 { persist() } return count } // MARK: - Persistence private func load() { guard let data = try? Data(contentsOf: storeURL), let decoded = try? JSONDecoder().decode(Store.self, from: data) else { return } bookmarks = decoded.bookmarks folders = decoded.folders } private func persist() { let snapshot = Store(bookmarks: bookmarks, folders: folders) let url = storeURL saveTask?.cancel() saveTask = Task.detached(priority: .utility) { if let data = try? JSONEncoder().encode(snapshot) { try? data.write(to: url, options: .atomic) } } } private struct Store: Codable { var bookmarks: [Bookmark]; var folders: [BookmarkFolder] } private func escape(_ s: String) -> String { s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<") } private func unescape(_ s: String) -> String { s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: """, with: "\"") } }