krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.5.0: DomainReportExporter.swift · raw
1import Foundation
2
3enum DomainExportFormat: String {
4 case text = "txt"
5 case csv = "csv"
6 case json = "json"
7
8 var fileExtension: String { rawValue }
9}
10
11enum DomainReportExporter {
12 static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
13 switch format {
14 case .text:
15 return Data(text(for: report).utf8)
16 case .csv:
17 return Data(csv(for: [report]).utf8)
18 case .json:
19 return try jsonEncoder.encode(report)
20 }
21 }
22
23 static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
24 switch format {
25 case .text:
26 return Data(batchText(for: reports, title: title).utf8)
27 case .csv:
28 return Data(csv(for: reports).utf8)
29 case .json:
30 return try jsonEncoder.encode(reports)
31 }
32 }
33
34 static func text(for report: DomainReport) -> String {
35 var lines = [
36 "DomainDig Report",
37 "Domain: \(report.domain)",
38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "App Version: \(report.metadata.appVersion)",
40 "Resolver: \(report.metadata.resolverDisplayName)",
41 "Resolver URL: \(report.metadata.resolverURLString)",
42 "Source: \(report.provenance.source.label)",
43 "Availability: \(availabilityLabel(report.availability))",
44 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
45 ]
46
47 if report.metadata.isPartialSnapshot {
48 lines.append("Snapshot Integrity: Partial snapshot")
49 }
50 if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
51 lines.append("Audit Note: \(auditNote)")
52 }
53 if !report.provenance.dataSources.isEmpty {
54 lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
55 }
56 if !report.metadata.validationIssues.isEmpty {
57 lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
58 }
59 if let workflowContext = report.workflowContext {
60 lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
61 }
62
63 appendSection("Summary", to: &lines) {
64 [
65 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
66 "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
67 "TLS Status: \(report.web.tlsStatus)",
68 "HTTP: \(httpSummary(for: report))",
69 "Email: \(report.email.summary)",
70 "Subdomains: \(report.subdomains.count)",
71 "Extended Subdomains: \(report.extendedSubdomains.count)",
72 "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")"
73 ]
74 }
75
76 appendSection("Risk", to: &lines) {
77 var values = [
78 "Score: \(report.riskAssessment.score)",
79 "Level: \(report.riskAssessment.level.title)"
80 ]
81 if report.riskAssessment.factors.isEmpty {
82 values.append("Factors: None")
83 } else {
84 values.append("Factors:")
85 for factor in report.riskAssessment.factors {
86 values.append(" [\(factor.impact.rawValue)] \(factor.description)")
87 }
88 }
89 return values
90 }
91
92 appendSection("Insights", to: &lines) {
93 report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" }
94 }
95
96 appendSection("Ownership", to: &lines) {
97 var ownershipLines = [
98 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
99 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
100 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
101 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
102 "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
103 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
104 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
105 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
106 ]
107 if let provenance = report.sectionProvenance[.ownership] {
108 ownershipLines.append("Provenance: \(provenanceLabel(provenance))")
109 }
110 if let error = report.dns.error, report.ownership == nil {
111 ownershipLines.append("Error: \(error)")
112 } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
113 _ = error
114 }
115 return ownershipLines
116 }
117
118 appendSection("Ownership History", to: &lines) {
119 guard !report.ownershipHistory.isEmpty else {
120 return ["No ownership history available"]
121 }
122
123 return report.ownershipHistory.map { event in
124 [
125 textDateFormatter.string(from: event.date),
126 event.summary,
127 "source=\(event.source)"
128 ].joined(separator: " | ")
129 }
130 }
131
132 appendSection("DNS", to: &lines) {
133 var dnsLines = [
134 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
135 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
136 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
137 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))",
138 "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")"
139 ]
140 if let provenance = report.sectionProvenance[.dns] {
141 dnsLines.append("Provenance: \(provenanceLabel(provenance))")
142 }
143 if let error = report.dns.error {
144 dnsLines.append("Error: \(error)")
145 }
146 if report.dns.recordSections.isEmpty {
147 dnsLines.append("Records: None")
148 } else {
149 dnsLines.append("Records:")
150 for section in report.dns.recordSections {
151 let values = (section.records + section.wildcardRecords).map(\.value)
152 let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
153 dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)")
154 }
155 }
156 return dnsLines
157 }
158
159 appendSection("DNS History", to: &lines) {
160 guard !report.dnsHistory.isEmpty else {
161 return ["No DNS history available"]
162 }
163
164 return report.dnsHistory.map { event in
165 [
166 textDateFormatter.string(from: event.date),
167 event.summary,
168 "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
169 "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
170 "source=\(event.source)"
171 ].joined(separator: " | ")
172 }
173 }
174
175 appendSection("Web", to: &lines) {
176 var webLines = [
177 "TLS Status: \(report.web.tlsStatus)",
178 "TLS Grade: \(report.web.tlsGrade.rawValue)",
179 "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
180 "Certificate Warning: \(report.web.certificateWarningLevel.title)",
181 "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
182 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
183 "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
184 "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
185 "Final URL: \(report.web.finalURL ?? "Unavailable")",
186 "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
187 "Header Count: \(report.web.headerCount)"
188 ]
189 if let provenance = report.sectionProvenance[.ssl] {
190 webLines.append("TLS Provenance: \(provenanceLabel(provenance))")
191 }
192 if let provenance = report.sectionProvenance[.httpHeaders] {
193 webLines.append("HTTP Provenance: \(provenanceLabel(provenance))")
194 }
195 if let provenance = report.sectionProvenance[.redirectChain] {
196 webLines.append("Redirect Provenance: \(provenanceLabel(provenance))")
197 }
198 if let tlsError = report.web.tlsError {
199 webLines.append("TLS Error: \(tlsError)")
200 }
201 if let headersError = report.web.headersError {
202 webLines.append("Headers Error: \(headersError)")
203 }
204 if let redirectError = report.web.redirectError {
205 webLines.append("Redirect Error: \(redirectError)")
206 }
207 if !report.web.headers.isEmpty {
208 webLines.append("Headers:")
209 for header in report.web.headers {
210 webLines.append(" \(header.name): \(header.value)")
211 }
212 }
213 if !report.web.redirectChain.isEmpty {
214 webLines.append("Redirect Chain:")
215 for hop in report.web.redirectChain {
216 webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
217 }
218 }
219 return webLines
220 }
221
222 appendSection("Email", to: &lines) {
223 var emailLines = [report.email.summary]
224 if let grade = report.email.grade {
225 emailLines.append("Grade: \(grade.rawValue)")
226 }
227 if !report.email.reasons.isEmpty {
228 emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))")
229 }
230 emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
231 if let provenance = report.sectionProvenance[.emailSecurity] {
232 emailLines.append("Provenance: \(provenanceLabel(provenance))")
233 }
234 if let records = report.email.records {
235 emailLines.append("SPF: \(recordLabel(records.spf))")
236 emailLines.append("DMARC: \(recordLabel(records.dmarc))")
237 emailLines.append("DKIM: \(recordLabel(records.dkim))")
238 emailLines.append("BIMI: \(recordLabel(records.bimi))")
239 emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
240 }
241 if let error = report.email.error {
242 emailLines.append("Error: \(error)")
243 }
244 return emailLines
245 }
246
247 appendSection("Network", to: &lines) {
248 var networkLines = [
249 "Reachability: \(report.network.reachabilitySummary)",
250 "Geolocation: \(report.network.geolocationSummary)",
251 "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")",
252 "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
253 ]
254 if let provenance = report.sectionProvenance[.ipGeolocation] {
255 networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))")
256 }
257 if let error = report.network.reachabilityError {
258 networkLines.append("Reachability Error: \(error)")
259 }
260 if let error = report.network.geolocationError {
261 networkLines.append("Geolocation Error: \(error)")
262 }
263 if let error = report.network.portScanError {
264 networkLines.append("Port Scan Error: \(error)")
265 }
266 if !report.network.portScan.isEmpty {
267 networkLines.append("Port Scan:")
268 for result in report.network.portScan {
269 networkLines.append(
270 " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
271 )
272 }
273 }
274 return networkLines
275 }
276
277 appendSection("Subdomains", to: &lines) {
278 var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"]
279 if let provenance = report.sectionProvenance[.subdomains] {
280 values.append("Provenance: \(provenanceLabel(provenance))")
281 }
282 if report.subdomains.isEmpty {
283 values.append("None")
284 return values
285 }
286 if !report.subdomainGroups.isEmpty {
287 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
288 }
289 values.append(contentsOf: report.subdomains.map { "- \($0)" })
290 if !report.extendedSubdomains.isEmpty {
291 values.append("Extended:")
292 values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
293 }
294 return values
295 }
296
297 appendSection("Pricing", to: &lines) {
298 guard let pricing = report.domainPricing else {
299 return ["External pricing unavailable"]
300 }
301
302 return [
303 "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
304 "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
305 "Resale: \(pricing.resaleSignal ?? "Unavailable")",
306 "Auction: \(pricing.auctionSignal ?? "Unavailable")",
307 "Source: \(pricing.source)",
308 "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
309 ]
310 }
311
312 appendSection("Changes", to: &lines) {
313 guard let changeSummary = report.changeSummary else {
314 return ["No comparison available"]
315 }
316
317 var values = [
318 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
319 "Severity: \(changeSummary.severity.title)",
320 "Impact: \(changeSummary.impactClassification.title)",
321 "Inferred Summary: \(changeSummary.message)",
322 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
323 ]
324 if let riskScoreDelta = changeSummary.riskScoreDelta {
325 values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
326 }
327 if !changeSummary.insights.isEmpty {
328 values.append("Insights: \(changeSummary.insights.joined(separator: " | "))")
329 }
330 if !changeSummary.observedFacts.isEmpty {
331 values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
332 }
333 if let contextNote = changeSummary.contextNote {
334 values.append("Context: \(contextNote)")
335 }
336 return values
337 }
338
339 return lines.joined(separator: "\n")
340 }
341
342 static func batchText(for reports: [DomainReport], title: String) -> String {
343 guard !reports.isEmpty else {
344 return "\(title)\nNo results available."
345 }
346
347 var lines = [title, String(repeating: "=", count: title.count), ""]
348 for (index, report) in reports.enumerated() {
349 if index > 0 {
350 lines.append("")
351 lines.append(String(repeating: "=", count: 48))
352 lines.append("")
353 }
354 lines.append(text(for: report))
355 }
356 return lines.joined(separator: "\n")
357 }
358
359 static func csv(for reports: [DomainReport]) -> String {
360 csv(for: reports, workflowInsights: [])
361 }
362
363 static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String {
364 let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ")
365 let headers = [
366 "domain",
367 "timestamp",
368 "app_version",
369 "result_source",
370 "resolver",
371 "availability",
372 "risk_score",
373 "risk_level",
374 "risk_factors",
375 "insights",
376 "availability_confidence",
377 "registrar",
378 "ownership_confidence",
379 "ownership_expires",
380 "nameservers",
381 "primary_ip",
382 "ptr_record",
383 "dnssec_signed",
384 "dns_patterns",
385 "tls_status",
386 "tls_grade",
387 "tls_highlights",
388 "certificate_warning_level",
389 "hsts_preloaded",
390 "http_status",
391 "http_security_grade",
392 "final_url",
393 "email_summary",
394 "email_grade",
395 "email_confidence",
396 "subdomain_count",
397 "extended_subdomain_count",
398 "subdomain_groups",
399 "subdomain_confidence",
400 "subdomains",
401 "extended_subdomains",
402 "ownership_history",
403 "dns_history",
404 "pricing_estimated",
405 "pricing_premium",
406 "pricing_resale_signal",
407 "pricing_auction_signal",
408 "pricing_source",
409 "open_ports",
410 "reachability_summary",
411 "geolocation_summary",
412 "geolocation_confidence",
413 "data_sources",
414 "audit_note",
415 "partial_snapshot",
416 "workflow_name",
417 "workflow_source",
418 "cached_sections",
419 "change_summary",
420 "change_impact",
421 "workflow_insights"
422 ]
423
424 let rows = reports.map { report in
425 let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
426 let nameservers = joined(report.ownership?.nameservers) ?? ""
427 let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
428 let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
429 let httpStatus = report.web.statusCode.map(String.init) ?? ""
430 let subdomainCount = String(report.subdomains.count)
431 let subdomains = report.subdomains.joined(separator: " | ")
432 let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
433 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
434 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
435 let insights = report.insights.joined(separator: " | ")
436 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
437 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
438 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
439 let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
440 let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
441
442 return [
443 report.domain,
444 csvDateFormatter.string(from: report.timestamp),
445 report.appVersion,
446 report.resultSource.rawValue,
447 report.resolverDisplayName,
448 availabilityLabel(report.availability),
449 "\(report.riskAssessment.score)",
450 report.riskAssessment.level.rawValue,
451 riskFactors,
452 insights,
453 report.availabilityConfidence?.rawValue ?? "",
454 report.ownership?.registrar ?? "",
455 report.ownershipConfidence?.rawValue ?? "",
456 expirationDate,
457 nameservers,
458 report.dns.primaryIP ?? "",
459 report.dns.ptrRecord ?? "",
460 dnssecSigned,
461 dnsPatterns,
462 report.web.tlsStatus,
463 report.web.tlsGrade.rawValue,
464 tlsHighlights,
465 report.web.certificateWarningLevel.rawValue,
466 hstsPreloaded,
467 httpStatus,
468 report.web.securityGrade ?? "",
469 report.web.finalURL ?? "",
470 report.email.summary,
471 report.email.grade?.rawValue ?? "",
472 report.emailConfidence?.rawValue ?? "",
473 subdomainCount,
474 String(report.extendedSubdomains.count),
475 subdomainGroups,
476 report.subdomainConfidence?.rawValue ?? "",
477 subdomains,
478 extendedSubdomains,
479 ownershipHistory,
480 dnsHistory,
481 report.domainPricing?.estimatedPrice ?? "",
482 report.domainPricing?.premiumIndicator == true ? "true" : "false",
483 report.domainPricing?.resaleSignal ?? "",
484 report.domainPricing?.auctionSignal ?? "",
485 report.domainPricing?.source ?? "",
486 openPorts,
487 report.network.reachabilitySummary,
488 report.network.geolocationSummary,
489 report.geolocationConfidence?.rawValue ?? "",
490 report.provenance.dataSources.joined(separator: " | "),
491 report.metadata.auditNote ?? "",
492 report.metadata.isPartialSnapshot ? "true" : "false",
493 report.workflowContext?.workflowName ?? "",
494 report.workflowContext?.source ?? "",
495 report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
496 report.changeSummary?.message ?? "",
497 report.changeSummary?.impactClassification.rawValue ?? "",
498 workflowInsightSummary
499 ]
500 }
501
502 return ([headers] + rows)
503 .map { row in row.map(csvEscaped).joined(separator: ",") }
504 .joined(separator: "\n")
505 }
506
507 static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
508 guard !reports.isEmpty else {
509 return "Timeline Export\nNo snapshots available for \(domain)."
510 }
511
512 var lines = [
513 "DomainDig Timeline Export",
514 "Domain: \(domain)",
515 "Snapshots: \(reports.count)"
516 ]
517
518 for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
519 lines.append("")
520 lines.append("\(textDateFormatter.string(from: report.timestamp))")
521 lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
522 lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
523 if includeDiffSummary, let changeSummary = report.changeSummary {
524 lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
525 }
526 }
527
528 return lines.joined(separator: "\n")
529 }
530
531 static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
532 struct TimelineExportEntry: Codable {
533 let timestamp: Date
534 let summary: String?
535 let severity: String?
536 let changedSections: [String]?
537 }
538
539 struct TimelineExportPayload: Codable {
540 let domain: String
541 let exportedAt: Date
542 let snapshots: [TimelineExportEntry]
543 }
544
545 let payload = TimelineExportPayload(
546 domain: domain,
547 exportedAt: Date(),
548 snapshots: reports
549 .sorted(by: { $0.timestamp > $1.timestamp })
550 .map { report in
551 TimelineExportEntry(
552 timestamp: report.timestamp,
553 summary: report.changeSummary?.message,
554 severity: report.changeSummary?.severity.title,
555 changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
556 )
557 }
558 )
559
560 return try jsonEncoder.encode(payload)
561 }
562
563 private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
564 lines.append("")
565 lines.append(title)
566 lines.append(String(repeating: "-", count: title.count))
567 lines.append(contentsOf: body())
568 }
569
570 private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
571 switch status {
572 case .available:
573 return "Available"
574 case .registered:
575 return "Registered"
576 case .unknown:
577 return "Unknown"
578 }
579 }
580
581 private static func joined(_ values: [String]?) -> String? {
582 guard let values, !values.isEmpty else { return nil }
583 return values.joined(separator: " | ")
584 }
585
586 private static func dnssecLabel(_ value: Bool?) -> String {
587 switch value {
588 case true:
589 return "Signed"
590 case false:
591 return "Unsigned"
592 case nil:
593 return "Unavailable"
594 }
595 }
596
597 private static func ownershipDateLabel(_ date: Date?) -> String {
598 guard let date else { return "Unavailable" }
599 return textDateFormatter.string(from: date)
600 }
601
602 private static func durationLabel(_ durationMs: Int?) -> String {
603 durationMs.map { "\($0) ms" } ?? "Unavailable"
604 }
605
606 private static func httpSummary(for report: DomainReport) -> String {
607 let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
608 return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
609 }
610
611 private static func booleanLabel(_ value: Bool?) -> String {
612 guard let value else { return "Unavailable" }
613 return value ? "Yes" : "No"
614 }
615
616 private static func recordLabel(_ record: EmailSecurityRecord) -> String {
617 if record.found {
618 return record.value ?? "Present"
619 }
620 return "Unavailable"
621 }
622
623 private static func provenanceLabel(_ provenance: SectionProvenance) -> String {
624 [
625 provenance.source,
626 provenance.provider,
627 provenance.resolver.map { "resolver=\($0)" },
628 provenance.resultSource.label.lowercased(),
629 textDateFormatter.string(from: provenance.collectedAt)
630 ]
631 .compactMap { $0 }
632 .joined(separator: " | ")
633 }
634
635 private static let textDateFormatter: DateFormatter = {
636 let formatter = DateFormatter()
637 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
638 return formatter
639 }()
640
641 private static let csvDateFormatter: ISO8601DateFormatter = {
642 let formatter = ISO8601DateFormatter()
643 formatter.formatOptions = [.withInternetDateTime]
644 return formatter
645 }()
646
647 private static let jsonEncoder: JSONEncoder = {
648 let encoder = JSONEncoder()
649 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
650 encoder.dateEncodingStrategy = .iso8601
651 return encoder
652 }()
653
654 nonisolated private static func csvEscaped(_ value: String) -> String {
655 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
656 return "\"\(escaped)\""
657 }
658}
659
660private extension String {
661 var nilIfEmpty: String? {
662 isEmpty ? nil : self
663 }
664}