Commit 01c3d342f6

01c3d342f6980a69feb8df27a3454fd721d6298e

parent: 54c151e22f

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 16:09 UTC

v4.8.0: Add markdown and PDF export formats

- DomainExportFormat gains .markdown and .pdf (CaseIterable, Identifiable,
  titled), alongside the existing text/csv/json.
- Markdown reuses the existing text-export content verbatim via a line-based
  transform (section "Title\n----" underlines become "## Title", the leading
  title becomes an H1, other lines become bullets), so the two formats can
  never drift apart.
- PDF renders that Markdown as a simple monospaced multi-page document via
  UIGraphicsPDFRenderer (mirrors AuditExporter's existing PDF approach;
  degrades to raw Markdown bytes on non-UIKit platforms).
- Replaced the single/batch/tracked-domains/workflow share call sites' hand-
  written per-format switches with format-agnostic functions
  (exportSingleReportData, exportBatchReportData, exportTrackedDomainsData,
  exportWorkflowData) that delegate straight to DomainReportExporter, removing
  the now-orphaned per-format helper functions those switches used to call.

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 @@
1import Foundation 1import Foundation
2 2
3enum DomainExportFormat: String { 3#if canImport(UIKit)
4import UIKit
5#endif
6
7enum 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
11enum DomainReportExporter { 28enum 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 {