krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.6.0: DomainDataPortabilityService.swift · raw
1import Foundation
2import UniformTypeIdentifiers
3
4struct DomainDigBackup: Codable {
5 static let currentSchemaVersion = 1
6
7 let schemaVersion: Int
8 let exportedAt: Date
9 let appVersion: String
10 let trackedDomains: [TrackedDomain]
11 let historyEntries: [HistoryEntry]
12 let auditSessions: [AuditSession]?
13 let workflows: [DomainWorkflow]
14 let monitoringSettings: MonitoringSettings?
15 let monitoringLogs: [MonitoringLog]
16 let appSettings: AppSettingsSnapshot
17 let featureMetadata: FeatureMetadataSnapshot?
18}
19
20struct AppSettingsSnapshot: Codable {
21 let recentSearches: [String]
22 let savedDomains: [String]
23 let resolverURLString: String
24 let appDensityRawValue: String
25}
26
27struct FeatureMetadataSnapshot: Codable {
28 let cachedEntitlement: PurchaseService.CachedEntitlement?
29 let usageCredits: UsageCreditsSnapshot?
30}
31
32struct UsageCreditsSnapshot: Codable {
33 let appVersion: String
34 let remainingByFeature: [UsageCreditFeature: Int]
35}
36
37struct TrackedDomainsExport: Codable {
38 let schemaVersion: Int
39 let exportedAt: Date
40 let appVersion: String
41 let trackedDomains: [TrackedDomain]
42}
43
44struct WorkflowsExport: Codable {
45 let schemaVersion: Int
46 let exportedAt: Date
47 let appVersion: String
48 let workflows: [DomainWorkflow]
49}
50
51struct HistoryExport: Codable {
52 let schemaVersion: Int
53 let exportedAt: Date
54 let appVersion: String
55 let historyEntries: [HistoryEntry]
56}
57
58enum DataPortabilityImportMode: String, CaseIterable, Identifiable {
59 case merge
60 case replace
61
62 var id: String { rawValue }
63
64 var title: String {
65 switch self {
66 case .merge:
67 return "Merge"
68 case .replace:
69 return "Replace"
70 }
71 }
72
73 var explanation: String {
74 switch self {
75 case .merge:
76 return "Keep local data and merge imported items."
77 case .replace:
78 return "Replace local data with the imported file."
79 }
80 }
81}
82
83enum DataPortabilityImportKind: String {
84 case backup
85 case trackedDomains
86 case workflows
87}
88
89struct DataLifecycleSummary: Equatable {
90 let trackedDomains: Int
91 let historySnapshots: Int
92 let auditSessions: Int
93 let workflows: Int
94 let cachedItems: Int
95 let monitoringLogs: Int
96}
97
98struct DataValidationMessage: Identifiable, Equatable {
99 let id = UUID()
100 let text: String
101 let isError: Bool
102}
103
104struct DataValidationReport {
105 let messages: [DataValidationMessage]
106
107 var warnings: [String] {
108 messages.filter { !$0.isError }.map(\.text)
109 }
110
111 var errors: [String] {
112 messages.filter(\.isError).map(\.text)
113 }
114}
115
116struct DataImportPreview {
117 let fileName: String
118 let kind: DataPortabilityImportKind
119 let mode: DataPortabilityImportMode
120 let summaryLines: [String]
121 let warnings: [String]
122 let currentCounts: DataLifecycleSummary
123 let projectedCounts: DataLifecycleSummary
124 fileprivate let payload: ImportPayload
125}
126
127struct DataImportResult {
128 let kind: DataPortabilityImportKind
129 let mode: DataPortabilityImportMode
130 let summary: String
131 let warnings: [String]
132}
133
134enum DataPortabilityError: LocalizedError {
135 case unsupportedFormat
136 case unsupportedSchema(Int)
137 case invalidCSV(String)
138 case unreadableFile
139 case validationFailed([String])
140
141 var errorDescription: String? {
142 switch self {
143 case .unsupportedFormat:
144 return "The selected file is not a supported DomainDig import."
145 case .unsupportedSchema(let version):
146 return "Backup schema v\(version) is not supported by this version of DomainDig."
147 case .invalidCSV(let message):
148 return message
149 case .unreadableFile:
150 return "The selected file could not be read."
151 case .validationFailed(let messages):
152 return messages.joined(separator: "\n")
153 }
154 }
155}
156
157enum DataPortabilityCSV {
158 static func trackedDomains(_ domains: [TrackedDomain]) -> String {
159 let header = [
160 "id",
161 "domain",
162 "createdAt",
163 "updatedAt",
164 "note",
165 "isPinned",
166 "monitoringEnabled",
167 "lastKnownAvailability",
168 "certificateWarningLevel",
169 "certificateDaysRemaining",
170 "lastMonitoredAt",
171 "lastAlertAt"
172 ]
173
174 let rows = domains.map { domain in
175 [
176 domain.id.uuidString,
177 domain.domain,
178 DomainDataPortabilityService.formatISODate(domain.createdAt),
179 DomainDataPortabilityService.formatISODate(domain.updatedAt),
180 domain.note ?? "",
181 String(domain.isPinned),
182 String(domain.monitoringEnabled),
183 domain.lastKnownAvailability?.rawValue ?? "",
184 domain.certificateWarningLevel.rawValue,
185 domain.certificateDaysRemaining.map(String.init) ?? "",
186 domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "",
187 domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? ""
188 ]
189 }
190
191 return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
192 }
193
194 static func workflows(_ workflows: [DomainWorkflow]) -> String {
195 let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"]
196 let rows = workflows.map { workflow in
197 [
198 workflow.id.uuidString,
199 workflow.name,
200 workflow.domains.joined(separator: "|"),
201 DomainDataPortabilityService.formatISODate(workflow.createdAt),
202 DomainDataPortabilityService.formatISODate(workflow.updatedAt),
203 workflow.notes ?? ""
204 ]
205 }
206
207 return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
208 }
209
210 static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] {
211 let rows = try parseRows(from: string)
212 guard let header = rows.first else { return [] }
213 let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
214
215 return mappedRows.compactMap { row in
216 let domain = Self.normalizedDomain(row["domain"])
217 guard !domain.isEmpty else { return nil }
218
219 let createdAt = parseDate(row["createdAt"]) ?? Date()
220 let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
221 let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:))
222 let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none
223
224 return TrackedDomain(
225 id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
226 domain: domain,
227 createdAt: createdAt,
228 updatedAt: updatedAt,
229 note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
230 isPinned: Self.parseBool(row["isPinned"]),
231 monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true,
232 lastKnownAvailability: availability,
233 certificateWarningLevel: certificateLevel,
234 certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
235 lastMonitoredAt: parseDate(row["lastMonitoredAt"]),
236 lastAlertAt: parseDate(row["lastAlertAt"]),
237 monitoringState: MonitoringState(),
238 pendingMonitoringAlerts: []
239 )
240 }
241 }
242
243 static func parseWorkflows(from string: String) throws -> [DomainWorkflow] {
244 let rows = try parseRows(from: string)
245 guard let header = rows.first else { return [] }
246 let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
247
248 return mappedRows.compactMap { row in
249 let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
250 let domains = (row["domains"] ?? "")
251 .split(separator: "|")
252 .map { Self.normalizedDomain(String($0)) }
253 .filter { !$0.isEmpty }
254
255 guard !name.isEmpty, !domains.isEmpty else { return nil }
256
257 let createdAt = parseDate(row["createdAt"]) ?? Date()
258 let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
259 return DomainWorkflow(
260 id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
261 name: name,
262 domains: Self.deduplicated(domains),
263 createdAt: createdAt,
264 updatedAt: updatedAt,
265 notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
266 )
267 }
268 }
269
270 private nonisolated static func parseRows(from string: String) throws -> [[String]] {
271 var rows: [[String]] = []
272 var currentRow: [String] = []
273 var currentField = ""
274 var insideQuotes = false
275
276 for character in string {
277 switch character {
278 case "\"":
279 insideQuotes.toggle()
280 case "," where !insideQuotes:
281 currentRow.append(currentField)
282 currentField = ""
283 case "\n" where !insideQuotes:
284 currentRow.append(currentField)
285 rows.append(currentRow)
286 currentRow = []
287 currentField = ""
288 case "\r":
289 continue
290 default:
291 currentField.append(character)
292 }
293 }
294
295 if insideQuotes {
296 throw DataPortabilityError.invalidCSV("The CSV file is malformed.")
297 }
298
299 if !currentField.isEmpty || !currentRow.isEmpty {
300 currentRow.append(currentField)
301 rows.append(currentRow)
302 }
303
304 return rows.filter { !$0.allSatisfy(\.isEmpty) }
305 }
306
307 private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] {
308 Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) })
309 }
310
311 private nonisolated static func csvLine(_ values: [String]) -> String {
312 values.map { value in
313 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
314 return "\"\(escaped)\""
315 }
316 .joined(separator: ",")
317 }
318
319 private nonisolated static func parseDate(_ value: String?) -> Date? {
320 guard let value, !value.isEmpty else { return nil }
321 return DomainDataPortabilityService.parseISODate(value)
322 }
323
324 private nonisolated static func parseBool(_ value: String?) -> Bool {
325 guard let value else { return false }
326 return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"
327 }
328
329 private nonisolated static func normalizedDomain(_ value: String?) -> String {
330 value?
331 .trimmingCharacters(in: .whitespacesAndNewlines)
332 .replacingOccurrences(of: "https://", with: "")
333 .replacingOccurrences(of: "http://", with: "")
334 .components(separatedBy: "/")
335 .first?
336 .lowercased() ?? ""
337 }
338
339 private nonisolated static func deduplicated(_ values: [String]) -> [String] {
340 var seen = Set<String>()
341 return values.filter { seen.insert($0).inserted }
342 }
343}
344
345enum DataMigrationService {
346 private static let migrationMarkerKey = "data.migrations.v3_4_0"
347
348 static func migrateIfNeeded(defaults: UserDefaults = .standard) {
349 guard !defaults.bool(forKey: migrationMarkerKey) else { return }
350
351 let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
352 DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults)
353
354 let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
355 DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
356
357 let auditSessions = DomainDataPortabilityService.loadAuditSessions(defaults: defaults)
358 DomainDataPortabilityService.saveAuditSessions(auditSessions, defaults: defaults)
359
360 let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
361 DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
362
363 let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults)
364 let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
365 DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults)
366
367 let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults)
368 DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults)
369
370 defaults.set(true, forKey: migrationMarkerKey)
371 }
372}
373
374enum DataValidationService {
375 static func validate(backup: DomainDigBackup) -> DataValidationReport {
376 var messages: [DataValidationMessage] = []
377
378 if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
379 messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true))
380 } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion {
381 messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false))
382 }
383
384 messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
385 messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
386 messages.append(contentsOf: validateAuditSessions(backup.auditSessions ?? []))
387 messages.append(contentsOf: validateWorkflows(backup.workflows))
388
389 if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
390 messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false))
391 }
392
393 return DataValidationReport(messages: messages)
394 }
395
396 static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport {
397 DataValidationReport(messages: validateTrackedDomains(trackedDomains))
398 }
399
400 static func validate(workflows: [DomainWorkflow]) -> DataValidationReport {
401 DataValidationReport(messages: validateWorkflows(workflows))
402 }
403
404 private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] {
405 var messages: [DataValidationMessage] = []
406 var seen = Set<String>()
407
408 for trackedDomain in trackedDomains {
409 let normalized = normalizeDomain(trackedDomain.domain)
410 if normalized.isEmpty {
411 messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true))
412 continue
413 }
414
415 if !seen.insert(normalized).inserted {
416 messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false))
417 }
418 }
419
420 return messages
421 }
422
423 private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] {
424 var messages: [DataValidationMessage] = []
425 for entry in historyEntries {
426 if normalizeDomain(entry.domain).isEmpty {
427 messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true))
428 }
429 if entry.timestamp == .distantPast {
430 messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false))
431 }
432 for issue in entry.validationIssues {
433 messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false))
434 }
435 }
436 return messages
437 }
438
439 private static func validateAuditSessions(_ auditSessions: [AuditSession]) -> [DataValidationMessage] {
440 var messages: [DataValidationMessage] = []
441 var seen = Set<UUID>()
442
443 for session in auditSessions {
444 if normalizeDomain(session.domain).isEmpty {
445 messages.append(.init(text: "An audit session is missing its domain name.", isError: true))
446 }
447
448 if !seen.insert(session.id).inserted {
449 messages.append(.init(text: "Duplicate audit session found for \(session.domain). Merge rules will consolidate it.", isError: false))
450 }
451 }
452
453 return messages
454 }
455
456 private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
457 var messages: [DataValidationMessage] = []
458 for workflow in workflows {
459 if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
460 messages.append(.init(text: "A workflow is missing its name.", isError: true))
461 }
462 if workflow.domains.isEmpty {
463 messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false))
464 }
465 if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count {
466 messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false))
467 }
468 }
469 return messages
470 }
471
472 private nonisolated static func normalizeDomain(_ value: String) -> String {
473 value
474 .trimmingCharacters(in: .whitespacesAndNewlines)
475 .replacingOccurrences(of: "https://", with: "")
476 .replacingOccurrences(of: "http://", with: "")
477 .components(separatedBy: "/")
478 .first?
479 .lowercased() ?? ""
480 }
481}
482
483enum DomainDataPortabilityService {
484 static let backupUTType = UTType.json
485 static let csvUTType = UTType.commaSeparatedText
486
487 fileprivate nonisolated static func formatISODate(_ date: Date) -> String {
488 let formatter = ISO8601DateFormatter()
489 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
490 return formatter.string(from: date)
491 }
492
493 fileprivate nonisolated static func parseISODate(_ value: String) -> Date? {
494 let formatter = ISO8601DateFormatter()
495 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
496 return formatter.date(from: value)
497 }
498
499 private enum StorageKey {
500 static let recentSearches = "recentSearches"
501 static let savedDomains = "savedDomains"
502 static let trackedDomains = "trackedDomains"
503 static let legacyWatchedDomains = "watchedDomains"
504 static let history = "lookupHistory"
505 static let audits = "domainAudits"
506 static let workflows = "domainWorkflows"
507 static let monitoringSettings = "monitoring.settings"
508 static let monitoringLogs = "monitoring.logs"
509 static let appDensity = AppDensity.userDefaultsKey
510 static let resolverURL = DNSResolverOption.userDefaultsKey
511 static let purchaseCache = "purchase.cachedEntitlement"
512 static let usageCredits = "usageCredits.ledger"
513 }
514
515 static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] {
516 defaults.stringArray(forKey: StorageKey.recentSearches) ?? []
517 }
518
519 static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) {
520 defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches)
521 }
522
523 static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] {
524 defaults.stringArray(forKey: StorageKey.savedDomains) ?? []
525 }
526
527 static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) {
528 defaults.set(values, forKey: StorageKey.savedDomains)
529 }
530
531 static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] {
532 let decoder = JSONDecoder()
533
534 if let data = defaults.data(forKey: StorageKey.trackedDomains),
535 let domains = try? decoder.decode([TrackedDomain].self, from: data) {
536 return deduplicatedTrackedDomains(domains)
537 }
538
539 if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains),
540 let legacy = try? decoder.decode([WatchedDomain].self, from: data) {
541 return deduplicatedTrackedDomains(
542 legacy.map {
543 TrackedDomain(
544 id: $0.id,
545 domain: normalizeDomain($0.domain),
546 createdAt: $0.createdAt,
547 updatedAt: $0.createdAt,
548 lastKnownAvailability: $0.lastKnownAvailability
549 )
550 }
551 )
552 }
553
554 return []
555 }
556
557 static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) {
558 if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) {
559 defaults.set(data, forKey: StorageKey.trackedDomains)
560 }
561 defaults.removeObject(forKey: StorageKey.legacyWatchedDomains)
562 }
563
564 static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] {
565 guard let data = defaults.data(forKey: StorageKey.history) else { return [] }
566
567 if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
568 return deduplicatedHistoryEntries(entries)
569 }
570
571 guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
572 return []
573 }
574
575 let decoder = JSONDecoder()
576 let entries = rawArray.compactMap { item -> HistoryEntry? in
577 guard JSONSerialization.isValidJSONObject(item),
578 let entryData = try? JSONSerialization.data(withJSONObject: item) else {
579 return nil
580 }
581 return try? decoder.decode(HistoryEntry.self, from: entryData)
582 }
583 return deduplicatedHistoryEntries(entries)
584 }
585
586 static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) {
587 if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) {
588 defaults.set(data, forKey: StorageKey.history)
589 }
590 }
591
592 static func loadAuditSessions(defaults: UserDefaults = .standard) -> [AuditSession] {
593 guard let data = defaults.data(forKey: StorageKey.audits),
594 let sessions = try? JSONDecoder().decode([AuditSession].self, from: data) else {
595 return []
596 }
597 return sessions.sorted { $0.createdAt > $1.createdAt }
598 }
599
600 static func saveAuditSessions(_ auditSessions: [AuditSession], defaults: UserDefaults = .standard) {
601 let sessions = auditSessions.sorted { $0.createdAt > $1.createdAt }
602 if let data = try? JSONEncoder().encode(sessions) {
603 defaults.set(data, forKey: StorageKey.audits)
604 }
605 }
606
607 static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
608 guard let data = defaults.data(forKey: StorageKey.workflows),
609 let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
610 return []
611 }
612
613 return deduplicatedWorkflows(workflows).sorted(by: workflowSort)
614 }
615
616 static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) {
617 if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) {
618 defaults.set(data, forKey: StorageKey.workflows)
619 }
620 }
621
622 static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings {
623 guard let data = defaults.data(forKey: StorageKey.monitoringSettings),
624 let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else {
625 return MonitoringSettings()
626 }
627 return settings
628 }
629
630 static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) {
631 if let data = try? JSONEncoder().encode(settings) {
632 defaults.set(data, forKey: StorageKey.monitoringSettings)
633 }
634 }
635
636 static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] {
637 guard let data = defaults.data(forKey: StorageKey.monitoringLogs),
638 let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else {
639 return []
640 }
641 return Array(logs.prefix(MonitoringStorage.maxLogs))
642 }
643
644 static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) {
645 if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) {
646 defaults.set(data, forKey: StorageKey.monitoringLogs)
647 }
648 }
649
650 static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot {
651 AppSettingsSnapshot(
652 recentSearches: loadRecentSearches(defaults: defaults),
653 savedDomains: loadSavedDomains(defaults: defaults),
654 resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString,
655 appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue
656 )
657 }
658
659 static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) {
660 saveRecentSearches(settings.recentSearches, defaults: defaults)
661 saveSavedDomains(settings.savedDomains, defaults: defaults)
662 defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL)
663 defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity)
664 }
665
666 static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot {
667 let cachedEntitlement: PurchaseService.CachedEntitlement?
668 if let data = defaults.data(forKey: StorageKey.purchaseCache) {
669 cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data)
670 } else {
671 cachedEntitlement = nil
672 }
673
674 let usageCredits: UsageCreditsSnapshot?
675 if let data = defaults.data(forKey: StorageKey.usageCredits) {
676 usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data)
677 } else {
678 usageCredits = nil
679 }
680
681 return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits)
682 }
683
684 static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) {
685 if let cachedEntitlement = featureMetadata?.cachedEntitlement,
686 let data = try? JSONEncoder().encode(cachedEntitlement) {
687 defaults.set(data, forKey: StorageKey.purchaseCache)
688 }
689
690 if let usageCredits = featureMetadata?.usageCredits,
691 let data = try? JSONEncoder().encode(usageCredits) {
692 defaults.set(data, forKey: StorageKey.usageCredits)
693 }
694 }
695
696 static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup {
697 DataMigrationService.migrateIfNeeded(defaults: defaults)
698 return DomainDigBackup(
699 schemaVersion: DomainDigBackup.currentSchemaVersion,
700 exportedAt: Date(),
701 appVersion: AppVersion.current,
702 trackedDomains: loadTrackedDomains(defaults: defaults),
703 historyEntries: loadHistoryEntries(defaults: defaults),
704 auditSessions: loadAuditSessions(defaults: defaults),
705 workflows: loadWorkflows(defaults: defaults),
706 monitoringSettings: loadMonitoringSettings(defaults: defaults),
707 monitoringLogs: loadMonitoringLogs(defaults: defaults),
708 appSettings: loadAppSettings(defaults: defaults),
709 featureMetadata: loadFeatureMetadata(defaults: defaults)
710 )
711 }
712
713 static func backupData(defaults: UserDefaults = .standard) throws -> Data {
714 try makeEncoder().encode(currentBackup(defaults: defaults))
715 }
716
717 static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data {
718 try makeEncoder().encode(
719 TrackedDomainsExport(
720 schemaVersion: DomainDigBackup.currentSchemaVersion,
721 exportedAt: Date(),
722 appVersion: AppVersion.current,
723 trackedDomains: loadTrackedDomains(defaults: defaults)
724 )
725 )
726 }
727
728 static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data {
729 try makeEncoder().encode(
730 WorkflowsExport(
731 schemaVersion: DomainDigBackup.currentSchemaVersion,
732 exportedAt: Date(),
733 appVersion: AppVersion.current,
734 workflows: loadWorkflows(defaults: defaults)
735 )
736 )
737 }
738
739 static func historyExportData(defaults: UserDefaults = .standard) throws -> Data {
740 try makeEncoder().encode(
741 HistoryExport(
742 schemaVersion: DomainDigBackup.currentSchemaVersion,
743 exportedAt: Date(),
744 appVersion: AppVersion.current,
745 historyEntries: loadHistoryEntries(defaults: defaults)
746 )
747 )
748 }
749
750 static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String {
751 DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults))
752 }
753
754 static func workflowsCSV(defaults: UserDefaults = .standard) -> String {
755 DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults))
756 }
757
758 static func prepareImport(
759 data: Data,
760 fileName: String,
761 mode: DataPortabilityImportMode,
762 defaults: UserDefaults = .standard
763 ) throws -> DataImportPreview {
764 DataMigrationService.migrateIfNeeded(defaults: defaults)
765 let currentCounts = lifecycleSummary(defaults: defaults)
766
767 let payload = try decodeImportPayload(from: data, fileName: fileName)
768 let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults)
769
770 return DataImportPreview(
771 fileName: fileName,
772 kind: payload.kind,
773 mode: mode,
774 summaryLines: payload.summaryLines(mode: mode),
775 warnings: payload.validationReport.warnings,
776 currentCounts: currentCounts,
777 projectedCounts: projectedCounts,
778 payload: payload
779 )
780 }
781
782 static func applyImport(
783 _ preview: DataImportPreview,
784 mode: DataPortabilityImportMode,
785 defaults: UserDefaults = .standard
786 ) throws -> DataImportResult {
787 switch preview.payload {
788 case .backup(let backup, let report):
789 try applyBackup(backup, mode: mode, defaults: defaults)
790 return DataImportResult(
791 kind: .backup,
792 mode: mode,
793 summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, \(backup.auditSessions?.count ?? 0) audit sessions, and \(backup.workflows.count) workflows.",
794 warnings: report.warnings
795 )
796 case .trackedDomains(let trackedDomains, let report):
797 applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults)
798 return DataImportResult(
799 kind: .trackedDomains,
800 mode: mode,
801 summary: "Imported \(trackedDomains.count) tracked domains.",
802 warnings: report.warnings
803 )
804 case .workflows(let workflows, let report):
805 applyWorkflowsImport(workflows, mode: mode, defaults: defaults)
806 return DataImportResult(
807 kind: .workflows,
808 mode: mode,
809 summary: "Imported \(workflows.count) workflows.",
810 warnings: report.warnings
811 )
812 }
813 }
814
815 static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport {
816 switch try decodeImportPayload(from: data, fileName: fileName) {
817 case .backup(_, let report):
818 return report
819 case .trackedDomains(_, let report):
820 return report
821 case .workflows(_, let report):
822 return report
823 }
824 }
825
826 static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary {
827 let appSettings = loadAppSettings(defaults: defaults)
828 let featureMetadata = loadFeatureMetadata(defaults: defaults)
829 let cachedItems = appSettings.recentSearches.count
830 + appSettings.savedDomains.count
831 + (featureMetadata.cachedEntitlement == nil ? 0 : 1)
832 + (featureMetadata.usageCredits == nil ? 0 : 1)
833
834 return DataLifecycleSummary(
835 trackedDomains: loadTrackedDomains(defaults: defaults).count,
836 historySnapshots: loadHistoryEntries(defaults: defaults).count,
837 auditSessions: loadAuditSessions(defaults: defaults).count,
838 workflows: loadWorkflows(defaults: defaults).count,
839 cachedItems: cachedItems,
840 monitoringLogs: loadMonitoringLogs(defaults: defaults).count
841 )
842 }
843
844 private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload {
845 let decoder = JSONDecoder()
846
847 if let backup = try? decoder.decode(DomainDigBackup.self, from: data) {
848 if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
849 throw DataPortabilityError.unsupportedSchema(backup.schemaVersion)
850 }
851 let report = DataValidationService.validate(backup: backup)
852 if !report.errors.isEmpty {
853 throw DataPortabilityError.validationFailed(report.errors)
854 }
855 return .backup(backup, report)
856 }
857
858 if let backup = try? decodeLegacyBackup(from: data) {
859 let report = DataValidationService.validate(backup: backup)
860 if !report.errors.isEmpty {
861 throw DataPortabilityError.validationFailed(report.errors)
862 }
863 return .backup(backup, report)
864 }
865
866 if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) {
867 let report = DataValidationService.validate(trackedDomains: export.trackedDomains)
868 if !report.errors.isEmpty {
869 throw DataPortabilityError.validationFailed(report.errors)
870 }
871 return .trackedDomains(export.trackedDomains, report)
872 }
873
874 if let export = try? decoder.decode(WorkflowsExport.self, from: data) {
875 let report = DataValidationService.validate(workflows: export.workflows)
876 if !report.errors.isEmpty {
877 throw DataPortabilityError.validationFailed(report.errors)
878 }
879 return .workflows(export.workflows, report)
880 }
881
882 if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) {
883 let report = DataValidationService.validate(trackedDomains: trackedDomains)
884 if !report.errors.isEmpty {
885 throw DataPortabilityError.validationFailed(report.errors)
886 }
887 return .trackedDomains(trackedDomains, report)
888 }
889
890 if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) {
891 let report = DataValidationService.validate(workflows: workflows)
892 if !report.errors.isEmpty {
893 throw DataPortabilityError.validationFailed(report.errors)
894 }
895 return .workflows(workflows, report)
896 }
897
898 let lowercasedFileName = fileName.lowercased()
899 if lowercasedFileName.hasSuffix(".csv") {
900 guard let string = String(data: data, encoding: .utf8) else {
901 throw DataPortabilityError.unreadableFile
902 }
903 if lowercasedFileName.contains("workflow") {
904 let workflows = try DataPortabilityCSV.parseWorkflows(from: string)
905 let report = DataValidationService.validate(workflows: workflows)
906 if !report.errors.isEmpty {
907 throw DataPortabilityError.validationFailed(report.errors)
908 }
909 return .workflows(workflows, report)
910 }
911
912 let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string)
913 let report = DataValidationService.validate(trackedDomains: trackedDomains)
914 if !report.errors.isEmpty {
915 throw DataPortabilityError.validationFailed(report.errors)
916 }
917 return .trackedDomains(trackedDomains, report)
918 }
919
920 throw DataPortabilityError.unsupportedFormat
921 }
922
923 private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup {
924 struct LegacyBackup: Decodable {
925 let trackedDomains: [TrackedDomain]?
926 let historyEntries: [HistoryEntry]?
927 let workflows: [DomainWorkflow]?
928 let monitoringSettings: MonitoringSettings?
929 let monitoringLogs: [MonitoringLog]?
930 let recentSearches: [String]?
931 let savedDomains: [String]?
932 }
933
934 let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data)
935 return DomainDigBackup(
936 schemaVersion: 0,
937 exportedAt: Date(),
938 appVersion: AppVersion.current,
939 trackedDomains: legacy.trackedDomains ?? [],
940 historyEntries: legacy.historyEntries ?? [],
941 auditSessions: nil,
942 workflows: legacy.workflows ?? [],
943 monitoringSettings: legacy.monitoringSettings,
944 monitoringLogs: legacy.monitoringLogs ?? [],
945 appSettings: AppSettingsSnapshot(
946 recentSearches: legacy.recentSearches ?? [],
947 savedDomains: legacy.savedDomains ?? [],
948 resolverURLString: DNSResolverOption.defaultURLString,
949 appDensityRawValue: AppDensity.compact.rawValue
950 ),
951 featureMetadata: nil
952 )
953 }
954
955 private static func applyBackup(
956 _ backup: DomainDigBackup,
957 mode: DataPortabilityImportMode,
958 defaults: UserDefaults
959 ) throws {
960 if mode == .replace {
961 saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
962 saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
963 saveAuditSessions(backup.auditSessions ?? [], defaults: defaults)
964 saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
965 saveMonitoringSettings(
966 MonitoringStorage.sanitizeSettings(
967 backup.monitoringSettings ?? MonitoringSettings(),
968 trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains)
969 ),
970 defaults: defaults
971 )
972 saveMonitoringLogs(backup.monitoringLogs, defaults: defaults)
973 saveAppSettings(backup.appSettings, defaults: defaults)
974 saveFeatureMetadata(backup.featureMetadata, defaults: defaults)
975 return
976 }
977
978 let existingTrackedDomains = loadTrackedDomains(defaults: defaults)
979 let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains)
980 saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults)
981
982 let existingHistoryEntries = loadHistoryEntries(defaults: defaults)
983 let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) }
984 saveHistoryEntries(
985 mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory),
986 defaults: defaults
987 )
988
989 let existingAuditSessions = loadAuditSessions(defaults: defaults)
990 let mergedAuditSessions = mergeAuditSessions(existing: existingAuditSessions, incoming: backup.auditSessions ?? [])
991 saveAuditSessions(mergedAuditSessions, defaults: defaults)
992
993 let existingWorkflows = loadWorkflows(defaults: defaults)
994 saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
995
996 let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults)
997 saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults)
998
999 let remappedMonitoringSettings = remapMonitoringSettings(
1000 backup.monitoringSettings ?? MonitoringSettings(),
1001 using: mergedTrackedDomains.idMap
1002 )
1003 saveMonitoringSettings(
1004 MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains),
1005 defaults: defaults
1006 )
1007
1008 let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings)
1009 saveAppSettings(mergedSettings, defaults: defaults)
1010
1011 let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata)
1012 saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults)
1013 }
1014
1015 private static func applyTrackedDomainsImport(
1016 _ trackedDomains: [TrackedDomain],
1017 mode: DataPortabilityImportMode,
1018 defaults: UserDefaults
1019 ) {
1020 let importedDomains = deduplicatedTrackedDomains(trackedDomains)
1021 let finalDomains: [TrackedDomain]
1022 switch mode {
1023 case .merge:
1024 finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains
1025 case .replace:
1026 finalDomains = importedDomains
1027 }
1028
1029 saveTrackedDomains(finalDomains, defaults: defaults)
1030 let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains)
1031 saveMonitoringSettings(sanitizedSettings, defaults: defaults)
1032 }
1033
1034 private static func applyWorkflowsImport(
1035 _ workflows: [DomainWorkflow],
1036 mode: DataPortabilityImportMode,
1037 defaults: UserDefaults
1038 ) {
1039 let importedWorkflows = deduplicatedWorkflows(workflows)
1040 let finalWorkflows: [DomainWorkflow]
1041 switch mode {
1042 case .merge:
1043 finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows)
1044 case .replace:
1045 finalWorkflows = importedWorkflows
1046 }
1047 saveWorkflows(finalWorkflows, defaults: defaults)
1048 }
1049
1050 private static func projectCounts(
1051 for payload: ImportPayload,
1052 mode: DataPortabilityImportMode,
1053 defaults: UserDefaults
1054 ) -> DataLifecycleSummary {
1055 let current = lifecycleSummary(defaults: defaults)
1056 switch payload {
1057 case .backup(let backup, _):
1058 if mode == .replace {
1059 return DataLifecycleSummary(
1060 trackedDomains: backup.trackedDomains.count,
1061 historySnapshots: backup.historyEntries.count,
1062 auditSessions: backup.auditSessions?.count ?? 0,
1063 workflows: backup.workflows.count,
1064 cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
1065 monitoringLogs: backup.monitoringLogs.count
1066 )
1067 }
1068
1069 return DataLifecycleSummary(
1070 trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
1071 historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
1072 auditSessions: mergeAuditSessions(existing: loadAuditSessions(defaults: defaults), incoming: backup.auditSessions ?? []).count,
1073 workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
1074 cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
1075 monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
1076 )
1077 case .trackedDomains(let trackedDomains, _):
1078 let total = mode == .replace
1079 ? trackedDomains.count
1080 : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count
1081 return DataLifecycleSummary(
1082 trackedDomains: total,
1083 historySnapshots: current.historySnapshots,
1084 auditSessions: current.auditSessions,
1085 workflows: current.workflows,
1086 cachedItems: current.cachedItems,
1087 monitoringLogs: current.monitoringLogs
1088 )
1089 case .workflows(let workflows, _):
1090 let total = mode == .replace
1091 ? workflows.count
1092 : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count
1093 return DataLifecycleSummary(
1094 trackedDomains: current.trackedDomains,
1095 historySnapshots: current.historySnapshots,
1096 auditSessions: current.auditSessions,
1097 workflows: total,
1098 cachedItems: current.cachedItems,
1099 monitoringLogs: current.monitoringLogs
1100 )
1101 }
1102 }
1103
1104 private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains {
1105 var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) })
1106 var idMap: [UUID: UUID] = [:]
1107
1108 for trackedDomain in incoming {
1109 let key = normalizeDomain(trackedDomain.domain)
1110 guard !key.isEmpty else { continue }
1111
1112 if let existingDomain = mergedByDomain[key] {
1113 let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt
1114 ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain)
1115 : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain)
1116 mergedByDomain[key] = chosen
1117 idMap[trackedDomain.id] = chosen.id
1118 } else {
1119 let normalized = normalizedTrackedDomain(trackedDomain)
1120 mergedByDomain[key] = normalized
1121 idMap[trackedDomain.id] = normalized.id
1122 }
1123 }
1124
1125 for trackedDomain in existing {
1126 idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id
1127 }
1128
1129 return MergedTrackedDomains(
1130 domains: mergedByDomain.values.sorted {
1131 if $0.updatedAt != $1.updatedAt {
1132 return $0.updatedAt > $1.updatedAt
1133 }
1134 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
1135 },
1136 idMap: idMap
1137 )
1138 }
1139
1140 private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain {
1141 let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain)
1142 let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming
1143 let note = winner.note ?? existing.note ?? incoming.note
1144
1145 return TrackedDomain(
1146 id: existing.id,
1147 domain: normalizedDomain,
1148 createdAt: min(existing.createdAt, incoming.createdAt),
1149 updatedAt: max(existing.updatedAt, incoming.updatedAt),
1150 note: note,
1151 isPinned: existing.isPinned || incoming.isPinned,
1152 monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled,
1153 lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability,
1154 lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID,
1155 lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary,
1156 lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity,
1157 certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
1158 certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
1159 lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(),
1160 lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max(),
1161 monitoringState: preferredMonitoringState(existing.monitoringState, incoming.monitoringState),
1162 pendingMonitoringAlerts: deduplicatedPendingAlerts(existing.pendingMonitoringAlerts + incoming.pendingMonitoringAlerts)
1163 )
1164 }
1165
1166 private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] {
1167 var mergedByKey: [String: HistoryEntry] = [:]
1168
1169 for entry in existing + incoming {
1170 let key = historyKey(for: entry)
1171 if let existingEntry = mergedByKey[key] {
1172 mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry)
1173 } else {
1174 mergedByKey[key] = entry
1175 }
1176 }
1177
1178 return mergedByKey.values.sorted { lhs, rhs in
1179 if lhs.timestamp != rhs.timestamp {
1180 return lhs.timestamp > rhs.timestamp
1181 }
1182 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1183 }
1184 }
1185
1186 private static func mergeAuditSessions(existing: [AuditSession], incoming: [AuditSession]) -> [AuditSession] {
1187 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) })
1188
1189 for session in incoming {
1190 if let existingSession = merged[session.id] {
1191 let winner = existingSession.findings.count >= session.findings.count ? existingSession : session
1192 let candidateNotes = [existingSession.notes, session.notes]
1193 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
1194 .filter { !$0.isEmpty }
1195 let notes = candidateNotes.reduce(into: [String]()) { result, note in
1196 if !result.contains(note) {
1197 result.append(note)
1198 }
1199 }.joined(separator: "\n\n")
1200 merged[session.id] = AuditSession(
1201 id: existingSession.id,
1202 domain: existingSession.domain,
1203 createdAt: min(existingSession.createdAt, session.createdAt),
1204 reviewer: winner.reviewer,
1205 status: winner.status,
1206 evidence: winner.evidence,
1207 findings: winner.findings,
1208 notes: notes,
1209 checklist: winner.checklist
1210 )
1211 } else {
1212 merged[session.id] = session
1213 }
1214 }
1215
1216 return merged.values.sorted { $0.createdAt > $1.createdAt }
1217 }
1218
1219 private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
1220 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
1221
1222 for workflow in incoming {
1223 let normalized = normalizedWorkflow(workflow)
1224 if let existingWorkflow = merged[workflow.id] {
1225 let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized
1226 merged[workflow.id] = DomainWorkflow(
1227 id: existingWorkflow.id,
1228 name: winner.name,
1229 domains: deduplicated(existingWorkflow.domains + normalized.domains),
1230 createdAt: min(existingWorkflow.createdAt, normalized.createdAt),
1231 updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt),
1232 notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes
1233 )
1234 } else {
1235 merged[workflow.id] = normalized
1236 }
1237 }
1238
1239 return merged.values.sorted(by: workflowSort)
1240 }
1241
1242 private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] {
1243 var merged: [UUID: MonitoringLog] = [:]
1244 for log in existing + incoming {
1245 if let current = merged[log.id] {
1246 merged[log.id] = log.timestamp >= current.timestamp ? log : current
1247 } else {
1248 merged[log.id] = log
1249 }
1250 }
1251 return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 }
1252 }
1253
1254 private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot {
1255 AppSettingsSnapshot(
1256 recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 },
1257 savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains),
1258 resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString,
1259 appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue
1260 )
1261 }
1262
1263 private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot {
1264 guard let incoming else { return existing }
1265
1266 let mergedUsageCredits: UsageCreditsSnapshot?
1267 switch (existing.usageCredits, incoming.usageCredits) {
1268 case let (.some(lhs), .some(rhs)):
1269 let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
1270 (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0))
1271 })
1272 mergedUsageCredits = UsageCreditsSnapshot(
1273 appVersion: rhs.appVersion,
1274 remainingByFeature: mergedRemaining
1275 )
1276 case (.some(let lhs), nil):
1277 mergedUsageCredits = lhs
1278 case (nil, .some(let rhs)):
1279 mergedUsageCredits = rhs
1280 case (nil, nil):
1281 mergedUsageCredits = nil
1282 }
1283
1284 return FeatureMetadataSnapshot(
1285 cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement,
1286 usageCredits: mergedUsageCredits
1287 )
1288 }
1289
1290 private static func remapMonitoringSettings(
1291 _ settings: MonitoringSettings,
1292 using idMap: [UUID: UUID]
1293 ) -> MonitoringSettings {
1294 var remapped = settings
1295 remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 })
1296 return remapped
1297 }
1298
1299 private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry {
1300 var remapped = entry
1301 if let trackedDomainID = entry.trackedDomainID {
1302 remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID
1303 }
1304 return remapped
1305 }
1306
1307 private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry {
1308 if historyQualityScore(lhs) == historyQualityScore(rhs) {
1309 return lhs.timestamp >= rhs.timestamp ? lhs : rhs
1310 }
1311 return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs
1312 }
1313
1314 private static func historyQualityScore(_ entry: HistoryEntry) -> Int {
1315 var score = 0
1316 score += entry.dnsSections.count
1317 score += entry.httpHeaders.count
1318 score += entry.reachabilityResults.count
1319 score += entry.redirectChain.count
1320 score += entry.subdomains.count
1321 score += entry.portScanResults.count
1322 score += entry.validationIssues.isEmpty ? 2 : 0
1323 score += entry.sslInfo == nil ? 0 : 4
1324 score += entry.ownership == nil ? 0 : 4
1325 score += entry.emailSecurity == nil ? 0 : 3
1326 score += entry.ipGeolocation == nil ? 0 : 2
1327 return score
1328 }
1329
1330 private static func historyKey(for entry: HistoryEntry) -> String {
1331 if entry.id != UUID() {
1332 return "id:\(entry.id.uuidString)"
1333 }
1334 return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)"
1335 }
1336
1337 private nonisolated static func normalizeDomain(_ value: String) -> String {
1338 value
1339 .trimmingCharacters(in: .whitespacesAndNewlines)
1340 .replacingOccurrences(of: "https://", with: "")
1341 .replacingOccurrences(of: "http://", with: "")
1342 .components(separatedBy: "/")
1343 .first?
1344 .lowercased() ?? ""
1345 }
1346
1347 private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1348 TrackedDomain(
1349 id: trackedDomain.id,
1350 domain: normalizeDomain(trackedDomain.domain),
1351 createdAt: trackedDomain.createdAt,
1352 updatedAt: trackedDomain.updatedAt,
1353 note: trackedDomain.note,
1354 isPinned: trackedDomain.isPinned,
1355 monitoringEnabled: trackedDomain.monitoringEnabled,
1356 lastKnownAvailability: trackedDomain.lastKnownAvailability,
1357 lastSnapshotID: trackedDomain.lastSnapshotID,
1358 lastChangeSummary: trackedDomain.lastChangeSummary,
1359 lastChangeSeverity: trackedDomain.lastChangeSeverity,
1360 certificateWarningLevel: trackedDomain.certificateWarningLevel,
1361 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1362 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1363 lastAlertAt: trackedDomain.lastAlertAt,
1364 monitoringState: trackedDomain.monitoringState,
1365 pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts
1366 )
1367 }
1368
1369 private static func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState {
1370 let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1371 let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1372 return lhsDate >= rhsDate ? lhs : rhs
1373 }
1374
1375 private static func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
1376 var uniqueByHash: [String: MonitoringPendingAlert] = [:]
1377 for alert in alerts {
1378 if let existing = uniqueByHash[alert.changeHash] {
1379 uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
1380 } else {
1381 uniqueByHash[alert.changeHash] = alert
1382 }
1383 }
1384 return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
1385 }
1386
1387 private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1388 DomainWorkflow(
1389 id: workflow.id,
1390 name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1391 domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1392 createdAt: workflow.createdAt,
1393 updatedAt: workflow.updatedAt,
1394 notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1395 )
1396 }
1397
1398 private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
1399 mergeTrackedDomains(existing: [], incoming: trackedDomains).domains
1400 }
1401
1402 private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] {
1403 mergeHistoryEntries(existing: [], incoming: historyEntries)
1404 }
1405
1406 private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] {
1407 mergeWorkflows(existing: [], incoming: workflows)
1408 }
1409
1410 private nonisolated static func deduplicated(_ values: [String]) -> [String] {
1411 var seen = Set<String>()
1412 return values.filter { seen.insert($0).inserted }
1413 }
1414
1415 private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] {
1416 var seen = Set<UUID>()
1417 return values.filter { seen.insert($0).inserted }
1418 }
1419
1420 private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool {
1421 if lhs.updatedAt != rhs.updatedAt {
1422 return lhs.updatedAt > rhs.updatedAt
1423 }
1424 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1425 }
1426
1427 private nonisolated static func higherCertificateWarningLevel(
1428 _ lhs: CertificateWarningLevel,
1429 _ rhs: CertificateWarningLevel
1430 ) -> CertificateWarningLevel {
1431 let rank: [CertificateWarningLevel: Int] = [
1432 .none: 0,
1433 .warning: 1,
1434 .critical: 2
1435 ]
1436 return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1437 }
1438
1439 private static func makeEncoder() -> JSONEncoder {
1440 let encoder = JSONEncoder()
1441 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1442 return encoder
1443 }
1444}
1445
1446private struct MergedTrackedDomains {
1447 let domains: [TrackedDomain]
1448 let idMap: [UUID: UUID]
1449}
1450
1451private enum ImportPayload {
1452 case backup(DomainDigBackup, DataValidationReport)
1453 case trackedDomains([TrackedDomain], DataValidationReport)
1454 case workflows([DomainWorkflow], DataValidationReport)
1455
1456 var kind: DataPortabilityImportKind {
1457 switch self {
1458 case .backup:
1459 return .backup
1460 case .trackedDomains:
1461 return .trackedDomains
1462 case .workflows:
1463 return .workflows
1464 }
1465 }
1466
1467 var validationReport: DataValidationReport {
1468 switch self {
1469 case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report):
1470 return report
1471 }
1472 }
1473
1474 func summaryLines(mode: DataPortabilityImportMode) -> [String] {
1475 switch self {
1476 case .backup(let backup, _):
1477 return [
1478 "Full backup import",
1479 "\(backup.trackedDomains.count) tracked domains",
1480 "\(backup.historyEntries.count) history snapshots",
1481 "\(backup.auditSessions?.count ?? 0) audit sessions",
1482 "\(backup.workflows.count) workflows",
1483 "\(backup.monitoringLogs.count) monitoring logs",
1484 mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
1485 ]
1486 case .trackedDomains(let trackedDomains, _):
1487 return [
1488 "Tracked domains import",
1489 "\(trackedDomains.count) tracked domains",
1490 mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain."
1491 ]
1492 case .workflows(let workflows, _):
1493 return [
1494 "Workflow import",
1495 "\(workflows.count) workflows",
1496 mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains."
1497 ]
1498 }
1499 }
1500}
1501
1502private extension String {
1503 var nilIfEmpty: String? {
1504 isEmpty ? nil : self
1505 }
1506}