From 321a10f85435fdcbfb54f109ee53df895a550a42 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 25 Jul 2026 00:10:46 -0500 Subject: refactor: extract history query surface into DomainViewModel+History (v5 step 4, 5/n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth slice of the DomainViewModel decomposition. - Moves the history query/compare surface into DomainViewModel+History.swift: per-domain snapshot reads (historyEntries/historyEntry/previousHistoryEntry), timeline grouping (timelineEntries/timelineSections), the two-snapshot selection model (toggle/clear/selectedSnapshots), diff generation and navigation (generateDiff*/moveTo*DiffChange/currentDiffTargetSectionID), and the resolver-mismatch notes. Pure move. - The inspection *pipeline* that produces history — performLookup, applySnapshot, saveHistoryEntry, persistHistory, snapshot-metadata bookkeeping — stays on the main type with the inspection code. This slice needs zero visibility changes: every dependency (the history array, diff state, latestSnapshot) was already internal. The main-file diff is pure deletion. DomainViewModel.swift: 4306 -> 4171 lines (4864 at the start of step 4; -693 total). App builds clean; unit suite 58/58. No project.pbxproj change. --- DomainDig/DomainViewModel+History.swift | 147 ++++++++++++++++++++++++++++++++ DomainDig/DomainViewModel.swift | 136 ----------------------------- 2 files changed, 147 insertions(+), 136 deletions(-) create mode 100644 DomainDig/DomainViewModel+History.swift (limited to 'DomainDig') 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) + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index e0f649b..7246112 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -3047,142 +3047,6 @@ final class 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) - } - private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { let latestEntries = latestSnapshots(for: domains) -- cgit v1.2.3