// // HistoryService.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Full-text browsing history: records visits (coalescing repeat visits to the // same URL), searches title/url, groups by day, supports delete-range/clear, // and provides a compact candidate list for "ask AI about my history" — only // titles/urls are ever sent to a model, never full page content, and only on // explicit user action. Private tabs never record here. JSON-persisted under // the Atlas data root. // import Foundation import Combine @MainActor final class HistoryService: ObservableObject { @Published private(set) var entries: [HistoryEntry] = [] private let storeURL: URL private var saveTask: Task? private let maxEntries = 10_000 init(filename: String = "history.json") { storeURL = PersistenceService.shared.rootDirectory.appendingPathComponent(filename) load() } // MARK: - Recording func record(url: String, title: String) { guard let scheme = URL(string: url)?.scheme, scheme == "http" || scheme == "https" else { return } if let idx = entries.firstIndex(where: { $0.url == url }) { entries[idx].lastVisit = Date() entries[idx].visitCount += 1 entries[idx].title = title.isEmpty ? entries[idx].title : title let e = entries.remove(at: idx) entries.insert(e, at: 0) } else { entries.insert(HistoryEntry(url: url, title: title.isEmpty ? url : title), at: 0) if entries.count > maxEntries { entries.removeLast(entries.count - maxEntries) } } persist() } // MARK: - Query func search(_ query: String) -> [HistoryEntry] { let q = query.lowercased().trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return entries } return entries.filter { $0.title.lowercased().contains(q) || $0.url.lowercased().contains(q) } } /// Entries grouped by calendar day (most recent first). func grouped(_ query: String = "") -> [(day: Date, items: [HistoryEntry])] { let cal = Calendar.current let matched = search(query) let groups = Dictionary(grouping: matched) { cal.startOfDay(for: $0.lastVisit) } return groups.sorted { $0.key > $1.key }.map { (day: $0.key, items: $0.value.sorted { $0.lastVisit > $1.lastVisit }) } } /// Compact context for "ask AI about my history" (titles + hosts only). func aiContext(limit: Int = 300) -> String { entries.prefix(limit).enumerated().map { i, e in "\(i + 1). \(e.title) — \(e.host) (\(Self.relative(e.lastVisit)))" }.joined(separator: "\n") } // MARK: - Deletion func delete(_ id: UUID) { entries.removeAll { $0.id == id }; persist() } func deleteRange(since date: Date) { entries.removeAll { $0.lastVisit >= date } persist() } func clearAll() { entries.removeAll(); persist() } // MARK: - Persistence private func load() { if let data = try? Data(contentsOf: storeURL), let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) { entries = decoded } } private func persist() { let snapshot = entries let url = storeURL saveTask?.cancel() saveTask = Task.detached(priority: .utility) { if let data = try? JSONEncoder().encode(snapshot) { try? data.write(to: url, options: .atomic) } } } static func relative(_ date: Date) -> String { let f = RelativeDateTimeFormatter() f.unitsStyle = .abbreviated return f.localizedString(for: date, relativeTo: Date()) } }