Commit 01c3d342f6
Unsigned
Layout: unified · split
DomainDig/ContentView.swift +6 −36
| @@ -710,50 +710,20 @@ struct ContentView: View { | |||
| 710 | } | 710 | } |
| 711 | 711 | ||
| 712 | private func shareSingleResults(format: DomainExportFormat) { | 712 | private func shareSingleResults(format: DomainExportFormat) { |
| 713 | let (filename, data) = exportPayload( | 713 | guard let data = viewModel.exportSingleReportData(format: format) else { return } |
| 714 | prefix: "domaindig_single", | 714 | ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data) |
| 715 | format: format, | ||
| 716 | text: viewModel.exportText(), | ||
| 717 | csv: viewModel.exportCSV(), | ||
| 718 | json: viewModel.exportJSONData() | ||
| 719 | ) | ||
| 720 | ExportPresenter.share(filename: filename, data: data) | ||
| 721 | } | 715 | } |
| 722 | 716 | ||
| 723 | private func shareBatchResults(format: DomainExportFormat) { | 717 | private func shareBatchResults(format: DomainExportFormat) { |
| 724 | let (filename, data) = exportPayload( | 718 | guard let data = viewModel.exportBatchReportData(format: format) else { return } |
| 725 | prefix: "domaindig_batch", | 719 | ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data) |
| 726 | format: format, | ||
| 727 | text: viewModel.exportBatchText(), | ||
| 728 | csv: viewModel.exportBatchCSV(), | ||
| 729 | json: viewModel.exportBatchJSONData() | ||
| 730 | ) | ||
| 731 | ExportPresenter.share(filename: filename, data: data) | ||
| 732 | } | 720 | } |
| 733 | 721 | ||
| 734 | private func exportPayload( | 722 | private func exportFilename(prefix: String, format: DomainExportFormat) -> String { |
| 735 | prefix: String, | ||
| 736 | format: DomainExportFormat, | ||
| 737 | text: String, | ||
| 738 | csv: String, | ||
| 739 | json: Data? | ||
| 740 | ) -> (String, Data) { | ||
| 741 | let formatter = DateFormatter() | 723 | let formatter = DateFormatter() |
| 742 | formatter.dateFormat = "yyyyMMdd_HHmmss" | 724 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 743 | let timestamp = formatter.string(from: Date()) | 725 | let timestamp = formatter.string(from: Date()) |
| 744 | let filename = "\(timestamp)_\(prefix).\(format.fileExtension)" | 726 | return "\(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) | ||
| 757 | } | 727 | } |
| 758 | 728 | ||
| 759 | private var defaultCollapsedSections: Set<ResultSection> { | 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 | func exportJSONData() -> Data? { | 1524 | func exportJSONData() -> Data? { |
| 1535 | guard let currentReport else { return nil } | 1525 | guard let currentReport else { return nil } |
| 1536 | return try? DomainReportExporter.data(for: currentReport, format: .json) | 1526 | return try? DomainReportExporter.data(for: currentReport, format: .json) |
| @@ -1541,6 +1531,11 @@ final class DomainViewModel { | |||
| 1541 | return String(data: data, encoding: .utf8) | 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 | func audits(for domain: String) -> [AuditSession] { | 1539 | func audits(for domain: String) -> [AuditSession] { |
| 1545 | auditSessions | 1540 | auditSessions |
| 1546 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | 1541 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } |
| @@ -1792,40 +1787,18 @@ final class DomainViewModel { | |||
| 1792 | usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) }) | 1787 | usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) }) |
| 1793 | } | 1788 | } |
| 1794 | 1789 | ||
| 1795 | func exportBatchText() -> String { | 1790 | func exportBatchReportData(format: DomainExportFormat) -> Data? { |
| 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? { | ||
| 1807 | try? DomainReportExporter.data( | 1791 | try? DomainReportExporter.data( |
| 1808 | for: currentBatchReports(), | 1792 | for: currentBatchReports(), |
| 1809 | format: .json, | 1793 | format: format, |
| 1810 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" | 1794 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" |
| 1811 | ) | 1795 | ) |
| 1812 | } | 1796 | } |
| 1813 | 1797 | ||
| 1814 | func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { | 1798 | func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? { |
| 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? { | ||
| 1826 | try? DomainReportExporter.data( | 1799 | try? DomainReportExporter.data( |
| 1827 | for: reports(for: domains), | 1800 | for: reports(for: domains), |
| 1828 | format: .json, | 1801 | format: format, |
| 1829 | title: "Tracked Domains Export" | 1802 | title: "Tracked Domains Export" |
| 1830 | ) | 1803 | ) |
| 1831 | } | 1804 | } |
| @@ -1934,6 +1907,31 @@ final class DomainViewModel { | |||
| 1934 | return try? encoder.encode(payload) | 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 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { | 1935 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { |
| 1938 | let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) | 1936 | let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) |
| 1939 | let previous = previousSnapshot( | 1937 | let previous = previousSnapshot( |
DomainDig/WatchlistView.swift +2 −9
| @@ -388,15 +388,8 @@ struct WatchlistView: View { | |||
| 388 | formatter.dateFormat = "yyyyMMdd_HHmmss" | 388 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 389 | let timestamp = formatter.string(from: Date()) | 389 | let timestamp = formatter.string(from: Date()) |
| 390 | let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)" | 390 | let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)" |
| 391 | let data: Data | 391 | guard let data = viewModel.exportTrackedDomainsData(domains: viewModel.filteredTrackedDomains, format: format) else { |
| 392 | 392 | return | |
| 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) | ||
| 400 | } | 393 | } |
| 401 | 394 | ||
| 402 | ExportPresenter.share(filename: filename, data: data) | 395 | ExportPresenter.share(filename: filename, data: data) |
DomainDig/WorkflowsView.swift +4 −18
| @@ -383,15 +383,8 @@ struct WorkflowDetailView: View { | |||
| 383 | formatter.dateFormat = "yyyyMMdd_HHmmss" | 383 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 384 | let timestamp = formatter.string(from: Date()) | 384 | let timestamp = formatter.string(from: Date()) |
| 385 | let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)" | 385 | let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)" |
| 386 | let data: Data | 386 | guard let data = viewModel.exportWorkflowData(summary: latestSummary, changedOnly: changedOnly, format: format) else { |
| 387 | 387 | return | |
| 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) | ||
| 395 | } | 388 | } |
| 396 | 389 | ||
| 397 | ExportPresenter.share(filename: filename, data: data) | 390 | ExportPresenter.share(filename: filename, data: data) |
| @@ -586,15 +579,8 @@ struct WorkflowRunSummaryView: View { | |||
| 586 | formatter.dateFormat = "yyyyMMdd_HHmmss" | 579 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 587 | let timestamp = formatter.string(from: Date()) | 580 | let timestamp = formatter.string(from: Date()) |
| 588 | let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)" | 581 | let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)" |
| 589 | let data: Data | 582 | guard let data = viewModel.exportWorkflowData(summary: summary, changedOnly: changedOnly, format: format) else { |
| 590 | 583 | return | |
| 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) | ||
| 598 | } | 584 | } |
| 599 | 585 | ||
| 600 | ExportPresenter.share(filename: filename, data: data) | 586 | ExportPresenter.share(filename: filename, data: data) |
DomainReportExporter.swift +101 −1
| @@ -1,11 +1,28 @@ | |||
| 1 | import Foundation | 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 | case text = "txt" | 8 | case text = "txt" |
| 5 | case csv = "csv" | 9 | case csv = "csv" |
| 6 | case json = "json" | 10 | case json = "json" |
| 11 | case markdown = "md" | ||
| 12 | case pdf = "pdf" | ||
| 7 | 13 | ||
| 14 | var id: String { rawValue } | ||
| 8 | var fileExtension: String { rawValue } | 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 | enum DomainReportExporter { | 28 | enum DomainReportExporter { |
| @@ -17,6 +34,10 @@ enum DomainReportExporter { | |||
| 17 | return Data(csv(for: [report]).utf8) | 34 | return Data(csv(for: [report]).utf8) |
| 18 | case .json: | 35 | case .json: |
| 19 | return try jsonEncoder.encode(report) | 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 | return Data(csv(for: reports).utf8) | 49 | return Data(csv(for: reports).utf8) |
| 29 | case .json: | 50 | case .json: |
| 30 | return try jsonEncoder.encode(reports) | 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 | static func text(for report: DomainReport) -> String { | 134 | static func text(for report: DomainReport) -> String { |