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%
3.7 KB · 109 lines swift
Raw Blame History
1//2//  HistoryService.swift3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Full-text browsing history: records visits (coalescing repeat visits to the9//  same URL), searches title/url, groups by day, supports delete-range/clear,10//  and provides a compact candidate list for "ask AI about my history" — only11//  titles/urls are ever sent to a model, never full page content, and only on12//  explicit user action. Private tabs never record here. JSON-persisted under13//  the Atlas data root.14//1516import Foundation17import Combine1819@MainActor20final class HistoryService: ObservableObject {21    @Published private(set) var entries: [HistoryEntry] = []2223    private let storeURL: URL24    private var saveTask: Task<Void, Never>?25    private let maxEntries = 10_0002627    init(filename: String = "history.json") {28        storeURL = PersistenceService.shared.rootDirectory.appendingPathComponent(filename)29        load()30    }3132    // MARK: - Recording3334    func record(url: String, title: String) {35        guard let scheme = URL(string: url)?.scheme, scheme == "http" || scheme == "https" else { return }36        if let idx = entries.firstIndex(where: { $0.url == url }) {37            entries[idx].lastVisit = Date()38            entries[idx].visitCount += 139            entries[idx].title = title.isEmpty ? entries[idx].title : title40            let e = entries.remove(at: idx)41            entries.insert(e, at: 0)42        } else {43            entries.insert(HistoryEntry(url: url, title: title.isEmpty ? url : title), at: 0)44            if entries.count > maxEntries { entries.removeLast(entries.count - maxEntries) }45        }46        persist()47    }4849    // MARK: - Query5051    func search(_ query: String) -> [HistoryEntry] {52        let q = query.lowercased().trimmingCharacters(in: .whitespaces)53        guard !q.isEmpty else { return entries }54        return entries.filter { $0.title.lowercased().contains(q) || $0.url.lowercased().contains(q) }55    }5657    /// Entries grouped by calendar day (most recent first).58    func grouped(_ query: String = "") -> [(day: Date, items: [HistoryEntry])] {59        let cal = Calendar.current60        let matched = search(query)61        let groups = Dictionary(grouping: matched) { cal.startOfDay(for: $0.lastVisit) }62        return groups.sorted { $0.key > $1.key }.map { (day: $0.key, items: $0.value.sorted { $0.lastVisit > $1.lastVisit }) }63    }6465    /// Compact context for "ask AI about my history" (titles + hosts only).66    func aiContext(limit: Int = 300) -> String {67        entries.prefix(limit).enumerated().map { i, e in68            "\(i + 1). \(e.title)\(e.host) (\(Self.relative(e.lastVisit)))"69        }.joined(separator: "\n")70    }7172    // MARK: - Deletion7374    func delete(_ id: UUID) { entries.removeAll { $0.id == id }; persist() }7576    func deleteRange(since date: Date) {77        entries.removeAll { $0.lastVisit >= date }78        persist()79    }8081    func clearAll() { entries.removeAll(); persist() }8283    // MARK: - Persistence8485    private func load() {86        if let data = try? Data(contentsOf: storeURL),87           let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) {88            entries = decoded89        }90    }9192    private func persist() {93        let snapshot = entries94        let url = storeURL95        saveTask?.cancel()96        saveTask = Task.detached(priority: .utility) {97            if let data = try? JSONEncoder().encode(snapshot) {98                try? data.write(to: url, options: .atomic)99            }100        }101    }102103    static func relative(_ date: Date) -> String {104        let f = RelativeDateTimeFormatter()105        f.unitsStyle = .abbreviated106        return f.localizedString(for: date, relativeTo: Date())107    }108}109