summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 11:09:02 -0500
committerChristian Cleberg <[email protected]>2026-07-20 13:56:41 -0500
commit01c3d342f6980a69feb8df27a3454fd721d6298e (patch)
tree72f7758dfb38623b1f4a3b6d5cf9c9d98e9bc601
parent54c151e22f8a5d4d7a9c290e79fc7626415f7083 (diff)
downloaddomain-dig-01c3d342f6980a69feb8df27a3454fd721d6298e.tar.gz
domain-dig-01c3d342f6980a69feb8df27a3454fd721d6298e.tar.bz2
domain-dig-01c3d342f6980a69feb8df27a3454fd721d6298e.zip
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.
-rw-r--r--DomainDig/ContentView.swift42
-rw-r--r--DomainDig/DomainViewModel.swift70
-rw-r--r--DomainDig/WatchlistView.swift11
-rw-r--r--DomainDig/WorkflowsView.swift22
-rw-r--r--DomainReportExporter.swift102
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 {