diff options
Diffstat (limited to 'DomainDig/DomainViewModel+History.swift')
| -rw-r--r-- | DomainDig/DomainViewModel+History.swift | 147 |
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) + } +} |
