Commit e5d6c6c30d
Unsigned
Layout: unified · split
DomainDig/DomainViewModel+Export.swift added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | import 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. | |
| 13 | extension 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 | ||
| 175 | private 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 | 110 | let snapshot: LookupSnapshot |
| 111 | 111 | } |
| 112 | 112 | |
| 113 | private struct WorkflowExportPayload: Codable { | |
| 114 | let workflowName: String | |
| 115 | let generatedAt: Date | |
| 116 | let workflowInsights: [WorkflowInsight] | |
| 117 | let reports: [DomainReport] | |
| 118 | } | |
| 119 | ||
| 120 | 113 | struct PortfolioDomainStatus: Identifiable { |
| 121 | 114 | let trackedDomain: TrackedDomain |
| 122 | 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 | 1366 | @discardableResult |
| 1389 | 1367 | func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { |
| 1390 | 1368 | let normalizedDomain = domain |
| @@ -1533,151 +1511,6 @@ final class DomainViewModel { | ||
| 1533 | 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 | 1514 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { |
| 1682 | 1515 | let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) |
| 1683 | 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 | 3325 | currentBatchResultEntries.map { entry in |
| 3493 | 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 | 3331 | let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results |
| 3499 | 3332 | return filteredResults.compactMap { result in |
| 3500 | 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 | 3346 | let latestEntries = latestSnapshots(for: domains) |
| 3514 | 3347 | |
| 3515 | 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 | 3361 | historyEntries(for: domain).map { report(for: $0) } |
| 3529 | 3362 | } |
| 3530 | 3363 | |