krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

e5d6c6c30df78e1d9b07088c256421aa663cb173

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-25T04:59:07Z

refactor: extract export/portability into DomainViewModel+Export (v5 step 4, 3/n)

Third slice of the DomainViewModel decomposition, stacked on the monitoring
split.

- Moves the export/data-portability surface into DomainViewModel+Export.swift:
  the single-report/batch/tracked-domain/timeline/workflow exporters, the
  full-backup and portable-slice exporters, prepareDataImport/applyDataImport,
  and persistCurrentAppSettings. The export-only WorkflowExportPayload struct
  moves with them. Pure move, no logic changes.
- The four report-projection helpers the exporters call (currentBatchReports,
  reports(for:), timelineReports, workflowReports) are promoted private ->
  internal and stay on the main type: they build DomainReports through the
  shared report layer (report(for:)/reportBuilder), so they belong with
  inspection, not export. Those four visibility drops are the only non-deletion
  edits to DomainViewModel.

DomainViewModel.swift: 4601 -> 4434 lines. App builds clean; unit suite 58/58.
No project.pbxproj change (DomainDig/ is a synchronized group).
 DomainDig/DomainViewModel+Export.swift | 180 +++++++++++++++++++++++++++++++++
 DomainDig/DomainViewModel.swift        | 175 +-------------------------------
 2 files changed, 184 insertions(+), 171 deletions(-)

