Commit e5d6c6c30d

e5d6c6c30df78e1d9b07088c256421aa663cb173

parent: 7805888980

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 04:59 UTC

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).

Layout: unified · split

DomainDig/DomainViewModel+Export.swift added +180
@@ -0,0 +1,180 @@
1import Foundation
2
3/// Export and data-portability surface of `DomainViewModel`: rendering the
4/// current report, batch results, tracked domains, timelines, and workflow runs
5/// into the shareable formats, plus the full-backup / portable-slice exporters
6/// and the import + app-settings persistence entry points.
7///
8/// These are thin adapters over `DomainReportExporter` and
9/// `DomainDataPortabilityService`. The report-projection helpers they call
10/// (`currentBatchReports`, `reports(for:)`, `timelineReports`, `workflowReports`)
11/// remain on the main type — they belong to the report layer shared with
12/// inspection, not to export.
13extension DomainViewModel {
14 func exportJSONData() -> Data? {
15 guard let currentReport else { return nil }
16 return try? DomainReportExporter.data(for: currentReport, format: .json)
17 }
18
19 func exportJSONString() -> String? {
20 guard let data = exportJSONData() else { return nil }
21 return String(data: data, encoding: .utf8)
22 }
23
24 func exportSingleReportData(format: DomainExportFormat) -> Data? {
25 guard let currentReport else { return nil }
26 return try? DomainReportExporter.data(for: currentReport, format: format)
27 }
28
29 func exportBatchReportData(format: DomainExportFormat) -> Data? {
30 try? DomainReportExporter.data(
31 for: currentBatchReports(),
32 format: format,
33 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
34 )
35 }
36
37 func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? {
38 try? DomainReportExporter.data(
39 for: reports(for: domains),
40 format: format,
41 title: "Tracked Domains Export"
42 )
43 }
44
45 func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
46 DomainReportExporter.timelineText(
47 for: timelineReports(for: domain),
48 domain: domain,
49 includeDiffSummary: includeDiffSummary
50 )
51 }
52
53 func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
54 try? DomainReportExporter.timelineData(
55 for: timelineReports(for: domain),
56 domain: domain,
57 includeDiffSummary: includeDiffSummary
58 )
59 }
60
61 func exportFullBackupData() -> Data? {
62 try? DomainDataPortabilityService.backupData()
63 }
64
65 func exportPortableTrackedDomainsJSONData() -> Data? {
66 try? DomainDataPortabilityService.trackedDomainsExportData()
67 }
68
69 func exportPortableTrackedDomainsCSV() -> String {
70 DomainDataPortabilityService.trackedDomainsCSV()
71 }
72
73 func exportPortableWorkflowsJSONData() -> Data? {
74 try? DomainDataPortabilityService.workflowsExportData()
75 }
76
77 func exportPortableWorkflowsCSV() -> String {
78 DomainDataPortabilityService.workflowsCSV()
79 }
80
81 func exportPortableHistoryJSONData() -> Data? {
82 try? DomainDataPortabilityService.historyExportData()
83 }
84
85 func prepareDataImport(
86 data: Data,
87 fileName: String,
88 mode: DataPortabilityImportMode
89 ) throws -> DataImportPreview {
90 try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
91 }
92
93 func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
94 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
95 refreshPersistedData()
96 portabilityStatusMessage = result.summary
97 CloudSyncService.shared.markAppSettingsChanged()
98 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
99 CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .imported)
100 return result
101 }
102
103 func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
104 DomainDataPortabilityService.saveAppSettings(
105 AppSettingsSnapshot(
106 recentSearches: recentSearches,
107 savedDomains: savedDomains,
108 resolverURLString: resolverURLString,
109 appDensityRawValue: appDensityRawValue
110 )
111 )
112 CloudSyncService.shared.markAppSettingsChanged()
113 refreshDataLifecycleSummary()
114 }
115
116 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
117 let reports = workflowReports(from: summary, changedOnly: changedOnly)
118 let base = DomainReportExporter.batchText(
119 for: reports,
120 title: "\(summary.workflowName) Workflow Export"
121 )
122 guard !summary.workflowInsights.isEmpty else { return base }
123 let insightLines = summary.workflowInsights.map {
124 "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
125 }
126 return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n")
127 }
128
129 func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
130 DomainReportExporter.csv(
131 for: workflowReports(from: summary, changedOnly: changedOnly),
132 workflowInsights: summary.workflowInsights
133 )
134 }
135
136 func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
137 let payload = WorkflowExportPayload(
138 workflowName: summary.workflowName,
139 generatedAt: summary.generatedAt,
140 workflowInsights: summary.workflowInsights,
141 reports: workflowReports(from: summary, changedOnly: changedOnly)
142 )
143 let encoder = JSONEncoder()
144 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
145 encoder.dateEncodingStrategy = .iso8601
146 return try? encoder.encode(payload)
147 }
148
149 func exportWorkflowMarkdown(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
150 let reports = workflowReports(from: summary, changedOnly: changedOnly)
151 let base = DomainReportExporter.batchMarkdown(for: reports, title: "\(summary.workflowName) Workflow Export")
152 guard !summary.workflowInsights.isEmpty else { return base }
153 let insightLines = summary.workflowInsights.map {
154 "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
155 }
156 return (["## \(summary.workflowName) Workflow Insights"] + insightLines + ["", base]).joined(separator: "\n")
157 }
158
159 func exportWorkflowData(summary: WorkflowRunSummary, changedOnly: Bool, format: DomainExportFormat) -> Data? {
160 switch format {
161 case .text:
162 return Data(exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
163 case .csv:
164 return Data(exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
165 case .json:
166 return exportWorkflowJSONData(summary: summary, changedOnly: changedOnly)
167 case .markdown:
168 return Data(exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly).utf8)
169 case .pdf:
170 return DomainReportExporter.pdfData(fromMarkdown: exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly))
171 }
172 }
173}
174
175private struct WorkflowExportPayload: Codable {
176 let workflowName: String
177 let generatedAt: Date
178 let workflowInsights: [WorkflowInsight]
179 let reports: [DomainReport]
180}
DomainDig/DomainViewModel.swift +4 −171
@@ -110,13 +110,6 @@ private struct BatchLookupPayload {
110 let snapshot: LookupSnapshot 110 let snapshot: LookupSnapshot
111} 111}
112 112
113private struct WorkflowExportPayload: Codable {
114 let workflowName: String
115 let generatedAt: Date
116 let workflowInsights: [WorkflowInsight]
117 let reports: [DomainReport]
118}
119
120struct PortfolioDomainStatus: Identifiable { 113struct PortfolioDomainStatus: Identifiable {
121 let trackedDomain: TrackedDomain 114 let trackedDomain: TrackedDomain
122 let latestEntry: HistoryEntry? 115 let latestEntry: HistoryEntry?
@@ -1370,21 +1363,6 @@ final class DomainViewModel {
1370 } 1363 }
1371 } 1364 }
1372 1365
1373 func exportJSONData() -> Data? {
1374 guard let currentReport else { return nil }
1375 return try? DomainReportExporter.data(for: currentReport, format: .json)
1376 }
1377
1378 func exportJSONString() -> String? {
1379 guard let data = exportJSONData() else { return nil }
1380 return String(data: data, encoding: .utf8)
1381 }
1382
1383 func exportSingleReportData(format: DomainExportFormat) -> Data? {
1384 guard let currentReport else { return nil }
1385 return try? DomainReportExporter.data(for: currentReport, format: format)
1386 }
1387
1388 @discardableResult 1366 @discardableResult
1389 func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { 1367 func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
1390 let normalizedDomain = domain 1368 let normalizedDomain = domain
@@ -1533,151 +1511,6 @@ final class DomainViewModel {
1533 usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) }) 1511 usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
1534 } 1512 }
1535 1513
1536 func exportBatchReportData(format: DomainExportFormat) -> Data? {
1537 try? DomainReportExporter.data(
1538 for: currentBatchReports(),
1539 format: format,
1540 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
1541 )
1542 }
1543
1544 func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? {
1545 try? DomainReportExporter.data(
1546 for: reports(for: domains),
1547 format: format,
1548 title: "Tracked Domains Export"
1549 )
1550 }
1551
1552 func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
1553 DomainReportExporter.timelineText(
1554 for: timelineReports(for: domain),
1555 domain: domain,
1556 includeDiffSummary: includeDiffSummary
1557 )
1558 }
1559
1560 func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
1561 try? DomainReportExporter.timelineData(
1562 for: timelineReports(for: domain),
1563 domain: domain,
1564 includeDiffSummary: includeDiffSummary
1565 )
1566 }
1567
1568 func exportFullBackupData() -> Data? {
1569 try? DomainDataPortabilityService.backupData()
1570 }
1571
1572 func exportPortableTrackedDomainsJSONData() -> Data? {
1573 try? DomainDataPortabilityService.trackedDomainsExportData()
1574 }
1575
1576 func exportPortableTrackedDomainsCSV() -> String {
1577 DomainDataPortabilityService.trackedDomainsCSV()
1578 }
1579
1580 func exportPortableWorkflowsJSONData() -> Data? {
1581 try? DomainDataPortabilityService.workflowsExportData()
1582 }
1583
1584 func exportPortableWorkflowsCSV() -> String {
1585 DomainDataPortabilityService.workflowsCSV()
1586 }
1587
1588 func exportPortableHistoryJSONData() -> Data? {
1589 try? DomainDataPortabilityService.historyExportData()
1590 }
1591
1592 func prepareDataImport(
1593 data: Data,
1594 fileName: String,
1595 mode: DataPortabilityImportMode
1596 ) throws -> DataImportPreview {
1597 try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
1598 }
1599
1600 func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
1601 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
1602 refreshPersistedData()
1603 portabilityStatusMessage = result.summary
1604 CloudSyncService.shared.markAppSettingsChanged()
1605 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
1606 CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .imported)
1607 return result
1608 }
1609
1610 func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
1611 DomainDataPortabilityService.saveAppSettings(
1612 AppSettingsSnapshot(
1613 recentSearches: recentSearches,
1614 savedDomains: savedDomains,
1615 resolverURLString: resolverURLString,
1616 appDensityRawValue: appDensityRawValue
1617 )
1618 )
1619 CloudSyncService.shared.markAppSettingsChanged()
1620 refreshDataLifecycleSummary()
1621 }
1622
1623 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1624 let reports = workflowReports(from: summary, changedOnly: changedOnly)
1625 let base = DomainReportExporter.batchText(
1626 for: reports,
1627 title: "\(summary.workflowName) Workflow Export"
1628 )
1629 guard !summary.workflowInsights.isEmpty else { return base }
1630 let insightLines = summary.workflowInsights.map {
1631 "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
1632 }
1633 return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n")
1634 }
1635
1636 func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1637 DomainReportExporter.csv(
1638 for: workflowReports(from: summary, changedOnly: changedOnly),
1639 workflowInsights: summary.workflowInsights
1640 )
1641 }
1642
1643 func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
1644 let payload = WorkflowExportPayload(
1645 workflowName: summary.workflowName,
1646 generatedAt: summary.generatedAt,
1647 workflowInsights: summary.workflowInsights,
1648 reports: workflowReports(from: summary, changedOnly: changedOnly)
1649 )
1650 let encoder = JSONEncoder()
1651 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1652 encoder.dateEncodingStrategy = .iso8601
1653 return try? encoder.encode(payload)
1654 }
1655
1656 func exportWorkflowMarkdown(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1657 let reports = workflowReports(from: summary, changedOnly: changedOnly)
1658 let base = DomainReportExporter.batchMarkdown(for: reports, title: "\(summary.workflowName) Workflow Export")
1659 guard !summary.workflowInsights.isEmpty else { return base }
1660 let insightLines = summary.workflowInsights.map {
1661 "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
1662 }
1663 return (["## \(summary.workflowName) Workflow Insights"] + insightLines + ["", base]).joined(separator: "\n")
1664 }
1665
1666 func exportWorkflowData(summary: WorkflowRunSummary, changedOnly: Bool, format: DomainExportFormat) -> Data? {
1667 switch format {
1668 case .text:
1669 return Data(exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
1670 case .csv:
1671 return Data(exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
1672 case .json:
1673 return exportWorkflowJSONData(summary: summary, changedOnly: changedOnly)
1674 case .markdown:
1675 return Data(exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly).utf8)
1676 case .pdf:
1677 return DomainReportExporter.pdfData(fromMarkdown: exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly))
1678 }
1679 }
1680
1681 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { 1514 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
1682 let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) 1515 let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
1683 let previous = previousSnapshot( 1516 let previous = previousSnapshot(
@@ -3488,13 +3321,13 @@ final class DomainViewModel {
3488 } 3321 }
3489 } 3322 }
3490 3323
3491 private func currentBatchReports() -> [DomainReport] { 3324 func currentBatchReports() -> [DomainReport] {
3492 currentBatchResultEntries.map { entry in 3325 currentBatchResultEntries.map { entry in
3493 report(for: entry, workflowContext: activeWorkflowContext) 3326 report(for: entry, workflowContext: activeWorkflowContext)
3494 } 3327 }
3495 } 3328 }
3496 3329
3497 private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] { 3330 func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
3498 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results 3331 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
3499 return filteredResults.compactMap { result in 3332 return filteredResults.compactMap { result in
3500 guard let entry = historyEntry(for: result) else { return nil } 3333 guard let entry = historyEntry(for: result) else { return nil }
@@ -3509,7 +3342,7 @@ final class DomainViewModel {
3509 } 3342 }
3510 } 3343 }
3511 3344
3512 private func reports(for domains: [TrackedDomain]) -> [DomainReport] { 3345 func reports(for domains: [TrackedDomain]) -> [DomainReport] {
3513 let latestEntries = latestSnapshots(for: domains) 3346 let latestEntries = latestSnapshots(for: domains)
3514 3347
3515 return domains.map { trackedDomain in 3348 return domains.map { trackedDomain in
@@ -3524,7 +3357,7 @@ final class DomainViewModel {
3524 } 3357 }
3525 } 3358 }
3526 3359
3527 private func timelineReports(for domain: String) -> [DomainReport] { 3360 func timelineReports(for domain: String) -> [DomainReport] {
3528 historyEntries(for: domain).map { report(for: $0) } 3361 historyEntries(for: domain).map { report(for: $0) }
3529 } 3362 }
3530 3363