SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
5.8 KB · 162 lines swift
Raw Blame History
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: "&amp;").replacingOccurrences(of: "<", with: "&lt;")155    }156    private func unescape(_ s: String) -> String {157        s.replacingOccurrences(of: "&amp;", with: "&").replacingOccurrences(of: "&lt;", with: "<")158            .replacingOccurrences(of: "&gt;", with: ">").replacingOccurrences(of: "&#39;", with: "'")159            .replacingOccurrences(of: "&quot;", with: "\"")160    }161}162