diff --git a/DomainDig/DomainViewModel+Export.swift b/DomainDig/DomainViewModel+Export.swift
new file mode 100644
index 0000000..5ca0514
--- /dev/null
+++ b/DomainDig/DomainViewModel+Export.swift
@@ -0,0 +1,180 @@
+import Foundation
+
+/// Export and data-portability surface of `DomainViewModel`: rendering the
+/// current report, batch results, tracked domains, timelines, and workflow runs
+/// into the shareable formats, plus the full-backup / portable-slice exporters
+/// and the import + app-settings persistence entry points.
+///
+/// These are thin adapters over `DomainReportExporter` and
+/// `DomainDataPortabilityService`. The report-projection helpers they call
+/// (`currentBatchReports`, `reports(for:)`, `timelineReports`, `workflowReports`)
+/// remain on the main type — they belong to the report layer shared with
+/// inspection, not to export.
+extension DomainViewModel {
+    func exportJSONData() -> Data? {
+        guard let currentReport else { return nil }
+        return try? DomainReportExporter.data(for: currentReport, format: .json)
+    }
+
+    func exportJSONString() -> String? {
+        guard let data = exportJSONData() else { return nil }
+        return String(data: data, encoding: .utf8)
+    }
+
+    func exportSingleReportData(format: DomainExportFormat) -> Data? {
+        guard let currentReport else { return nil }
+        return try? DomainReportExporter.data(for: currentReport, format: format)
+    }
+
+    func exportBatchReportData(format: DomainExportFormat) -> Data? {
+        try? DomainReportExporter.data(
+            for: currentBatchReports(),
+            format: format,
+            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
+        )
+    }
+
+    func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? {
+        try? DomainReportExporter.data(
+            for: reports(for: domains),
+            format: format,
+            title: "Tracked Domains Export"
+        )
+    }
+
+    func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
+        DomainReportExporter.timelineText(
+            for: timelineReports(for: domain),
+            domain: domain,
+            includeDiffSummary: includeDiffSummary
+        )
+    }
+
+    func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
+        try? DomainReportExporter.timelineData(
+            for: timelineReports(for: domain),
+            domain: domain,
+            includeDiffSummary: includeDiffSummary
+        )
+    }
+
+    func exportFullBackupData() -> Data? {
+        try? DomainDataPortabilityService.backupData()
+    }
+
+    func exportPortableTrackedDomainsJSONData() -> Data? {
+        try? DomainDataPortabilityService.trackedDomainsExportData()
+    }
+
+    func exportPortableTrackedDomainsCSV() -> String {
+        DomainDataPortabilityService.trackedDomainsCSV()
+    }
+
+    func exportPortableWorkflowsJSONData() -> Data? {
+        try? DomainDataPortabilityService.workflowsExportData()
+    }
+
+    func exportPortableWorkflowsCSV() -> String {
+        DomainDataPortabilityService.workflowsCSV()
+    }
+
+    func exportPortableHistoryJSONData() -> Data? {
+        try? DomainDataPortabilityService.historyExportData()
+    }
+
+    func prepareDataImport(
+        data: Data,
+        fileName: String,
+        mode: DataPortabilityImportMode
+    ) throws -> DataImportPreview {
+        try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
+    }
+
+    func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
+        let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
+        refreshPersistedData()
+        portabilityStatusMessage = result.summary
+        CloudSyncService.shared.markAppSettingsChanged()
+        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
+        CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .imported)
+        return result
+    }
+
+    func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
+        DomainDataPortabilityService.saveAppSettings(
+            AppSettingsSnapshot(
+                recentSearches: recentSearches,
+                savedDomains: savedDomains,
+                resolverURLString: resolverURLString,
+                appDensityRawValue: appDensityRawValue
+            )
+        )
+        CloudSyncService.shared.markAppSettingsChanged()
+        refreshDataLifecycleSummary()
+    }
+
+    func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+        let reports = workflowReports(from: summary, changedOnly: changedOnly)
+        let base = DomainReportExporter.batchText(
+            for: reports,
+            title: "\(summary.workflowName) Workflow Export"
+        )
+        guard !summary.workflowInsights.isEmpty else { return base }
+        let insightLines = summary.workflowInsights.map {
+            "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
+        }
+        return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n")
+    }
+
+    func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+        DomainReportExporter.csv(
+            for: workflowReports(from: summary, changedOnly: changedOnly),
+            workflowInsights: summary.workflowInsights
+        )
+    }
+
+    func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
+        let payload = WorkflowExportPayload(
+            workflowName: summary.workflowName,
+            generatedAt: summary.generatedAt,
+            workflowInsights: summary.workflowInsights,
+            reports: workflowReports(from: summary, changedOnly: changedOnly)
+        )
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        encoder.dateEncodingStrategy = .iso8601
+        return try? encoder.encode(payload)
+    }
+
+    func exportWorkflowMarkdown(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+        let reports = workflowReports(from: summary, changedOnly: changedOnly)
+        let base = DomainReportExporter.batchMarkdown(for: reports, title: "\(summary.workflowName) Workflow Export")
+        guard !summary.workflowInsights.isEmpty else { return base }
+        let insightLines = summary.workflowInsights.map {
+            "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
+        }
+        return (["## \(summary.workflowName) Workflow Insights"] + insightLines + ["", base]).joined(separator: "\n")
+    }
+
+    func exportWorkflowData(summary: WorkflowRunSummary, changedOnly: Bool, format: DomainExportFormat) -> Data? {
+        switch format {
+        case .text:
+            return Data(exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
+        case .csv:
+            return Data(exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
+        case .json:
+            return exportWorkflowJSONData(summary: summary, changedOnly: changedOnly)
+        case .markdown:
+            return Data(exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly).utf8)
+        case .pdf:
+            return DomainReportExporter.pdfData(fromMarkdown: exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly))
+        }
+    }
+}
+
+private struct WorkflowExportPayload: Codable {
+    let workflowName: String
+    let generatedAt: Date
+    let workflowInsights: [WorkflowInsight]
+    let reports: [DomainReport]
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 3814704..250d46d 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -110,13 +110,6 @@ private struct BatchLookupPayload {
     let snapshot: LookupSnapshot
 }
 
-private struct WorkflowExportPayload: Codable {
-    let workflowName: String
-    let generatedAt: Date
-    let workflowInsights: [WorkflowInsight]
-    let reports: [DomainReport]
-}
-
 struct PortfolioDomainStatus: Identifiable {
     let trackedDomain: TrackedDomain
     let latestEntry: HistoryEntry?
@@ -1370,21 +1363,6 @@ final class DomainViewModel {
         }
     }
 
-    func exportJSONData() -> Data? {
-        guard let currentReport else { return nil }
-        return try? DomainReportExporter.data(for: currentReport, format: .json)
-    }
-
-    func exportJSONString() -> String? {
-        guard let data = exportJSONData() else { return nil }
-        return String(data: data, encoding: .utf8)
-    }
-
-    func exportSingleReportData(format: DomainExportFormat) -> Data? {
-        guard let currentReport else { return nil }
-        return try? DomainReportExporter.data(for: currentReport, format: format)
-    }
-
     @discardableResult
     func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
         let normalizedDomain = domain
@@ -1533,151 +1511,6 @@ final class DomainViewModel {
         usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
     }
 
-    func exportBatchReportData(format: DomainExportFormat) -> Data? {
-        try? DomainReportExporter.data(
-            for: currentBatchReports(),
-            format: format,
-            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
-        )
-    }
-
-    func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? {
-        try? DomainReportExporter.data(
-            for: reports(for: domains),
-            format: format,
-            title: "Tracked Domains Export"
-        )
-    }
-
-    func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
-        DomainReportExporter.timelineText(
-            for: timelineReports(for: domain),
-            domain: domain,
-            includeDiffSummary: includeDiffSummary
-        )
-    }
-
-    func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
-        try? DomainReportExporter.timelineData(
-            for: timelineReports(for: domain),
-            domain: domain,
-            includeDiffSummary: includeDiffSummary
-        )
-    }
-
-    func exportFullBackupData() -> Data? {
-        try? DomainDataPortabilityService.backupData()
-    }
-
-    func exportPortableTrackedDomainsJSONData() -> Data? {
-        try? DomainDataPortabilityService.trackedDomainsExportData()
-    }
-
-    func exportPortableTrackedDomainsCSV() -> String {
-        DomainDataPortabilityService.trackedDomainsCSV()
-    }
-
-    func exportPortableWorkflowsJSONData() -> Data? {
-        try? DomainDataPortabilityService.workflowsExportData()
-    }
-
-    func exportPortableWorkflowsCSV() -> String {
-        DomainDataPortabilityService.workflowsCSV()
-    }
-
-    func exportPortableHistoryJSONData() -> Data? {
-        try? DomainDataPortabilityService.historyExportData()
-    }
-
-    func prepareDataImport(
-        data: Data,
-        fileName: String,
-        mode: DataPortabilityImportMode
-    ) throws -> DataImportPreview {
-        try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
-    }
-
-    func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
-        let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
-        refreshPersistedData()
-        portabilityStatusMessage = result.summary
-        CloudSyncService.shared.markAppSettingsChanged()
-        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
-        CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .imported)
-        return result
-    }
-
-    func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
-        DomainDataPortabilityService.saveAppSettings(
-            AppSettingsSnapshot(
-                recentSearches: recentSearches,
-                savedDomains: savedDomains,
-                resolverURLString: resolverURLString,
-                appDensityRawValue: appDensityRawValue
-            )
-        )
-        CloudSyncService.shared.markAppSettingsChanged()
-        refreshDataLifecycleSummary()
-    }
-
-    func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
-        let reports = workflowReports(from: summary, changedOnly: changedOnly)
-        let base = DomainReportExporter.batchText(
-            for: reports,
-            title: "\(summary.workflowName) Workflow Export"
-        )
-        guard !summary.workflowInsights.isEmpty else { return base }
-        let insightLines = summary.workflowInsights.map {
-            "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
-        }
-        return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n")
-    }
-
-    func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
-        DomainReportExporter.csv(
-            for: workflowReports(from: summary, changedOnly: changedOnly),
-            workflowInsights: summary.workflowInsights
-        )
-    }
-
-    func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
-        let payload = WorkflowExportPayload(
-            workflowName: summary.workflowName,
-            generatedAt: summary.generatedAt,
-            workflowInsights: summary.workflowInsights,
-            reports: workflowReports(from: summary, changedOnly: changedOnly)
-        )
-        let encoder = JSONEncoder()
-        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
-        encoder.dateEncodingStrategy = .iso8601
-        return try? encoder.encode(payload)
-    }
-
-    func exportWorkflowMarkdown(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
-        let reports = workflowReports(from: summary, changedOnly: changedOnly)
-        let base = DomainReportExporter.batchMarkdown(for: reports, title: "\(summary.workflowName) Workflow Export")
-        guard !summary.workflowInsights.isEmpty else { return base }
-        let insightLines = summary.workflowInsights.map {
-            "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
-        }
-        return (["## \(summary.workflowName) Workflow Insights"] + insightLines + ["", base]).joined(separator: "\n")
-    }
-
-    func exportWorkflowData(summary: WorkflowRunSummary, changedOnly: Bool, format: DomainExportFormat) -> Data? {
-        switch format {
-        case .text:
-            return Data(exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
-        case .csv:
-            return Data(exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
-        case .json:
-            return exportWorkflowJSONData(summary: summary, changedOnly: changedOnly)
-        case .markdown:
-            return Data(exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly).utf8)
-        case .pdf:
-            return DomainReportExporter.pdfData(fromMarkdown: exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly))
-        }
-    }
-
     private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
         let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
         let previous = previousSnapshot(
@@ -3488,13 +3321,13 @@ final class DomainViewModel {
         }
     }
 
-    private func currentBatchReports() -> [DomainReport] {
+    func currentBatchReports() -> [DomainReport] {
         currentBatchResultEntries.map { entry in
             report(for: entry, workflowContext: activeWorkflowContext)
         }
     }
 
-    private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
+    func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
         let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
         return filteredResults.compactMap { result in
             guard let entry = historyEntry(for: result) else { return nil }
@@ -3509,7 +3342,7 @@ final class DomainViewModel {
         }
     }
 
-    private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
+    func reports(for domains: [TrackedDomain]) -> [DomainReport] {
         let latestEntries = latestSnapshots(for: domains)
 
         return domains.map { trackedDomain in
@@ -3524,7 +3357,7 @@ final class DomainViewModel {
         }
     }
 
-    private func timelineReports(for domain: String) -> [DomainReport] {
+    func timelineReports(for domain: String) -> [DomainReport] {
         historyEntries(for: domain).map { report(for: $0) }
     }