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 {
710710 }
711711
712712 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)
721715 }
722716
723717 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)
732720 }
733721
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 {
741723 let formatter = DateFormatter()
742724 formatter.dateFormat = "yyyyMMdd_HHmmss"
743725 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)"
757727 }
758728
759729 private var defaultCollapsedSections: Set<ResultSection> {
DomainDig/DomainViewModel.swift +34 −36
@@ -1521,16 +1521,6 @@ final class DomainViewModel {
15211521 }
15221522 }
15231523
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
15341524 func exportJSONData() -> Data? {
15351525 guard let currentReport else { return nil }
15361526 return try? DomainReportExporter.data(for: currentReport, format: .json)
@@ -1541,6 +1531,11 @@ final class DomainViewModel {
15411531 return String(data: data, encoding: .utf8)
15421532 }
15431533
1534 func exportSingleReportData(format: DomainExportFormat) -> Data? {
1535 guard let currentReport else { return nil }
1536 return try? DomainReportExporter.data(for: currentReport, format: format)
1537 }
1538
15441539 func audits(for domain: String) -> [AuditSession] {
15451540 auditSessions
15461541 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
@@ -1792,40 +1787,18 @@ final class DomainViewModel {
17921787 usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
17931788 }
17941789
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? {
18071791 try? DomainReportExporter.data(
18081792 for: currentBatchReports(),
1809 format: .json,
1793 format: format,
18101794 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
18111795 )
18121796 }
18131797
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? {
18261799 try? DomainReportExporter.data(
18271800 for: reports(for: domains),
1828 format: .json,
1801 format: format,
18291802 title: "Tracked Domains Export"
18301803 )
18311804 }
@@ -1934,6 +1907,31 @@ final class DomainViewModel {
19341907 return try? encoder.encode(payload)
19351908 }
19361909
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
19371935 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
19381936 let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
19391937 let previous = previousSnapshot(
DomainDig/WatchlistView.swift +2 −9
@@ -388,15 +388,8 @@ struct WatchlistView: View {
388388 formatter.dateFormat = "yyyyMMdd_HHmmss"
389389 let timestamp = formatter.string(from: Date())
390390 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
400393 }
401394
402395 ExportPresenter.share(filename: filename, data: data)
DomainDig/WorkflowsView.swift +4 −18
@@ -383,15 +383,8 @@ struct WorkflowDetailView: View {
383383 formatter.dateFormat = "yyyyMMdd_HHmmss"
384384 let timestamp = formatter.string(from: Date())
385385 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
395388 }
396389
397390 ExportPresenter.share(filename: filename, data: data)
@@ -586,15 +579,8 @@ struct WorkflowRunSummaryView: View {
586579 formatter.dateFormat = "yyyyMMdd_HHmmss"
587580 let timestamp = formatter.string(from: Date())
588581 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
598584 }
599585
600586 ExportPresenter.share(filename: filename, data: data)
DomainReportExporter.swift +101 −1
@@ -1,11 +1,28 @@
11import Foundation
22
3enum DomainExportFormat: String {
3#if canImport(UIKit)
4import UIKit
5#endif
6
7enum DomainExportFormat: String, CaseIterable, Identifiable {
48 case text = "txt"
59 case csv = "csv"
610 case json = "json"
11 case markdown = "md"
12 case pdf = "pdf"
713
14 var id: String { rawValue }
815 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 }
926}
1027
1128enum DomainReportExporter {
@@ -17,6 +34,10 @@ enum DomainReportExporter {
1734 return Data(csv(for: [report]).utf8)
1835 case .json:
1936 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))
2041 }
2142 }
2243
@@ -28,7 +49,86 @@ enum DomainReportExporter {
2849 return Data(csv(for: reports).utf8)
2950 case .json:
3051 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 }
31128 }
129 #else
130 return Data(markdown.utf8)
131 #endif
32132 }
33133
34134 static func text(for report: DomainReport) -> String {