summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel+History.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig/DomainViewModel+History.swift')
-rw-r--r--DomainDig/DomainViewModel+History.swift147
1 files changed, 147 insertions, 0 deletions
diff --git a/DomainDig/DomainViewModel+History.swift b/DomainDig/DomainViewModel+History.swift
new file mode 100644
index 0000000..7b6f80e
--- /dev/null
+++ b/DomainDig/DomainViewModel+History.swift
@@ -0,0 +1,147 @@
+import Foundation
+
+/// History query surface of `DomainViewModel`: reading snapshots for a domain,
+/// grouping them into a timeline, the two-snapshot selection model, and
+/// generating/navigating diffs between snapshots.
+///
+/// This is the read/compare side of history. The pipeline that *produces*
+/// history — `performLookup`, `applySnapshot`, `saveHistoryEntry`,
+/// `persistHistory`, and the snapshot-metadata bookkeeping — stays on the main
+/// type with the inspection code.
+extension DomainViewModel {
+ func resolverMismatchNote(for entry: HistoryEntry) -> String? {
+ guard entry.resolverURLString != resolverURLString else { return nil }
+ return "Current resolver differs from this snapshot. Re-running may produce different evidence."
+ }
+
+ func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
+ guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
+ return nil
+ }
+ return "Current resolver differs from the latest snapshot for this tracked domain."
+ }
+
+ func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
+ previousHistoryEntry(for: entry)?.snapshot
+ }
+
+ func timelineEntries(for domain: String) -> [SnapshotSummary] {
+ historyEntries(for: domain).map(\.snapshotSummary)
+ }
+
+ func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
+ let entries = timelineEntries(for: domain)
+ let grouping = grouping ?? timelineGrouping
+
+ guard grouping == .relativeDay else {
+ return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
+ }
+
+ let calendar = Calendar.current
+ let today = Date()
+ let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
+ let grouped = Dictionary(grouping: entries) { entry -> String in
+ if calendar.isDate(entry.timestamp, inSameDayAs: today) {
+ return "Today"
+ }
+ if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
+ return "Yesterday"
+ }
+ return "Older"
+ }
+
+ return ["Today", "Yesterday", "Older"].compactMap { title in
+ guard let items = grouped[title], !items.isEmpty else { return nil }
+ return TimelineSection(id: title.lowercased(), title: title, entries: items)
+ }
+ }
+
+ func historyEntries(for domain: String) -> [HistoryEntry] {
+ history
+ .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ .sorted { $0.timestamp > $1.timestamp }
+ }
+
+ func historyEntry(withID id: UUID) -> HistoryEntry? {
+ history.first(where: { $0.id == id })
+ }
+
+ func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
+ let siblings = historyEntries(for: entry.domain)
+ guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
+ let nextIndex = index + 1
+ guard siblings.indices.contains(nextIndex) else { return nil }
+ return siblings[nextIndex]
+ }
+
+ func toggleSnapshotSelection(_ entry: HistoryEntry) {
+ if selectedSnapshotIDs.contains(entry.id) {
+ selectedSnapshotIDs.remove(entry.id)
+ } else if selectedSnapshotIDs.count < 2 {
+ selectedSnapshotIDs.insert(entry.id)
+ } else if let oldest = selectedSnapshotIDs.first {
+ selectedSnapshotIDs.remove(oldest)
+ selectedSnapshotIDs.insert(entry.id)
+ }
+ }
+
+ func clearSnapshotSelection() {
+ selectedSnapshotIDs.removeAll()
+ }
+
+ var selectedSnapshots: [HistoryEntry] {
+ selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
+ }
+
+ @discardableResult
+ func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
+ guard selectedSnapshots.count == 2 else {
+ activeDomainDiff = nil
+ activeDiffChangeIndex = 0
+ return nil
+ }
+
+ let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
+ activeDomainDiff = diff
+ if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+ activeDiffChangeIndex = index
+ } else {
+ activeDiffChangeIndex = 0
+ }
+ return diff
+ }
+
+ func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
+ selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
+ let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
+ activeDomainDiff = diff
+ if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+ activeDiffChangeIndex = index
+ } else {
+ activeDiffChangeIndex = 0
+ }
+ return diff
+ }
+
+ func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
+ guard let historyEntryID = batchResult.historyEntryID else { return nil }
+ return history.first(where: { $0.id == historyEntryID })
+ }
+
+ var currentDiffTargetSectionID: String? {
+ guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
+ return nil
+ }
+ return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
+ }
+
+ func moveToNextDiffChange() {
+ guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
+ activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
+ }
+
+ func moveToPreviousDiffChange() {
+ guard activeDomainDiff != nil else { return }
+ activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
+ }
+}