Commit 01c3d342f6
Unsigned
Layout: unified · split
DomainDig/ContentView.swift +6 −36
| @@ -710,50 +710,20 @@ struct ContentView: View { | ||
| 710 | 710 | } |
| 711 | 711 | |
| 712 | 712 | private func shareSingleResults(format: DomainExportFormat) { |
| 713 | let (filename, data) = exportPayload( | |
| 714 | prefix: "domaindig_single", | |
| 715 | format: format, | |
| 716 | text: viewModel.exportText(), | |
| 717 | csv: viewModel.exportCSV(), | |
| 718 | json: viewModel.exportJSONData() | |
| 719 | ) | |
| 720 | ExportPresenter.share(filename: filename, data: data) | |
| 713 | guard let data = viewModel.exportSingleReportData(format: format) else { return } | |
| 714 | ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data) | |
| 721 | 715 | } |
| 722 | 716 | |
| 723 | 717 | private func shareBatchResults(format: DomainExportFormat) { |
| 724 | let (filename, data) = exportPayload( | |
| 725 | prefix: "domaindig_batch", | |
| 726 | format: format, | |
| 727 | text: viewModel.exportBatchText(), | |
| 728 | csv: viewModel.exportBatchCSV(), | |
| 729 | json: viewModel.exportBatchJSONData() | |
| 730 | ) | |
| 731 | ExportPresenter.share(filename: filename, data: data) | |
| 718 | guard let data = viewModel.exportBatchReportData(format: format) else { return } | |
| 719 | ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data) | |
| 732 | 720 | } |
| 733 | 721 | |
| 734 | private func exportPayload( | |
| 735 | prefix: String, | |
| 736 | format: DomainExportFormat, | |
| 737 | text: String, | |
| 738 | csv: String, | |
| 739 | json: Data? | |
| 740 | ) -> (String, Data) { | |
| 722 | private func exportFilename(prefix: String, format: DomainExportFormat) -> String { | |
| 741 | 723 | let formatter = DateFormatter() |
| 742 | 724 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 743 | 725 | let timestamp = formatter.string(from: Date()) |
| 744 | let filename = "\(timestamp)_\(prefix).\(format.fileExtension)" | |
| 745 | let data: Data | |
| 746 | ||
| 747 | switch format { | |
| 748 | case .text: | |
| 749 | data = Data(text.utf8) | |
| 750 | case .csv: | |
| 751 | data = Data(csv.utf8) | |
| 752 | case .json: | |
| 753 | data = json ?? Data("[]".utf8) | |
| 754 | } | |
| 755 | ||
| 756 | return (filename, data) | |
| 726 | return "\(timestamp)_\(prefix).\(format.fileExtension)" | |
| 757 | 727 | } |
| 758 | 728 | |
| 759 | 729 | private var defaultCollapsedSections: Set<ResultSection> { |
DomainDig/DomainViewModel.swift +34 −36
| @@ -1521,16 +1521,6 @@ final class DomainViewModel { | ||
| 1521 | 1521 | } |
| 1522 | 1522 | } |
| 1523 | 1523 | |
| 1524 | func exportText() -> String { | |
| 1525 | guard let currentReport else { return "No results available." } | |
| 1526 | return DomainReportExporter.text(for: currentReport) | |
| 1527 | } | |
| 1528 | ||
| 1529 | func exportCSV() -> String { | |
| 1530 | guard let currentReport else { return DomainReportExporter.csv(for: []) } | |
| 1531 | return DomainReportExporter.csv(for: [currentReport]) | |
| 1532 | } | |
| 1533 | ||
| 1534 | 1524 | func exportJSONData() -> Data? { |
| 1535 | 1525 | guard let currentReport else { return nil } |
| 1536 | 1526 | return try? DomainReportExporter.data(for: currentReport, format: .json) |
| @@ -1541,6 +1531,11 @@ final class DomainViewModel { | ||
| 1541 | 1531 | return String(data: data, encoding: .utf8) |
| 1542 | 1532 | } |
| 1543 | 1533 | |
| 1534 | func exportSingleReportData(format: DomainExportFormat) -> Data? { | |
| 1535 | guard let currentReport else { return nil } | |
| 1536 | return try? DomainReportExporter.data(for: currentReport, format: format) | |
| 1537 | } | |
| 1538 | ||
| 1544 | 1539 | func audits(for domain: String) -> [AuditSession] { |
| 1545 | 1540 | auditSessions |
| 1546 | 1541 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } |
| @@ -1792,40 +1787,18 @@ final class DomainViewModel { | ||
| 1792 | 1787 | usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) }) |
| 1793 | 1788 | } |
| 1794 | 1789 | |
| 1795 | func exportBatchText() -> String { | |
| 1796 | DomainReportExporter.batchText( | |
| 1797 | for: currentBatchReports(), | |
| 1798 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" | |
| 1799 | ) | |
| 1800 | } | |
| 1801 | ||
| 1802 | func exportBatchCSV() -> String { | |
| 1803 | DomainReportExporter.csv(for: currentBatchReports()) | |
| 1804 | } | |
| 1805 | ||
| 1806 | func exportBatchJSONData() -> Data? { | |
| 1790 | func exportBatchReportData(format: DomainExportFormat) -> Data? { | |
| 1807 | 1791 | try? DomainReportExporter.data( |
| 1808 | 1792 | for: currentBatchReports(), |
| 1809 | format: .json, | |
| 1793 | format: format, | |
| 1810 | 1794 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" |
| 1811 | 1795 | ) |
| 1812 | 1796 | } |
| 1813 | 1797 | |
| 1814 | func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { | |
| 1815 | DomainReportExporter.csv(for: reports(for: domains)) | |
| 1816 | } | |
| 1817 | ||
| 1818 | func exportTrackedDomainsText(domains: [TrackedDomain]) -> String { | |
| 1819 | DomainReportExporter.batchText( | |
| 1820 | for: reports(for: domains), | |
| 1821 | title: "Tracked Domains Export" | |
| 1822 | ) | |
| 1823 | } | |
| 1824 | ||
| 1825 | func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? { | |
| 1798 | func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? { | |
| 1826 | 1799 | try? DomainReportExporter.data( |
| 1827 | 1800 | for: reports(for: domains), |
| 1828 | format: .json, | |
| 1801 | format: format, | |
| 1829 | 1802 | title: "Tracked Domains Export" |
| 1830 | 1803 | ) |
| 1831 | 1804 | } |
| @@ -1934,6 +1907,31 @@ final class DomainViewModel { | ||
| 1934 | 1907 | return try? encoder.encode(payload) |
| 1935 | 1908 | } |
| 1936 | 1909 | |
| 1910 | func exportWorkflowMarkdown(summary: WorkflowRunSummary, changedOnly: Bool) -> String { | |
| 1911 | let reports = workflowReports(from: summary, changedOnly: changedOnly) | |
| 1912 | let base = DomainReportExporter.batchMarkdown(for: reports, title: "\(summary.workflowName) Workflow Export") | |
| 1913 | guard !summary.workflowInsights.isEmpty else { return base } | |
| 1914 | let insightLines = summary.workflowInsights.map { | |
| 1915 | "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))" | |
| 1916 | } | |
| 1917 | return (["## \(summary.workflowName) Workflow Insights"] + insightLines + ["", base]).joined(separator: "\n") | |
| 1918 | } | |
| 1919 | ||
| 1920 | func exportWorkflowData(summary: WorkflowRunSummary, changedOnly: Bool, format: DomainExportFormat) -> Data? { | |
| 1921 | switch format { | |
| 1922 | case .text: | |
| 1923 | return Data(exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8) | |
| 1924 | case .csv: | |
| 1925 | return Data(exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8) | |
| 1926 | case .json: | |
| 1927 | return exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) | |
| 1928 | case .markdown: | |
| 1929 | return Data(exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly).utf8) | |
| 1930 | case .pdf: | |
| 1931 | return DomainReportExporter.pdfData(fromMarkdown: exportWorkflowMarkdown(summary: summary, changedOnly: changedOnly)) | |
| 1932 | } | |
| 1933 | } | |
| 1934 | ||
| 1937 | 1935 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { |
| 1938 | 1936 | let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) |
| 1939 | 1937 | let previous = previousSnapshot( |
DomainDig/WatchlistView.swift +2 −9
| @@ -388,15 +388,8 @@ struct WatchlistView: View { | ||
| 388 | 388 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 389 | 389 | let timestamp = formatter.string(from: Date()) |
| 390 | 390 | let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)" |
| 391 | let data: Data | |
| 392 | ||
| 393 | switch format { | |
| 394 | case .text: | |
| 395 | data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8) | |
| 396 | case .csv: | |
| 397 | data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8) | |
| 398 | case .json: | |
| 399 | data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8) | |
| 391 | guard let data = viewModel.exportTrackedDomainsData(domains: viewModel.filteredTrackedDomains, format: format) else { | |
| 392 | return | |
| 400 | 393 | } |
| 401 | 394 | |
| 402 | 395 | ExportPresenter.share(filename: filename, data: data) |
DomainDig/WorkflowsView.swift +4 −18
| @@ -383,15 +383,8 @@ struct WorkflowDetailView: View { | ||
| 383 | 383 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 384 | 384 | let timestamp = formatter.string(from: Date()) |
| 385 | 385 | let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)" |
| 386 | let data: Data | |
| 387 | ||
| 388 | switch format { | |
| 389 | case .text: | |
| 390 | data = Data(viewModel.exportWorkflowText(summary: latestSummary, changedOnly: changedOnly).utf8) | |
| 391 | case .csv: | |
| 392 | data = Data(viewModel.exportWorkflowCSV(summary: latestSummary, changedOnly: changedOnly).utf8) | |
| 393 | case .json: | |
| 394 | data = viewModel.exportWorkflowJSONData(summary: latestSummary, changedOnly: changedOnly) ?? Data("[]".utf8) | |
| 386 | guard let data = viewModel.exportWorkflowData(summary: latestSummary, changedOnly: changedOnly, format: format) else { | |
| 387 | return | |
| 395 | 388 | } |
| 396 | 389 | |
| 397 | 390 | ExportPresenter.share(filename: filename, data: data) |
| @@ -586,15 +579,8 @@ struct WorkflowRunSummaryView: View { | ||
| 586 | 579 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 587 | 580 | let timestamp = formatter.string(from: Date()) |
| 588 | 581 | let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)" |
| 589 | let data: Data | |
| 590 | ||
| 591 | switch format { | |
| 592 | case .text: | |
| 593 | data = Data(viewModel.exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8) | |
| 594 | case .csv: | |
| 595 | data = Data(viewModel.exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8) | |
| 596 | case .json: | |
| 597 | data = viewModel.exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) ?? Data("[]".utf8) | |
| 582 | guard let data = viewModel.exportWorkflowData(summary: summary, changedOnly: changedOnly, format: format) else { | |
| 583 | return | |
| 598 | 584 | } |
| 599 | 585 | |
| 600 | 586 | ExportPresenter.share(filename: filename, data: data) |
DomainReportExporter.swift +101 −1
| @@ -1,11 +1,28 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | enum DomainExportFormat: String { | |
| 3 | #if canImport(UIKit) | |
| 4 | import UIKit | |
| 5 | #endif | |
| 6 | ||
| 7 | enum DomainExportFormat: String, CaseIterable, Identifiable { | |
| 4 | 8 | case text = "txt" |
| 5 | 9 | case csv = "csv" |
| 6 | 10 | case json = "json" |
| 11 | case markdown = "md" | |
| 12 | case pdf = "pdf" | |
| 7 | 13 | |
| 14 | var id: String { rawValue } | |
| 8 | 15 | var fileExtension: String { rawValue } |
| 16 | ||
| 17 | var title: String { | |
| 18 | switch self { | |
| 19 | case .text: return "TXT" | |
| 20 | case .csv: return "CSV" | |
| 21 | case .json: return "JSON" | |
| 22 | case .markdown: return "Markdown" | |
| 23 | case .pdf: return "PDF" | |
| 24 | } | |
| 25 | } | |
| 9 | 26 | } |
| 10 | 27 | |
| 11 | 28 | enum DomainReportExporter { |
| @@ -17,6 +34,10 @@ enum DomainReportExporter { | ||
| 17 | 34 | return Data(csv(for: [report]).utf8) |
| 18 | 35 | case .json: |
| 19 | 36 | return try jsonEncoder.encode(report) |
| 37 | case .markdown: | |
| 38 | return Data(markdown(for: report).utf8) | |
| 39 | case .pdf: | |
| 40 | return pdfData(fromMarkdown: markdown(for: report)) | |
| 20 | 41 | } |
| 21 | 42 | } |
| 22 | 43 | |
| @@ -28,7 +49,86 @@ enum DomainReportExporter { | ||
| 28 | 49 | return Data(csv(for: reports).utf8) |
| 29 | 50 | case .json: |
| 30 | 51 | return try jsonEncoder.encode(reports) |
| 52 | case .markdown: | |
| 53 | return Data(batchMarkdown(for: reports, title: title).utf8) | |
| 54 | case .pdf: | |
| 55 | return pdfData(fromMarkdown: batchMarkdown(for: reports, title: title)) | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | /// Renders `text(for:)`'s content as Markdown: the leading title becomes an | |
| 60 | /// H1, `appendSection`'s "Title\n----" underlines become H2 headers, and | |
| 61 | /// other non-empty lines that aren't already list items become bullets. | |
| 62 | /// This reuses the exact same section content as the text export rather | |
| 63 | /// than re-deriving it, so the two formats never drift. | |
| 64 | static func markdown(for report: DomainReport) -> String { | |
| 65 | markdown(fromPlainText: text(for: report), title: "DomainDig Report") | |
| 66 | } | |
| 67 | ||
| 68 | static func batchMarkdown(for reports: [DomainReport], title: String) -> String { | |
| 69 | markdown(fromPlainText: batchText(for: reports, title: title), title: title) | |
| 70 | } | |
| 71 | ||
| 72 | private static func markdown(fromPlainText text: String, title: String) -> String { | |
| 73 | let lines = text.components(separatedBy: "\n") | |
| 74 | var output: [String] = ["# \(title)", ""] | |
| 75 | var index = 0 | |
| 76 | while index < lines.count { | |
| 77 | let line = lines[index] | |
| 78 | if index == 0, line == title { | |
| 79 | index += 1 | |
| 80 | continue | |
| 81 | } | |
| 82 | if index + 1 < lines.count, !line.isEmpty, lines[index + 1] == String(repeating: "-", count: line.count) { | |
| 83 | output.append("") | |
| 84 | output.append("## \(line)") | |
| 85 | index += 2 | |
| 86 | continue | |
| 87 | } | |
| 88 | if line.isEmpty || line.hasPrefix("-") || line.hasPrefix(" ") { | |
| 89 | output.append(line) | |
| 90 | } else { | |
| 91 | output.append("- \(line)") | |
| 92 | } | |
| 93 | index += 1 | |
| 94 | } | |
| 95 | return output.joined(separator: "\n") | |
| 96 | } | |
| 97 | ||
| 98 | /// Renders Markdown as a simple monospaced multi-page PDF. Foundation-only | |
| 99 | /// consumers (no UIKit available) get the Markdown bytes back instead. | |
| 100 | static func pdfData(fromMarkdown markdown: String) -> Data { | |
| 101 | #if canImport(UIKit) | |
| 102 | let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792)) | |
| 103 | return renderer.pdfData { context in | |
| 104 | let lines = markdown.components(separatedBy: .newlines) | |
| 105 | let paragraphStyle = NSMutableParagraphStyle() | |
| 106 | paragraphStyle.lineBreakMode = .byWordWrapping | |
| 107 | let attributes: [NSAttributedString.Key: Any] = [ | |
| 108 | .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular), | |
| 109 | .paragraphStyle: paragraphStyle | |
| 110 | ] | |
| 111 | ||
| 112 | var yOffset: CGFloat = 36 | |
| 113 | context.beginPage() | |
| 114 | ||
| 115 | for line in lines { | |
| 116 | if yOffset > 744 { | |
| 117 | context.beginPage() | |
| 118 | yOffset = 36 | |
| 119 | } | |
| 120 | ||
| 121 | let renderedLine = NSString(string: line.isEmpty ? " " : line) | |
| 122 | renderedLine.draw( | |
| 123 | in: CGRect(x: 36, y: yOffset, width: 540, height: 22), | |
| 124 | withAttributes: attributes | |
| 125 | ) | |
| 126 | yOffset += 16 | |
| 127 | } | |
| 31 | 128 | } |
| 129 | #else | |
| 130 | return Data(markdown.utf8) | |
| 131 | #endif | |
| 32 | 132 | } |
| 33 | 133 | |
| 34 | 134 | static func text(for report: DomainReport) -> String { |