summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel+History.swift
blob: 7b6f80ea1630b480b537685ecb28a23e6c72d9a2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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)
    }
}