diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 11:09:02 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-20 13:56:41 -0500 |
| commit | 01c3d342f6980a69feb8df27a3454fd721d6298e (patch) | |
| tree | 72f7758dfb38623b1f4a3b6d5cf9c9d98e9bc601 | |
| parent | 54c151e22f8a5d4d7a9c290e79fc7626415f7083 (diff) | |
| download | domain-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.swift | 42 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 70 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 11 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 22 | ||||
| -rw-r--r-- | 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 { |
