krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

01c3d342f6980a69feb8df27a3454fd721d6298e

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T16:09:02Z

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.
 DomainDig/ContentView.swift     |  42 +++--------------
 DomainDig/DomainViewModel.swift |  70 ++++++++++++++-------------
 DomainDig/WatchlistView.swift   |  11 +----
 DomainDig/WorkflowsView.swift   |  22 ++-------
 DomainReportExporter.swift      | 102 +++++++++++++++++++++++++++++++++++++++-
 5 files changed, 147 insertions(+), 100 deletions(-)

diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 30d693e..b5ec729 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -710,50 +710,20 @@ struct ContentView: View {
     }
 
     private func shareSingleResults(format: DomainExportFormat) {
-        let (filename, data) = exportPayload(
-            prefix: "domaindig_single",
-            format: format,
-            text: viewModel.exportText(),
-            csv: viewModel.exportCSV(),
-            json: viewModel.exportJSONData()
-        )
-        ExportPresenter.share(filename: filename, data: data)
+        guard let data = viewModel.exportSingleReportData(format: format) else { return }
+        ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data)
     }
 
     private func shareBatchResults(format: DomainExportFormat) {
-        let (filename, data) = exportPayload(
-            prefix: "domaindig_batch",
-            format: format,
-            text: viewModel.exportBatchText(),
-            csv: viewModel.exportBatchCSV(),
-            json: viewModel.exportBatchJSONData()
-        )
-        ExportPresenter.share(filename: filename, data: data)
+        guard let data = viewModel.exportBatchReportData(format: format) else { return }
+        ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data)
     }
 
