Commit 321a10f854
Unsigned
Layout: unified · split
DomainDig/DomainViewModel+History.swift added +147
| @@ -0,0 +1,147 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// History query surface of `DomainViewModel`: reading snapshots for a domain, | ||
| 4 | /// grouping them into a timeline, the two-snapshot selection model, and | ||
| 5 | /// generating/navigating diffs between snapshots. | ||
| 6 | /// | ||
| 7 | /// This is the read/compare side of history. The pipeline that *produces* | ||
| 8 | /// history — `performLookup`, `applySnapshot`, `saveHistoryEntry`, | ||
| 9 | /// `persistHistory`, and the snapshot-metadata bookkeeping — stays on the main | ||
| 10 | /// type with the inspection code. | ||
| 11 | extension DomainViewModel { | ||
| 12 | func resolverMismatchNote(for entry: HistoryEntry) -> String? { | ||
| 13 | guard entry.resolverURLString != resolverURLString else { return nil } | ||
| 14 | return "Current resolver differs from this snapshot. Re-running may produce different evidence." | ||
| 15 | } | ||
| 16 | |||
| 17 | func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? { | ||
| 18 | guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else { | ||
| 19 | return nil | ||
| 20 | } | ||
| 21 | return "Current resolver differs from the latest snapshot for this tracked domain." | ||
| 22 | } | ||
| 23 | |||
| 24 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { | ||
| 25 | previousHistoryEntry(for: entry)?.snapshot | ||
| 26 | } | ||
| 27 | |||
| 28 | func timelineEntries(for domain: String) -> [SnapshotSummary] { | ||
| 29 | historyEntries(for: domain).map(\.snapshotSummary) | ||
| 30 | } | ||
| 31 | |||
| 32 | func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] { | ||
| 33 | let entries = timelineEntries(for: domain) | ||
| 34 | let grouping = grouping ?? timelineGrouping | ||
| 35 | |||
| 36 | guard grouping == .relativeDay else { | ||
| 37 | return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)] | ||
| 38 | } | ||
| 39 | |||
| 40 | let calendar = Calendar.current | ||
| 41 | let today = Date() | ||
| 42 | let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today | ||
| 43 | let grouped = Dictionary(grouping: entries) { entry -> String in | ||
| 44 | if calendar.isDate(entry.timestamp, inSameDayAs: today) { | ||
| 45 | return "Today" | ||
| 46 | } | ||
| 47 | if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) { | ||
| 48 | return "Yesterday" | ||
| 49 | } | ||
| 50 | return "Older" | ||
| 51 | } | ||
| 52 | |||
| 53 | return ["Today", "Yesterday", "Older"].compactMap { title in | ||
| 54 | guard let items = grouped[title], !items.isEmpty else { return nil } | ||
| 55 | return TimelineSection(id: title.lowercased(), title: title, entries: items) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | func historyEntries(for domain: String) -> [HistoryEntry] { | ||
| 60 | history | ||
| 61 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | ||
| 62 | .sorted { $0.timestamp > $1.timestamp } | ||
| 63 | } | ||
| 64 | |||
| 65 | func historyEntry(withID id: UUID) -> HistoryEntry? { | ||
| 66 | history.first(where: { $0.id == id }) | ||
| 67 | } | ||
| 68 | |||
| 69 | func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? { | ||
| 70 | let siblings = historyEntries(for: entry.domain) | ||
| 71 | guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil } | ||
| 72 | let nextIndex = index + 1 | ||
| 73 | guard siblings.indices.contains(nextIndex) else { return nil } | ||
| 74 | return siblings[nextIndex] | ||
| 75 | } | ||
| 76 | |||
| 77 | func toggleSnapshotSelection(_ entry: HistoryEntry) { | ||
| 78 | if selectedSnapshotIDs.contains(entry.id) { | ||
| 79 | selectedSnapshotIDs.remove(entry.id) | ||
| 80 | } else if selectedSnapshotIDs.count < 2 { | ||
| 81 | selectedSnapshotIDs.insert(entry.id) | ||
| 82 | } else if let oldest = selectedSnapshotIDs.first { | ||
| 83 | selectedSnapshotIDs.remove(oldest) | ||
| 84 | selectedSnapshotIDs.insert(entry.id) | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | func clearSnapshotSelection() { | ||
| 89 | selectedSnapshotIDs.removeAll() | ||
| 90 | } | ||
| 91 | |||
| 92 | var selectedSnapshots: [HistoryEntry] { | ||
| 93 | selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp } | ||
| 94 | } | ||
| 95 | |||
| 96 | @discardableResult | ||
| 97 | func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? { | ||
| 98 | guard selectedSnapshots.count == 2 else { | ||
| 99 | activeDomainDiff = nil | ||
| 100 | activeDiffChangeIndex = 0 | ||
| 101 | return nil | ||
| 102 | } | ||
| 103 | |||
| 104 | let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot) | ||
| 105 | activeDomainDiff = diff | ||
| 106 | if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { | ||
| 107 | activeDiffChangeIndex = index | ||
| 108 | } else { | ||
| 109 | activeDiffChangeIndex = 0 | ||
| 110 | } | ||
| 111 | return diff | ||
| 112 | } | ||
| 113 | |||
| 114 | func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff { | ||
| 115 | selectedSnapshotIDs = [olderEntry.id, newerEntry.id] | ||
| 116 | let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot) | ||
| 117 | activeDomainDiff = diff | ||
| 118 | if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { | ||
| 119 | activeDiffChangeIndex = index | ||
| 120 | } else { | ||
| 121 | activeDiffChangeIndex = 0 | ||
| 122 | } | ||
| 123 | return diff | ||
| 124 | } | ||
| 125 | |||
| 126 | func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? { | ||
| 127 | guard let historyEntryID = batchResult.historyEntryID else { return nil } | ||
| 128 | return history.first(where: { $0.id == historyEntryID }) | ||
| 129 | } | ||
| 130 | |||
| 131 | var currentDiffTargetSectionID: String? { | ||
| 132 | guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else { | ||
| 133 | return nil | ||
| 134 | } | ||
| 135 | return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex] | ||
| 136 | } | ||
| 137 | |||
| 138 | func moveToNextDiffChange() { | ||
| 139 | guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return } | ||
| 140 | activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1) | ||
| 141 | } | ||
| 142 | |||
| 143 | func moveToPreviousDiffChange() { | ||
| 144 | guard activeDomainDiff != nil else { return } | ||
| 145 | activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0) | ||
| 146 | } | ||
| 147 | } | ||
DomainDig/DomainViewModel.swift −136
| @@ -3047,142 +3047,6 @@ final class DomainViewModel { | |||
| 3047 | } | 3047 | } |
| 3048 | } | 3048 | } |
| 3049 | 3049 | ||
| 3050 | func resolverMismatchNote(for entry: HistoryEntry) -> String? { | ||
| 3051 | guard entry.resolverURLString != resolverURLString else { return nil } | ||
| 3052 | return "Current resolver differs from this snapshot. Re-running may produce different evidence." | ||
| 3053 | } | ||
| 3054 | |||
| 3055 | func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? { | ||
| 3056 | guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else { | ||
| 3057 | return nil | ||
| 3058 | } | ||
| 3059 | return "Current resolver differs from the latest snapshot for this tracked domain." | ||
| 3060 | } | ||
| 3061 | |||
| 3062 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { | ||
| 3063 | previousHistoryEntry(for: entry)?.snapshot | ||
| 3064 | } | ||
| 3065 | |||
| 3066 | func timelineEntries(for domain: String) -> [SnapshotSummary] { | ||
| 3067 | historyEntries(for: domain).map(\.snapshotSummary) | ||
| 3068 | } | ||
| 3069 | |||
| 3070 | func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] { | ||
| 3071 | let entries = timelineEntries(for: domain) | ||
| 3072 | let grouping = grouping ?? timelineGrouping | ||
| 3073 | |||
| 3074 | guard grouping == .relativeDay else { | ||
| 3075 | return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)] | ||
| 3076 | } | ||
| 3077 | |||
| 3078 | let calendar = Calendar.current | ||
| 3079 | let today = Date() | ||
| 3080 | let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today | ||
| 3081 | let grouped = Dictionary(grouping: entries) { entry -> String in | ||
| 3082 | if calendar.isDate(entry.timestamp, inSameDayAs: today) { | ||
| 3083 | return "Today" | ||
| 3084 | } | ||
| 3085 | if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) { | ||
| 3086 | return "Yesterday" | ||
| 3087 | } | ||
| 3088 | return "Older" | ||
| 3089 | } | ||
| 3090 | |||
| 3091 | return ["Today", "Yesterday", "Older"].compactMap { title in | ||
| 3092 | guard let items = grouped[title], !items.isEmpty else { return nil } | ||
| 3093 | return TimelineSection(id: title.lowercased(), title: title, entries: items) | ||
| 3094 | } | ||
| 3095 | } | ||
| 3096 | |||
| 3097 | func historyEntries(for domain: String) -> [HistoryEntry] { | ||
| 3098 | history | ||
| 3099 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | ||
| 3100 | .sorted { $0.timestamp > $1.timestamp } | ||
| 3101 | } | ||
| 3102 | |||
| 3103 | func historyEntry(withID id: UUID) -> HistoryEntry? { | ||
| 3104 | history.first(where: { $0.id == id }) | ||
| 3105 | } | ||
| 3106 | |||
| 3107 | func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? { | ||
| 3108 | let siblings = historyEntries(for: entry.domain) | ||
| 3109 | guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil } | ||
| 3110 | let nextIndex = index + 1 | ||
| 3111 | guard siblings.indices.contains(nextIndex) else { return nil } | ||
| 3112 | return siblings[nextIndex] | ||
| 3113 | } | ||
| 3114 | |||
| 3115 | func toggleSnapshotSelection(_ entry: HistoryEntry) { | ||
| 3116 | if selectedSnapshotIDs.contains(entry.id) { | ||
| 3117 | selectedSnapshotIDs.remove(entry.id) | ||
| 3118 | } else if selectedSnapshotIDs.count < 2 { | ||
| 3119 | selectedSnapshotIDs.insert(entry.id) | ||
| 3120 | } else if let oldest = selectedSnapshotIDs.first { | ||
| 3121 | selectedSnapshotIDs.remove(oldest) | ||
| 3122 | selectedSnapshotIDs.insert(entry.id) | ||
| 3123 | } | ||
| 3124 | } | ||
| 3125 | |||
| 3126 | func clearSnapshotSelection() { | ||
| 3127 | selectedSnapshotIDs.removeAll() | ||
| 3128 | } | ||
| 3129 | |||
| 3130 | var selectedSnapshots: [HistoryEntry] { | ||
| 3131 | selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp } | ||
| 3132 | } | ||
| 3133 | |||
| 3134 | @discardableResult | ||
| 3135 | func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? { | ||
| 3136 | guard selectedSnapshots.count == 2 else { | ||
| 3137 | activeDomainDiff = nil | ||
| 3138 | activeDiffChangeIndex = 0 | ||
| 3139 | return nil | ||
| 3140 | } | ||
| 3141 | |||
| 3142 | let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot) | ||
| 3143 | activeDomainDiff = diff | ||
| 3144 | if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { | ||
| 3145 | activeDiffChangeIndex = index | ||
| 3146 | } else { | ||
| 3147 | activeDiffChangeIndex = 0 | ||
| 3148 | } | ||
| 3149 | return diff | ||
| 3150 | } | ||
| 3151 | |||
| 3152 | func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff { | ||
| 3153 | selectedSnapshotIDs = [olderEntry.id, newerEntry.id] | ||
| 3154 | let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot) | ||
| 3155 | activeDomainDiff = diff | ||
| 3156 | if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { | ||
| 3157 | activeDiffChangeIndex = index | ||
| 3158 | } else { | ||
| 3159 | activeDiffChangeIndex = 0 | ||
| 3160 | } | ||
| 3161 | return diff | ||
| 3162 | } | ||
| 3163 | |||
| 3164 | func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? { | ||
| 3165 | guard let historyEntryID = batchResult.historyEntryID else { return nil } | ||
| 3166 | return history.first(where: { $0.id == historyEntryID }) | ||
| 3167 | } | ||
| 3168 | |||
| 3169 | var currentDiffTargetSectionID: String? { | ||
| 3170 | guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else { | ||
| 3171 | return nil | ||
| 3172 | } | ||
| 3173 | return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex] | ||
| 3174 | } | ||
| 3175 | |||
| 3176 | func moveToNextDiffChange() { | ||
| 3177 | guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return } | ||
| 3178 | activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1) | ||
| 3179 | } | ||
| 3180 | |||
| 3181 | func moveToPreviousDiffChange() { | ||
| 3182 | guard activeDomainDiff != nil else { return } | ||
| 3183 | activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0) | ||
| 3184 | } | ||
| 3185 | |||
| 3186 | private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { | 3050 | private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { |
| 3187 | let latestEntries = latestSnapshots(for: domains) | 3051 | let latestEntries = latestSnapshots(for: domains) |
| 3188 | 3052 | ||