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// 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