-    private func exportPayload(
-        prefix: String,
-        format: DomainExportFormat,
-        text: String,
-        csv: String,
-        json: Data?
-    ) -> (String, Data) {
+    private func exportFilename(prefix: String, format: DomainExportFormat) -> String {
         let formatter = DateFormatter()
         formatter.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = formatter.string(from: Date())
-        let filename = "\(timestamp)_\(prefix).\(format.fileExtension)"
-        let data: Data
-
-        switch format {
-        case .text:
-            data = Data(text.utf8)
-        case .csv:
-            data = Data(csv.utf8)
-        case .json:
-            data = json ?? Data("[]".utf8)
-        }
-
-        return (filename, data)
+        return "\(timestamp)_\(prefix).\(format.fileExtension)"
     }
 
     private var defaultCollapsedSections: Set<ResultSection> {
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 33756df..674efdf 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -1521,16 +1521,6 @@ final class DomainViewModel {
         }
     }
 
-    func exportText() -> String {
-        guard let currentReport else { return "No results available." }
-        return DomainReportExporter.text(for: currentReport)
-    }
-
-    func exportCSV() -> String {
-        guard let currentReport else { return DomainReportExporter.csv(for: []) }
-        return DomainReportExporter.csv(for: [currentReport])
-    }
-
     func exportJSONData() -> Data? {
         guard let currentReport else { return nil }
         return try? DomainReportExporter.data(for: currentReport, format: .json)
@@ -1541,6 +1531,11 @@ final class DomainViewModel {
         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 audits(for domain: String) -> [AuditSession] {
         auditSessions
             .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
@@ -1792,40 +1787,18 @@ final class DomainViewModel {
         usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
     }
 
-    func exportBatchText() -> String {
-        DomainReportExporter.batchText(
-            for: currentBatchReports(),
-            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
-        )
-    }
-
-    func exportBatchCSV() -> String {
-        DomainReportExporter.csv(for: currentBatchReports())
-    }
-
-    func exportBatchJSONData() -> Data? {
+    func exportBatchReportData(format: DomainExportFormat) -> Data? {
         try? DomainReportExporter.data(
             for: currentBatchReports(),
-            format: .json,
+            format: format,
             title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
         )
     }
 
-    func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
-        DomainReportExporter.csv(for: reports(for: domains))
-    }
-
-    func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
-        DomainReportExporter.batchText(
-            for: reports(for: domains),
-            title: "Tracked Domains Export"
-        )
-    }
-
-    func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? {
+    func exportTrackedDomainsData(domains: [TrackedDomain], format: DomainExportFormat) -> Data? {
         try? DomainReportExporter.data(
             for: reports(for: domains),
-            format: .json,
+            format: format,
             title: "Tracked Domains Export"
         )
     }
@@ -1934,6 +1907,31 @@ final class DomainViewModel {
         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(
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index e017502..a1af244 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -388,15 +388,8 @@ struct WatchlistView: View {
         formatter.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = formatter.string(from: Date())
         let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
-        let data: Data
-
-        switch format {
-        case .text:
-            data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
-        case .csv:
-            data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
-        case .json:
-            data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
+        guard let data = viewModel.exportTrackedDomainsData(domains: viewModel.filteredTrackedDomains, format: format) else {
+            return
         }
 
         ExportPresenter.share(filename: filename, data: data)
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index 11273c7..e1cea09 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -383,15 +383,8 @@ struct WorkflowDetailView: View {
         formatter.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = formatter.string(from: Date())
         let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)"
-        let data: Data
-
-        switch format {
-        case .text:
-            data = Data(viewModel.exportWorkflowText(summary: latestSummary, changedOnly: changedOnly).utf8)
-        case .csv:
-            data = Data(viewModel.exportWorkflowCSV(summary: latestSummary, changedOnly: changedOnly).utf8)
-        case .json:
-            data = viewModel.exportWorkflowJSONData(summary: latestSummary, changedOnly: changedOnly) ?? Data("[]".utf8)
+        guard let data = viewModel.exportWorkflowData(summary: latestSummary, changedOnly: changedOnly, format: format) else {
+            return
         }
 
         ExportPresenter.share(filename: filename, data: data)
@@ -586,15 +579,8 @@ struct WorkflowRunSummaryView: View {
         formatter.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = formatter.string(from: Date())
         let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)"
-        let data: Data
-
-        switch format {
-        case .text:
-            data = Data(viewModel.exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
-        case .csv:
-            data = Data(viewModel.exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
-        case .json:
-            data = viewModel.exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) ?? Data("[]".utf8)
+        guard let data = viewModel.exportWorkflowData(summary: summary, changedOnly: changedOnly, format: format) else {
+            return
         }
 
         ExportPresenter.share(filename: filename, data: data)
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 05ecff9..1a04c70 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -1,11 +1,28 @@
 import Foundation
 
-enum DomainExportFormat: String {
+#if canImport(UIKit)
+import UIKit
+#endif
+
+enum DomainExportFormat: String, CaseIterable, Identifiable {
     case text = "txt"
     case csv = "csv"
     case json = "json"
+    case markdown = "md"
+    case pdf = "pdf"
 
+    var id: String { rawValue }
     var fileExtension: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .text: return "TXT"
+        case .csv: return "CSV"
+        case .json: return "JSON"
+        case .markdown: return "Markdown"
+        case .pdf: return "PDF"
+        }
+    }
 }
 
 enum DomainReportExporter {
@@ -17,6 +34,10 @@ enum DomainReportExporter {
             return Data(csv(for: [report]).utf8)
         case .json:
             return try jsonEncoder.encode(report)
+        case .markdown:
+            return Data(markdown(for: report).utf8)
+        case .pdf:
+            return pdfData(fromMarkdown: markdown(for: report))
         }
     }
 
@@ -28,7 +49,86 @@ enum DomainReportExporter {
             return Data(csv(for: reports).utf8)
         case .json:
             return try jsonEncoder.encode(reports)
+        case .markdown:
+            return Data(batchMarkdown(for: reports, title: title).utf8)
+        case .pdf:
+            return pdfData(fromMarkdown: batchMarkdown(for: reports, title: title))
+        }
+    }
+
+    /// Renders `text(for:)`'s content as Markdown: the leading title becomes an
+    /// H1, `appendSection`'s "Title\n----" underlines become H2 headers, and
+    /// other non-empty lines that aren't already list items become bullets.
+    /// This reuses the exact same section content as the text export rather
+    /// than re-deriving it, so the two formats never drift.
+    static func markdown(for report: DomainReport) -> String {
+        markdown(fromPlainText: text(for: report), title: "DomainDig Report")
+    }
+
+    static func batchMarkdown(for reports: [DomainReport], title: String) -> String {
+        markdown(fromPlainText: batchText(for: reports, title: title), title: title)
+    }
+
+    private static func markdown(fromPlainText text: String, title: String) -> String {
+        let lines = text.components(separatedBy: "\n")
+        var output: [String] = ["# \(title)", ""]
+        var index = 0
+        while index < lines.count {
+            let line = lines[index]
+            if index == 0, line == title {
+                index += 1
+                continue
+            }
+            if index + 1 < lines.count, !line.isEmpty, lines[index + 1] == String(repeating: "-", count: line.count) {
+                output.append("")
+                output.append("## \(line)")
+                index += 2
+                continue
+            }
+            if line.isEmpty || line.hasPrefix("-") || line.hasPrefix("  ") {
+                output.append(line)
+            } else {
+                output.append("- \(line)")
+            }
+            index += 1
+        }
+        return output.joined(separator: "\n")
+    }
+
+    /// Renders Markdown as a simple monospaced multi-page PDF. Foundation-only
+    /// consumers (no UIKit available) get the Markdown bytes back instead.
+    static func pdfData(fromMarkdown markdown: String) -> Data {
+        #if canImport(UIKit)
+        let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
+        return renderer.pdfData { context in
+            let lines = markdown.components(separatedBy: .newlines)
+            let paragraphStyle = NSMutableParagraphStyle()
+            paragraphStyle.lineBreakMode = .byWordWrapping
+            let attributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
+                .paragraphStyle: paragraphStyle
+            ]
+
+            var yOffset: CGFloat = 36
+            context.beginPage()
+
+            for line in lines {
+                if yOffset > 744 {
+                    context.beginPage()
+                    yOffset = 36
+                }
+
+                let renderedLine = NSString(string: line.isEmpty ? " " : line)
+                renderedLine.draw(
+                    in: CGRect(x: 36, y: yOffset, width: 540, height: 22),
+                    withAttributes: attributes
+                )
+                yOffset += 16
+            }
         }
+        #else
+        return Data(markdown.utf8)
+        #endif
     }
 
     static func text(for report: DomainReport) -> String {