spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
1//2// BookmarksService.swift3// Zyquo Atlas4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Favorites store: CRUD over bookmarks + folders, full-text search across9// title/url/tags/notes, the bookmarks-bar subset, and Netscape-format HTML10// import/export (the de-facto browser bookmark interchange). Persisted as JSON11// under the Atlas data root; observable so the bar and manager update live.12//1314import Foundation15import Combine1617@MainActor18final class BookmarksService: ObservableObject {19 @Published private(set) var bookmarks: [Bookmark] = []20 @Published private(set) var folders: [BookmarkFolder] = []2122 private let storeURL: URL23 private var saveTask: Task<Void, Never>?2425 init(filename: String = "bookmarks.json") {26 storeURL = PersistenceService.shared.rootDirectory.appendingPathComponent(filename)27 load()28 }2930 var barBookmarks: [Bookmark] { bookmarks.filter { $0.onBar } }3132 func bookmarks(inFolder id: UUID?) -> [Bookmark] {33 bookmarks.filter { $0.folderID == id }34 }3536 func isBookmarked(_ url: String) -> Bool {37 bookmarks.contains { $0.url == url }38 }3940 // MARK: - Mutations4142 @discardableResult43 func add(title: String, url: String, onBar: Bool = false, folderID: UUID? = nil) -> Bookmark {44 if let existing = bookmarks.first(where: { $0.url == url }) { return existing }45 let bm = Bookmark(title: title.isEmpty ? url : title, url: url, folderID: folderID, onBar: onBar)46 bookmarks.insert(bm, at: 0)47 persist()48 return bm49 }5051 /// Adds if absent, removes if present (⌘D toggle).52 func toggle(title: String, url: String) {53 if let idx = bookmarks.firstIndex(where: { $0.url == url }) {54 bookmarks.remove(at: idx)55 } else {56 bookmarks.insert(Bookmark(title: title.isEmpty ? url : title, url: url, onBar: true), at: 0)57 }58 persist()59 }6061 func update(_ bookmark: Bookmark) {62 guard let idx = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }63 bookmarks[idx] = bookmark64 persist()65 }6667 func delete(_ id: UUID) {68 bookmarks.removeAll { $0.id == id }69 persist()70 }7172 @discardableResult73 func addFolder(_ name: String) -> BookmarkFolder {74 let f = BookmarkFolder(name: name)75 folders.append(f)76 persist()77 return f78 }7980 func deleteFolder(_ id: UUID) {81 folders.removeAll { $0.id == id }82 for i in bookmarks.indices where bookmarks[i].folderID == id { bookmarks[i].folderID = nil }83 persist()84 }8586 // MARK: - Search8788 func search(_ query: String) -> [Bookmark] {89 let q = query.lowercased().trimmingCharacters(in: .whitespaces)90 guard !q.isEmpty else { return bookmarks }91 return bookmarks.filter {92 $0.title.lowercased().contains(q) || $0.url.lowercased().contains(q)93 || $0.notes.lowercased().contains(q) || $0.tags.contains { $0.lowercased().contains(q) }94 }95 }9697 // MARK: - Import / export (Netscape bookmark HTML)9899 func exportHTML(to url: URL) throws {100 var lines = ["<!DOCTYPE NETSCAPE-Bookmark-file-1>",101 "<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">",102 "<TITLE>Bookmarks</TITLE>", "<H1>Zyquo Atlas Bookmarks</H1>", "<DL><p>"]103 for b in bookmarks {104 let ts = Int(b.createdAt.timeIntervalSince1970)105 lines.append(" <DT><A HREF=\"\(b.url)\" ADD_DATE=\"\(ts)\">\(escape(b.title))</A>")106 }107 lines.append("</DL><p>")108 try lines.joined(separator: "\n").data(using: .utf8)?.write(to: url, options: .atomic)109 }110111 @discardableResult112 func importHTML(from url: URL) throws -> Int {113 let html = try String(contentsOf: url, encoding: .utf8)114 // Minimal, robust: pull every <A HREF="...">text</A>.115 let pattern = #"<A[^>]*HREF=\"([^\"]+)\"[^>]*>(.*?)</A>"#116 let re = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .dotMatchesLineSeparators])117 let ns = html as NSString118 var count = 0119 re.enumerateMatches(in: html, range: NSRange(location: 0, length: ns.length)) { m, _, _ in120 guard let m, m.numberOfRanges == 3 else { return }121 let href = ns.substring(with: m.range(at: 1))122 let title = unescape(ns.substring(with: m.range(at: 2)))123 guard href.hasPrefix("http"), !bookmarks.contains(where: { $0.url == href }) else { return }124 bookmarks.append(Bookmark(title: title.isEmpty ? href : title, url: href))125 count += 1126 }127 if count > 0 { persist() }128 return count129 }130131 // MARK: - Persistence132133 private func load() {134 guard let data = try? Data(contentsOf: storeURL),135 let decoded = try? JSONDecoder().decode(Store.self, from: data) else { return }136 bookmarks = decoded.bookmarks137 folders = decoded.folders138 }139140 private func persist() {141 let snapshot = Store(bookmarks: bookmarks, folders: folders)142 let url = storeURL143 saveTask?.cancel()144 saveTask = Task.detached(priority: .utility) {145 if let data = try? JSONEncoder().encode(snapshot) {146 try? data.write(to: url, options: .atomic)147 }148 }149 }150151 private struct Store: Codable { var bookmarks: [Bookmark]; var folders: [BookmarkFolder] }152153 private func escape(_ s: String) -> String {154 s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<")155 }156 private func unescape(_ s: String) -> String {157 s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<")158 .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "'", with: "'")159 .replacingOccurrences(of: """, with: "\"")160 }161}162