krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.2: DomainReportExporter.swift · raw
1import Foundation
2
3#if canImport(UIKit)
4import UIKit
5#endif
6
7enum DomainExportFormat: String, CaseIterable, Identifiable, Codable {
8 case text = "txt"
9 case csv = "csv"
10 case json = "json"
11 case markdown = "md"
12 case pdf = "pdf"
13
14 var id: 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 }
26}
27
28enum DomainReportExporter {
29 static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
30 switch format {
31 case .text:
32 return Data(text(for: report).utf8)
33 case .csv:
34 return Data(csv(for: [report]).utf8)
35 case .json:
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))
41 }
42 }
43
44 static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
45 switch format {
46 case .text:
47 return Data(batchText(for: reports, title: title).utf8)
48 case .csv:
49 return Data(csv(for: reports).utf8)
50 case .json:
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 if index < lines.count, isUnderline(lines[index], for: line) {
81 index += 1
82 }
83 continue
84 }
85 if index + 1 < lines.count, !line.isEmpty, isUnderline(lines[index + 1], for: line) {
86 output.append("")
87 output.append("## \(line)")
88 index += 2
89 continue
90 }
91 if isRule(line) {
92 output.append("")
93 output.append("---")
94 index += 1
95 continue
96 }
97 if line.isEmpty || line.hasPrefix("-") || line.hasPrefix(" ") {
98 output.append(line)
99 } else {
100 output.append("- \(line)")
101 }
102 index += 1
103 }
104 return output.joined(separator: "\n")
105 }
106
107 /// True when `line` is a run of `-` or `=` exactly as long as the heading it
108 /// underlines. `batchText` uses `=` for the document title, `appendSection`
109 /// uses `-` for section headers.
110 private static func isUnderline(_ line: String, for heading: String) -> Bool {
111 guard !heading.isEmpty else { return false }
112 return line == String(repeating: "-", count: heading.count)
113 || line == String(repeating: "=", count: heading.count)
114 }
115
116 /// True for a standalone divider not attached to a heading — `batchText`
117 /// emits a fixed 48-character `=` run between reports.
118 private static func isRule(_ line: String) -> Bool {
119 line.count >= 3 && (line.allSatisfy { $0 == "=" } || line.allSatisfy { $0 == "-" })
120 }
121
122 /// Renders Markdown as a simple monospaced multi-page PDF. Foundation-only
123 /// consumers (no UIKit available) get the Markdown bytes back instead.
124 static func pdfData(fromMarkdown markdown: String) -> Data {
125 #if canImport(UIKit)
126 let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
127 return renderer.pdfData { context in
128 let lines = markdown.components(separatedBy: .newlines)
129 let paragraphStyle = NSMutableParagraphStyle()
130 paragraphStyle.lineBreakMode = .byWordWrapping
131 let attributes: [NSAttributedString.Key: Any] = [
132 .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
133 .paragraphStyle: paragraphStyle
134 ]
135
136 var yOffset: CGFloat = 36
137 context.beginPage()
138
139 for line in lines {
140 if yOffset > 744 {
141 context.beginPage()
142 yOffset = 36
143 }
144
145 let renderedLine = NSString(string: line.isEmpty ? " " : line)
146 renderedLine.draw(
147 in: CGRect(x: 36, y: yOffset, width: 540, height: 22),
148 withAttributes: attributes
149 )
150 yOffset += 16
151 }
152 }
153 #else
154 return Data(markdown.utf8)
155 #endif
156 }
157
158 static func text(for report: DomainReport) -> String {
159 var lines = [
160 "DomainDig Report",
161 "Domain: \(report.domain)",
162 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
163 "App Version: \(report.metadata.appVersion)",
164 "Resolver: \(report.metadata.resolverDisplayName)",
165 "Resolver URL: \(report.metadata.resolverURLString)",
166 "Source: \(report.provenance.source.label)",
167 "Availability: \(availabilityLabel(report.availability))",
168 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
169 ]
170
171 if report.metadata.isPartialSnapshot {
172 lines.append("Snapshot Integrity: Partial snapshot")
173 }
174 if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
175 lines.append("Audit Note: \(auditNote)")
176 }
177 if !report.provenance.dataSources.isEmpty {
178 lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
179 }
180 if !report.metadata.validationIssues.isEmpty {
181 lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
182 }
183 if let workflowContext = report.workflowContext {
184 lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
185 }
186
187 appendSection("Summary", to: &lines) {
188 [
189 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
190 "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
191 "TLS Status: \(report.web.tlsStatus)",
192 "HTTP: \(httpSummary(for: report))",
193 "Email: \(report.email.summary)",
194 "Subdomains: \(report.subdomains.count)",
195 "Extended Subdomains: \(report.extendedSubdomains.count)",
196 "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")",
197 "Reputation: \(report.reputation?.status.title ?? "Unavailable")"
198 ]
199 }
200
201 appendSection("Risk", to: &lines) {
202 var values = [
203 "Score: \(report.riskAssessment.score)",
204 "Level: \(report.riskAssessment.level.title)"
205 ]
206 if report.riskAssessment.factors.isEmpty {
207 values.append("Factors: None")
208 } else {
209 values.append("Factors:")
210 for factor in report.riskAssessment.factors {
211 values.append(" [\(factor.impact.rawValue)] \(factor.description)")
212 }
213 }
214 return values
215 }
216
217 appendSection("Insights", to: &lines) {
218 report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" }
219 }
220
221 appendSection("Ownership", to: &lines) {
222 var ownershipLines = [
223 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
224 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
225 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
226 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
227 "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
228 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
229 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
230 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
231 ]
232 if let provenance = report.sectionProvenance[.ownership] {
233 ownershipLines.append("Provenance: \(provenanceLabel(provenance))")
234 }
235 if let error = report.dns.error, report.ownership == nil {
236 ownershipLines.append("Error: \(error)")
237 } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
238 _ = error
239 }
240 return ownershipLines
241 }
242
243 appendSection("Ownership History", to: &lines) {
244 guard !report.ownershipHistory.isEmpty else {
245 return ["No ownership history available"]
246 }
247
248 return report.ownershipHistory.map { event in
249 [
250 textDateFormatter.string(from: event.date),
251 event.summary,
252 "source=\(event.source)"
253 ].joined(separator: " | ")
254 }
255 }
256
257 appendSection("DNS", to: &lines) {
258 var dnsLines = [
259 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
260 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
261 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
262 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))",
263 "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")"
264 ]
265 if let provenance = report.sectionProvenance[.dns] {
266 dnsLines.append("Provenance: \(provenanceLabel(provenance))")
267 }
268 if let error = report.dns.error {
269 dnsLines.append("Error: \(error)")
270 }
271 if report.dns.recordSections.isEmpty {
272 dnsLines.append("Records: None")
273 } else {
274 dnsLines.append("Records:")
275 for section in report.dns.recordSections {
276 var seen: Set<String> = []
277 let values = (section.records + section.wildcardRecords)
278 .map(\.value)
279 .filter { seen.insert($0).inserted }
280 let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
281 dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)")
282 }
283 }
284 return dnsLines
285 }
286
287 appendSection("DNS History", to: &lines) {
288 guard !report.dnsHistory.isEmpty else {
289 return ["No DNS history available"]
290 }
291
292 return report.dnsHistory.map { event in
293 [
294 textDateFormatter.string(from: event.date),
295 event.summary,
296 "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
297 "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
298 "source=\(event.source)"
299 ].joined(separator: " | ")
300 }
301 }
302
303 appendSection("Web", to: &lines) {
304 var webLines = [
305 "TLS Status: \(report.web.tlsStatus)",
306 "TLS Grade: \(report.web.tlsGrade.rawValue)",
307 "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
308 "Certificate Warning: \(report.web.certificateWarningLevel.title)",
309 "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
310 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
311 "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
312 "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
313 "Final URL: \(report.web.finalURL ?? "Unavailable")",
314 "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
315 "Header Count: \(report.web.headerCount)"
316 ]
317 if let provenance = report.sectionProvenance[.ssl] {
318 webLines.append("TLS Provenance: \(provenanceLabel(provenance))")
319 }
320 if let provenance = report.sectionProvenance[.httpHeaders] {
321 webLines.append("HTTP Provenance: \(provenanceLabel(provenance))")
322 }
323 if let provenance = report.sectionProvenance[.redirectChain] {
324 webLines.append("Redirect Provenance: \(provenanceLabel(provenance))")
325 }
326 if let tlsError = report.web.tlsError {
327 webLines.append("TLS Error: \(tlsError)")
328 }
329 if let headersError = report.web.headersError {
330 webLines.append("Headers Error: \(headersError)")
331 }
332 if let redirectError = report.web.redirectError {
333 webLines.append("Redirect Error: \(redirectError)")
334 }
335 if !report.web.headers.isEmpty {
336 webLines.append("Headers:")
337 for header in report.web.headers {
338 webLines.append(" \(header.name): \(header.value)")
339 }
340 }
341 if !report.web.redirectChain.isEmpty {
342 webLines.append("Redirect Chain:")
343 for hop in report.web.redirectChain {
344 webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
345 }
346 }
347 return webLines
348 }
349
350 appendSection("Email", to: &lines) {
351 var emailLines = [report.email.summary]
352 if let grade = report.email.grade {
353 emailLines.append("Grade: \(grade.rawValue)")
354 }
355 if !report.email.reasons.isEmpty {
356 emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))")
357 }
358 emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
359 if let provenance = report.sectionProvenance[.emailSecurity] {
360 emailLines.append("Provenance: \(provenanceLabel(provenance))")
361 }
362 if let records = report.email.records {
363 emailLines.append("SPF: \(recordLabel(records.spf))")
364 emailLines.append("DMARC: \(recordLabel(records.dmarc))")
365 emailLines.append("DKIM: \(recordLabel(records.dkim))")
366 emailLines.append("BIMI: \(recordLabel(records.bimi))")
367 emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
368 }
369 if let error = report.email.error {
370 emailLines.append("Error: \(error)")
371 }
372 return emailLines
373 }
374
375 appendSection("Network", to: &lines) {
376 var networkLines = [
377 "Reachability: \(report.network.reachabilitySummary)",
378 "Geolocation: \(report.network.geolocationSummary)",
379 "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")",
380 "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
381 ]
382 if let provenance = report.sectionProvenance[.ipGeolocation] {
383 networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))")
384 }
385 if let error = report.network.reachabilityError {
386 networkLines.append("Reachability Error: \(error)")
387 }
388 if let error = report.network.geolocationError {
389 networkLines.append("Geolocation Error: \(error)")
390 }
391 if let error = report.network.portScanError {
392 networkLines.append("Port Scan Error: \(error)")
393 }
394 if !report.network.portScan.isEmpty {
395 networkLines.append("Port Scan:")
396 for result in report.network.portScan {
397 networkLines.append(
398 " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
399 )
400 }
401 }
402 return networkLines
403 }
404
405 appendSection("Subdomains", to: &lines) {
406 var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"]
407 if let provenance = report.sectionProvenance[.subdomains] {
408 values.append("Provenance: \(provenanceLabel(provenance))")
409 }
410 if report.subdomains.isEmpty {
411 values.append("None")
412 return values
413 }
414 if !report.subdomainGroups.isEmpty {
415 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
416 }
417 values.append(contentsOf: report.subdomains.map { "- \($0)" })
418 if !report.extendedSubdomains.isEmpty {
419 values.append("Extended:")
420 values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
421 }
422 return values
423 }
424
425 appendSection("Pricing", to: &lines) {
426 guard let pricing = report.domainPricing else {
427 return ["External pricing unavailable"]
428 }
429
430 return [
431 "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
432 "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
433 "Resale: \(pricing.resaleSignal ?? "Unavailable")",
434 "Auction: \(pricing.auctionSignal ?? "Unavailable")",
435 "Source: \(pricing.source)",
436 "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
437 ]
438 }
439
440 appendSection("Changes", to: &lines) {
441 guard let changeSummary = report.changeSummary else {
442 return ["No comparison available"]
443 }
444
445 var values = [
446 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
447 "Severity: \(changeSummary.severity.title)",
448 "Impact: \(changeSummary.impactClassification.title)",
449 "Inferred Summary: \(changeSummary.message)",
450 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
451 ]
452 if let riskScoreDelta = changeSummary.riskScoreDelta {
453 values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
454 }
455 if !changeSummary.insights.isEmpty {
456 values.append("Insights: \(changeSummary.insights.joined(separator: " | "))")
457 }
458 if !changeSummary.observedFacts.isEmpty {
459 values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
460 }
461 if let contextNote = changeSummary.contextNote {
462 values.append("Context: \(contextNote)")
463 }
464 return values
465 }
466
467 return lines.joined(separator: "\n")
468 }
469
470 static func batchText(for reports: [DomainReport], title: String) -> String {
471 guard !reports.isEmpty else {
472 return "\(title)\nNo results available."
473 }
474
475 var lines = [title, String(repeating: "=", count: title.count), ""]
476 for (index, report) in reports.enumerated() {
477 if index > 0 {
478 lines.append("")
479 lines.append(String(repeating: "=", count: 48))
480 lines.append("")
481 }
482 lines.append(text(for: report))
483 }
484 return lines.joined(separator: "\n")
485 }
486
487 static func csv(for reports: [DomainReport]) -> String {
488 csv(for: reports, workflowInsights: [])
489 }
490
491 static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String {
492 let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ")
493 let headers = [
494 "domain",
495 "timestamp",
496 "app_version",
497 "result_source",
498 "resolver",
499 "availability",
500 "risk_score",
501 "risk_level",
502 "risk_factors",
503 "insights",
504 "availability_confidence",
505 "registrar",
506 "ownership_confidence",
507 "ownership_expires",
508 "nameservers",
509 "primary_ip",
510 "ptr_record",
511 "dnssec_signed",
512 "dns_patterns",
513 "tls_status",
514 "tls_grade",
515 "tls_highlights",
516 "certificate_warning_level",
517 "hsts_preloaded",
518 "http_status",
519 "http_security_grade",
520 "final_url",
521 "email_summary",
522 "email_grade",
523 "email_confidence",
524 "subdomain_count",
525 "extended_subdomain_count",
526 "subdomain_groups",
527 "subdomain_confidence",
528 "subdomains",
529 "extended_subdomains",
530 "ownership_history",
531 "dns_history",
532 "pricing_estimated",
533 "pricing_premium",
534 "pricing_resale_signal",
535 "pricing_auction_signal",
536 "pricing_source",
537 "reputation_status",
538 "reputation_listed_sources",
539 "open_ports",
540 "reachability_summary",
541 "geolocation_summary",
542 "geolocation_confidence",
543 "data_sources",
544 "audit_note",
545 "partial_snapshot",
546 "workflow_name",
547 "workflow_source",
548 "cached_sections",
549 "change_summary",
550 "change_impact",
551 "workflow_insights"
552 ]
553
554 let rows = reports.map { report in
555 let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
556 let nameservers = joined(report.ownership?.nameservers) ?? ""
557 let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
558 let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
559 let httpStatus = report.web.statusCode.map(String.init) ?? ""
560 let subdomainCount = String(report.subdomains.count)
561 let subdomains = report.subdomains.joined(separator: " | ")
562 let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
563 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
564 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
565 let insights = report.insights.joined(separator: " | ")
566 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
567 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
568 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
569 let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
570 let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
571 let reputationStatus = report.reputation?.status.rawValue ?? ""
572 let reputationListedSources = report.reputation?.listedSources.joined(separator: " | ") ?? ""
573
574 return [
575 report.domain,
576 csvDateFormatter.string(from: report.timestamp),
577 report.appVersion,
578 report.resultSource.rawValue,
579 report.resolverDisplayName,
580 availabilityLabel(report.availability),
581 "\(report.riskAssessment.score)",
582 report.riskAssessment.level.rawValue,
583 riskFactors,
584 insights,
585 report.availabilityConfidence?.rawValue ?? "",
586 report.ownership?.registrar ?? "",
587 report.ownershipConfidence?.rawValue ?? "",
588 expirationDate,
589 nameservers,
590 report.dns.primaryIP ?? "",
591 report.dns.ptrRecord ?? "",
592 dnssecSigned,
593 dnsPatterns,
594 report.web.tlsStatus,
595 report.web.tlsGrade.rawValue,
596 tlsHighlights,
597 report.web.certificateWarningLevel.rawValue,
598 hstsPreloaded,
599 httpStatus,
600 report.web.securityGrade ?? "",
601 report.web.finalURL ?? "",
602 report.email.summary,
603 report.email.grade?.rawValue ?? "",
604 report.emailConfidence?.rawValue ?? "",
605 subdomainCount,
606 String(report.extendedSubdomains.count),
607 subdomainGroups,
608 report.subdomainConfidence?.rawValue ?? "",
609 subdomains,
610 extendedSubdomains,
611 ownershipHistory,
612 dnsHistory,
613 report.domainPricing?.estimatedPrice ?? "",
614 report.domainPricing?.premiumIndicator == true ? "true" : "false",
615 report.domainPricing?.resaleSignal ?? "",
616 report.domainPricing?.auctionSignal ?? "",
617 report.domainPricing?.source ?? "",
618 reputationStatus,
619 reputationListedSources,
620 openPorts,
621 report.network.reachabilitySummary,
622 report.network.geolocationSummary,
623 report.geolocationConfidence?.rawValue ?? "",
624 report.provenance.dataSources.joined(separator: " | "),
625 report.metadata.auditNote ?? "",
626 report.metadata.isPartialSnapshot ? "true" : "false",
627 report.workflowContext?.workflowName ?? "",
628 report.workflowContext?.source ?? "",
629 report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
630 report.changeSummary?.message ?? "",
631 report.changeSummary?.impactClassification.rawValue ?? "",
632 workflowInsightSummary
633 ]
634 }
635
636 return ([headers] + rows)
637 .map { row in row.map(csvEscaped).joined(separator: ",") }
638 .joined(separator: "\n")
639 }
640
641 static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
642 guard !reports.isEmpty else {
643 return "Timeline Export\nNo snapshots available for \(domain)."
644 }
645
646 var lines = [
647 "DomainDig Timeline Export",
648 "Domain: \(domain)",
649 "Snapshots: \(reports.count)"
650 ]
651
652 for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
653 lines.append("")
654 lines.append("\(textDateFormatter.string(from: report.timestamp))")
655 lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
656 lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
657 if includeDiffSummary, let changeSummary = report.changeSummary {
658 lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
659 }
660 }
661
662 return lines.joined(separator: "\n")
663 }
664
665 static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
666 struct TimelineExportEntry: Codable {
667 let timestamp: Date
668 let summary: String?
669 let severity: String?
670 let changedSections: [String]?
671 }
672
673 struct TimelineExportPayload: Codable {
674 let domain: String
675 let exportedAt: Date
676 let snapshots: [TimelineExportEntry]
677 }
678
679 let payload = TimelineExportPayload(
680 domain: domain,
681 exportedAt: Date(),
682 snapshots: reports
683 .sorted(by: { $0.timestamp > $1.timestamp })
684 .map { report in
685 TimelineExportEntry(
686 timestamp: report.timestamp,
687 summary: report.changeSummary?.message,
688 severity: report.changeSummary?.severity.title,
689 changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
690 )
691 }
692 )
693
694 return try jsonEncoder.encode(payload)
695 }
696
697 private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
698 lines.append("")
699 lines.append(title)
700 lines.append(String(repeating: "-", count: title.count))
701 lines.append(contentsOf: body())
702 }
703
704 private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
705 switch status {
706 case .available:
707 return "Available"
708 case .registered:
709 return "Registered"
710 case .unknown:
711 return "Unknown"
712 }
713 }
714
715 private static func joined(_ values: [String]?) -> String? {
716 guard let values, !values.isEmpty else { return nil }
717 return values.joined(separator: " | ")
718 }
719
720 private static func dnssecLabel(_ value: Bool?) -> String {
721 switch value {
722 case true:
723 return "Signed"
724 case false:
725 return "Unsigned"
726 case nil:
727 return "Unavailable"
728 }
729 }
730
731 private static func ownershipDateLabel(_ date: Date?) -> String {
732 guard let date else { return "Unavailable" }
733 return textDateFormatter.string(from: date)
734 }
735
736 private static func durationLabel(_ durationMs: Int?) -> String {
737 durationMs.map { "\($0) ms" } ?? "Unavailable"
738 }
739
740 private static func httpSummary(for report: DomainReport) -> String {
741 let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
742 return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
743 }
744
745 private static func booleanLabel(_ value: Bool?) -> String {
746 guard let value else { return "Unavailable" }
747 return value ? "Yes" : "No"
748 }
749
750 private static func recordLabel(_ record: EmailSecurityRecord) -> String {
751 if record.found {
752 return record.value ?? "Present"
753 }
754 return "Unavailable"
755 }
756
757 private static func provenanceLabel(_ provenance: SectionProvenance) -> String {
758 [
759 provenance.source,
760 provenance.provider,
761 provenance.resolver.map { "resolver=\($0)" },
762 provenance.resultSource.label.lowercased(),
763 textDateFormatter.string(from: provenance.collectedAt)
764 ]
765 .compactMap { $0 }
766 .joined(separator: " | ")
767 }
768
769 private static let textDateFormatter: DateFormatter = {
770 let formatter = DateFormatter()
771 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
772 return formatter
773 }()
774
775 private static let csvDateFormatter: ISO8601DateFormatter = {
776 let formatter = ISO8601DateFormatter()
777 formatter.formatOptions = [.withInternetDateTime]
778 return formatter
779 }()
780
781 private static let jsonEncoder: JSONEncoder = {
782 let encoder = JSONEncoder()
783 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
784 encoder.dateEncodingStrategy = .iso8601
785 return encoder
786 }()
787
788 nonisolated private static func csvEscaped(_ value: String) -> String {
789 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
790 return "\"\(escaped)\""
791 }
792}
793
794private extension String {
795 var nilIfEmpty: String? {
796 isEmpty ? nil : self
797 }
798}