From e5d6c6c30df78e1d9b07088c256421aa663cb173 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 24 Jul 2026 23:59:07 -0500 Subject: 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(-) create mode 100644 DomainDig/DomainViewModel+Export.swift (limited to 'DomainDig') 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) } } -- cgit v1.2.3