diff options
Diffstat (limited to 'DomainDataPortabilityService.swift')
| -rw-r--r-- | DomainDataPortabilityService.swift | 1397 |
1 files changed, 1397 insertions, 0 deletions
diff --git a/DomainDataPortabilityService.swift b/DomainDataPortabilityService.swift new file mode 100644 index 0000000..fabdfdd --- /dev/null +++ b/DomainDataPortabilityService.swift @@ -0,0 +1,1397 @@ +import Foundation +import UniformTypeIdentifiers + +struct DomainDigBackup: Codable { + static let currentSchemaVersion = 1 + + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let trackedDomains: [TrackedDomain] + let historyEntries: [HistoryEntry] + let workflows: [DomainWorkflow] + let monitoringSettings: MonitoringSettings? + let monitoringLogs: [MonitoringLog] + let appSettings: AppSettingsSnapshot + let featureMetadata: FeatureMetadataSnapshot? +} + +struct AppSettingsSnapshot: Codable { + let recentSearches: [String] + let savedDomains: [String] + let resolverURLString: String + let appDensityRawValue: String +} + +struct FeatureMetadataSnapshot: Codable { + let cachedEntitlement: PurchaseService.CachedEntitlement? + let usageCredits: UsageCreditsSnapshot? +} + +struct UsageCreditsSnapshot: Codable { + let appVersion: String + let remainingByFeature: [UsageCreditFeature: Int] +} + +struct TrackedDomainsExport: Codable { + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let trackedDomains: [TrackedDomain] +} + +struct WorkflowsExport: Codable { + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let workflows: [DomainWorkflow] +} + +struct HistoryExport: Codable { + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let historyEntries: [HistoryEntry] +} + +enum DataPortabilityImportMode: String, CaseIterable, Identifiable { + case merge + case replace + + var id: String { rawValue } + + var title: String { + switch self { + case .merge: + return "Merge" + case .replace: + return "Replace" + } + } + + var explanation: String { + switch self { + case .merge: + return "Keep local data and merge imported items." + case .replace: + return "Replace local data with the imported file." + } + } +} + +enum DataPortabilityImportKind: String { + case backup + case trackedDomains + case workflows +} + +struct DataLifecycleSummary: Equatable { + let trackedDomains: Int + let historySnapshots: Int + let workflows: Int + let cachedItems: Int + let monitoringLogs: Int +} + +struct DataValidationMessage: Identifiable, Equatable { + let id = UUID() + let text: String + let isError: Bool +} + +struct DataValidationReport { + let messages: [DataValidationMessage] + + var warnings: [String] { + messages.filter { !$0.isError }.map(\.text) + } + + var errors: [String] { + messages.filter(\.isError).map(\.text) + } +} + +struct DataImportPreview { + let fileName: String + let kind: DataPortabilityImportKind + let mode: DataPortabilityImportMode + let summaryLines: [String] + let warnings: [String] + let currentCounts: DataLifecycleSummary + let projectedCounts: DataLifecycleSummary + fileprivate let payload: ImportPayload +} + +struct DataImportResult { + let kind: DataPortabilityImportKind + let mode: DataPortabilityImportMode + let summary: String + let warnings: [String] +} + +enum DataPortabilityError: LocalizedError { + case unsupportedFormat + case unsupportedSchema(Int) + case invalidCSV(String) + case unreadableFile + case validationFailed([String]) + + var errorDescription: String? { + switch self { + case .unsupportedFormat: + return "The selected file is not a supported DomainDig import." + case .unsupportedSchema(let version): + return "Backup schema v\(version) is not supported by this version of DomainDig." + case .invalidCSV(let message): + return message + case .unreadableFile: + return "The selected file could not be read." + case .validationFailed(let messages): + return messages.joined(separator: "\n") + } + } +} + +enum DataPortabilityCSV { + static func trackedDomains(_ domains: [TrackedDomain]) -> String { + let header = [ + "id", + "domain", + "createdAt", + "updatedAt", + "note", + "isPinned", + "monitoringEnabled", + "lastKnownAvailability", + "certificateWarningLevel", + "certificateDaysRemaining", + "lastMonitoredAt", + "lastAlertAt" + ] + + let rows = domains.map { domain in + [ + domain.id.uuidString, + domain.domain, + DomainDataPortabilityService.formatISODate(domain.createdAt), + DomainDataPortabilityService.formatISODate(domain.updatedAt), + domain.note ?? "", + String(domain.isPinned), + String(domain.monitoringEnabled), + domain.lastKnownAvailability?.rawValue ?? "", + domain.certificateWarningLevel.rawValue, + domain.certificateDaysRemaining.map(String.init) ?? "", + domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "", + domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? "" + ] + } + + return ([header] + rows).map(Self.csvLine).joined(separator: "\n") + } + + static func workflows(_ workflows: [DomainWorkflow]) -> String { + let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"] + let rows = workflows.map { workflow in + [ + workflow.id.uuidString, + workflow.name, + workflow.domains.joined(separator: "|"), + DomainDataPortabilityService.formatISODate(workflow.createdAt), + DomainDataPortabilityService.formatISODate(workflow.updatedAt), + workflow.notes ?? "" + ] + } + + return ([header] + rows).map(Self.csvLine).joined(separator: "\n") + } + + static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] { + let rows = try parseRows(from: string) + guard let header = rows.first else { return [] } + let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) } + + return mappedRows.compactMap { row in + let domain = Self.normalizedDomain(row["domain"]) + guard !domain.isEmpty else { return nil } + + let createdAt = parseDate(row["createdAt"]) ?? Date() + let updatedAt = parseDate(row["updatedAt"]) ?? createdAt + let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:)) + let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none + + return TrackedDomain( + id: UUID(uuidString: row["id"] ?? "") ?? UUID(), + domain: domain, + createdAt: createdAt, + updatedAt: updatedAt, + note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + isPinned: Self.parseBool(row["isPinned"]), + monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true, + lastKnownAvailability: availability, + certificateWarningLevel: certificateLevel, + certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""), + lastMonitoredAt: parseDate(row["lastMonitoredAt"]), + lastAlertAt: parseDate(row["lastAlertAt"]) + ) + } + } + + static func parseWorkflows(from string: String) throws -> [DomainWorkflow] { + let rows = try parseRows(from: string) + guard let header = rows.first else { return [] } + let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) } + + return mappedRows.compactMap { row in + let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let domains = (row["domains"] ?? "") + .split(separator: "|") + .map { Self.normalizedDomain(String($0)) } + .filter { !$0.isEmpty } + + guard !name.isEmpty, !domains.isEmpty else { return nil } + + let createdAt = parseDate(row["createdAt"]) ?? Date() + let updatedAt = parseDate(row["updatedAt"]) ?? createdAt + return DomainWorkflow( + id: UUID(uuidString: row["id"] ?? "") ?? UUID(), + name: name, + domains: Self.deduplicated(domains), + createdAt: createdAt, + updatedAt: updatedAt, + notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + } + + private nonisolated static func parseRows(from string: String) throws -> [[String]] { + var rows: [[String]] = [] + var currentRow: [String] = [] + var currentField = "" + var insideQuotes = false + + for character in string { + switch character { + case "\"": + insideQuotes.toggle() + case "," where !insideQuotes: + currentRow.append(currentField) + currentField = "" + case "\n" where !insideQuotes: + currentRow.append(currentField) + rows.append(currentRow) + currentRow = [] + currentField = "" + case "\r": + continue + default: + currentField.append(character) + } + } + + if insideQuotes { + throw DataPortabilityError.invalidCSV("The CSV file is malformed.") + } + + if !currentField.isEmpty || !currentRow.isEmpty { + currentRow.append(currentField) + rows.append(currentRow) + } + + return rows.filter { !$0.allSatisfy(\.isEmpty) } + } + + private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] { + Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) }) + } + + private nonisolated static func csvLine(_ values: [String]) -> String { + values.map { value in + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + .joined(separator: ",") + } + + private nonisolated static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return DomainDataPortabilityService.parseISODate(value) + } + + private nonisolated static func parseBool(_ value: String?) -> Bool { + guard let value else { return false } + return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true" + } + + private nonisolated static func normalizedDomain(_ value: String?) -> String { + value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } + + private nonisolated static func deduplicated(_ values: [String]) -> [String] { + var seen = Set<String>() + return values.filter { seen.insert($0).inserted } + } +} + +enum DataMigrationService { + private static let migrationMarkerKey = "data.migrations.v3_4_0" + + static func migrateIfNeeded(defaults: UserDefaults = .standard) { + guard !defaults.bool(forKey: migrationMarkerKey) else { return } + + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults) + DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults) + + let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults) + DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults) + + let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults) + DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults) + + let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults) + let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) + DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults) + + let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults) + DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults) + + defaults.set(true, forKey: migrationMarkerKey) + } +} + +enum DataValidationService { + static func validate(backup: DomainDigBackup) -> DataValidationReport { + var messages: [DataValidationMessage] = [] + + if backup.schemaVersion > DomainDigBackup.currentSchemaVersion { + messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true)) + } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion { + messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false)) + } + + messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains)) + messages.append(contentsOf: validateHistoryEntries(backup.historyEntries)) + messages.append(contentsOf: validateWorkflows(backup.workflows)) + + if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false)) + } + + return DataValidationReport(messages: messages) + } + + static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport { + DataValidationReport(messages: validateTrackedDomains(trackedDomains)) + } + + static func validate(workflows: [DomainWorkflow]) -> DataValidationReport { + DataValidationReport(messages: validateWorkflows(workflows)) + } + + private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + var seen = Set<String>() + + for trackedDomain in trackedDomains { + let normalized = normalizeDomain(trackedDomain.domain) + if normalized.isEmpty { + messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true)) + continue + } + + if !seen.insert(normalized).inserted { + messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false)) + } + } + + return messages + } + + private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + for entry in historyEntries { + if normalizeDomain(entry.domain).isEmpty { + messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true)) + } + if entry.timestamp == .distantPast { + messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false)) + } + for issue in entry.validationIssues { + messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false)) + } + } + return messages + } + + private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + for workflow in workflows { + if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(.init(text: "A workflow is missing its name.", isError: true)) + } + if workflow.domains.isEmpty { + messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false)) + } + if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count { + messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false)) + } + } + return messages + } + + private nonisolated static func normalizeDomain(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } +} + +enum DomainDataPortabilityService { + static let backupUTType = UTType.json + static let csvUTType = UTType.commaSeparatedText + + fileprivate nonisolated static func formatISODate(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + fileprivate nonisolated static func parseISODate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) + } + + private enum StorageKey { + static let recentSearches = "recentSearches" + static let savedDomains = "savedDomains" + static let trackedDomains = "trackedDomains" + static let legacyWatchedDomains = "watchedDomains" + static let history = "lookupHistory" + static let workflows = "domainWorkflows" + static let monitoringSettings = "monitoring.settings" + static let monitoringLogs = "monitoring.logs" + static let appDensity = AppDensity.userDefaultsKey + static let resolverURL = DNSResolverOption.userDefaultsKey + static let purchaseCache = "purchase.cachedEntitlement" + static let usageCredits = "usageCredits.ledger" + } + + static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: StorageKey.recentSearches) ?? [] + } + + static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) { + defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches) + } + + static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: StorageKey.savedDomains) ?? [] + } + + static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) { + defaults.set(values, forKey: StorageKey.savedDomains) + } + + static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] { + let decoder = JSONDecoder() + + if let data = defaults.data(forKey: StorageKey.trackedDomains), + let domains = try? decoder.decode([TrackedDomain].self, from: data) { + return deduplicatedTrackedDomains(domains) + } + + if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains), + let legacy = try? decoder.decode([WatchedDomain].self, from: data) { + return deduplicatedTrackedDomains( + legacy.map { + TrackedDomain( + id: $0.id, + domain: normalizeDomain($0.domain), + createdAt: $0.createdAt, + updatedAt: $0.createdAt, + lastKnownAvailability: $0.lastKnownAvailability + ) + } + ) + } + + return [] + } + + static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) { + defaults.set(data, forKey: StorageKey.trackedDomains) + } + defaults.removeObject(forKey: StorageKey.legacyWatchedDomains) + } + + static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] { + guard let data = defaults.data(forKey: StorageKey.history) else { return [] } + + if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { + return deduplicatedHistoryEntries(entries) + } + + guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + return [] + } + + let decoder = JSONDecoder() + let entries = rawArray.compactMap { item -> HistoryEntry? in + guard JSONSerialization.isValidJSONObject(item), + let entryData = try? JSONSerialization.data(withJSONObject: item) else { + return nil + } + return try? decoder.decode(HistoryEntry.self, from: entryData) + } + return deduplicatedHistoryEntries(entries) + } + + static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) { + defaults.set(data, forKey: StorageKey.history) + } + } + + static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] { + guard let data = defaults.data(forKey: StorageKey.workflows), + let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { + return [] + } + + return deduplicatedWorkflows(workflows).sorted(by: workflowSort) + } + + static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) { + defaults.set(data, forKey: StorageKey.workflows) + } + } + + static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings { + guard let data = defaults.data(forKey: StorageKey.monitoringSettings), + let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else { + return MonitoringSettings() + } + return settings + } + + static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(settings) { + defaults.set(data, forKey: StorageKey.monitoringSettings) + } + } + + static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] { + guard let data = defaults.data(forKey: StorageKey.monitoringLogs), + let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else { + return [] + } + return Array(logs.prefix(MonitoringStorage.maxLogs)) + } + + static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) { + defaults.set(data, forKey: StorageKey.monitoringLogs) + } + } + + static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot { + AppSettingsSnapshot( + recentSearches: loadRecentSearches(defaults: defaults), + savedDomains: loadSavedDomains(defaults: defaults), + resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString, + appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue + ) + } + + static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) { + saveRecentSearches(settings.recentSearches, defaults: defaults) + saveSavedDomains(settings.savedDomains, defaults: defaults) + defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL) + defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity) + } + + static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot { + let cachedEntitlement: PurchaseService.CachedEntitlement? + if let data = defaults.data(forKey: StorageKey.purchaseCache) { + cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data) + } else { + cachedEntitlement = nil + } + + let usageCredits: UsageCreditsSnapshot? + if let data = defaults.data(forKey: StorageKey.usageCredits) { + usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data) + } else { + usageCredits = nil + } + + return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits) + } + + static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) { + if let cachedEntitlement = featureMetadata?.cachedEntitlement, + let data = try? JSONEncoder().encode(cachedEntitlement) { + defaults.set(data, forKey: StorageKey.purchaseCache) + } + + if let usageCredits = featureMetadata?.usageCredits, + let data = try? JSONEncoder().encode(usageCredits) { + defaults.set(data, forKey: StorageKey.usageCredits) + } + } + + static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup { + DataMigrationService.migrateIfNeeded(defaults: defaults) + return DomainDigBackup( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + trackedDomains: loadTrackedDomains(defaults: defaults), + historyEntries: loadHistoryEntries(defaults: defaults), + workflows: loadWorkflows(defaults: defaults), + monitoringSettings: loadMonitoringSettings(defaults: defaults), + monitoringLogs: loadMonitoringLogs(defaults: defaults), + appSettings: loadAppSettings(defaults: defaults), + featureMetadata: loadFeatureMetadata(defaults: defaults) + ) + } + + static func backupData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode(currentBackup(defaults: defaults)) + } + + static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode( + TrackedDomainsExport( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + trackedDomains: loadTrackedDomains(defaults: defaults) + ) + ) + } + + static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode( + WorkflowsExport( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + workflows: loadWorkflows(defaults: defaults) + ) + ) + } + + static func historyExportData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode( + HistoryExport( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + historyEntries: loadHistoryEntries(defaults: defaults) + ) + ) + } + + static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String { + DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults)) + } + + static func workflowsCSV(defaults: UserDefaults = .standard) -> String { + DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults)) + } + + static func prepareImport( + data: Data, + fileName: String, + mode: DataPortabilityImportMode, + defaults: UserDefaults = .standard + ) throws -> DataImportPreview { + DataMigrationService.migrateIfNeeded(defaults: defaults) + let currentCounts = lifecycleSummary(defaults: defaults) + + let payload = try decodeImportPayload(from: data, fileName: fileName) + let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults) + + return DataImportPreview( + fileName: fileName, + kind: payload.kind, + mode: mode, + summaryLines: payload.summaryLines(mode: mode), + warnings: payload.validationReport.warnings, + currentCounts: currentCounts, + projectedCounts: projectedCounts, + payload: payload + ) + } + + static func applyImport( + _ preview: DataImportPreview, + mode: DataPortabilityImportMode, + defaults: UserDefaults = .standard + ) throws -> DataImportResult { + switch preview.payload { + case .backup(let backup, let report): + try applyBackup(backup, mode: mode, defaults: defaults) + return DataImportResult( + kind: .backup, + mode: mode, + summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, and \(backup.workflows.count) workflows.", + warnings: report.warnings + ) + case .trackedDomains(let trackedDomains, let report): + applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults) + return DataImportResult( + kind: .trackedDomains, + mode: mode, + summary: "Imported \(trackedDomains.count) tracked domains.", + warnings: report.warnings + ) + case .workflows(let workflows, let report): + applyWorkflowsImport(workflows, mode: mode, defaults: defaults) + return DataImportResult( + kind: .workflows, + mode: mode, + summary: "Imported \(workflows.count) workflows.", + warnings: report.warnings + ) + } + } + + static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport { + switch try decodeImportPayload(from: data, fileName: fileName) { + case .backup(_, let report): + return report + case .trackedDomains(_, let report): + return report + case .workflows(_, let report): + return report + } + } + + static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary { + let appSettings = loadAppSettings(defaults: defaults) + let featureMetadata = loadFeatureMetadata(defaults: defaults) + let cachedItems = appSettings.recentSearches.count + + appSettings.savedDomains.count + + (featureMetadata.cachedEntitlement == nil ? 0 : 1) + + (featureMetadata.usageCredits == nil ? 0 : 1) + + return DataLifecycleSummary( + trackedDomains: loadTrackedDomains(defaults: defaults).count, + historySnapshots: loadHistoryEntries(defaults: defaults).count, + workflows: loadWorkflows(defaults: defaults).count, + cachedItems: cachedItems, + monitoringLogs: loadMonitoringLogs(defaults: defaults).count + ) + } + + private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload { + let decoder = JSONDecoder() + + if let backup = try? decoder.decode(DomainDigBackup.self, from: data) { + if backup.schemaVersion > DomainDigBackup.currentSchemaVersion { + throw DataPortabilityError.unsupportedSchema(backup.schemaVersion) + } + let report = DataValidationService.validate(backup: backup) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .backup(backup, report) + } + + if let backup = try? decodeLegacyBackup(from: data) { + let report = DataValidationService.validate(backup: backup) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .backup(backup, report) + } + + if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) { + let report = DataValidationService.validate(trackedDomains: export.trackedDomains) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .trackedDomains(export.trackedDomains, report) + } + + if let export = try? decoder.decode(WorkflowsExport.self, from: data) { + let report = DataValidationService.validate(workflows: export.workflows) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .workflows(export.workflows, report) + } + + if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) { + let report = DataValidationService.validate(trackedDomains: trackedDomains) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .trackedDomains(trackedDomains, report) + } + + if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) { + let report = DataValidationService.validate(workflows: workflows) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .workflows(workflows, report) + } + + let lowercasedFileName = fileName.lowercased() + if lowercasedFileName.hasSuffix(".csv") { + guard let string = String(data: data, encoding: .utf8) else { + throw DataPortabilityError.unreadableFile + } + if lowercasedFileName.contains("workflow") { + let workflows = try DataPortabilityCSV.parseWorkflows(from: string) + let report = DataValidationService.validate(workflows: workflows) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .workflows(workflows, report) + } + + let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string) + let report = DataValidationService.validate(trackedDomains: trackedDomains) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .trackedDomains(trackedDomains, report) + } + + throw DataPortabilityError.unsupportedFormat + } + + private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup { + struct LegacyBackup: Decodable { + let trackedDomains: [TrackedDomain]? + let historyEntries: [HistoryEntry]? + let workflows: [DomainWorkflow]? + let monitoringSettings: MonitoringSettings? + let monitoringLogs: [MonitoringLog]? + let recentSearches: [String]? + let savedDomains: [String]? + } + + let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data) + return DomainDigBackup( + schemaVersion: 0, + exportedAt: Date(), + appVersion: AppVersion.current, + trackedDomains: legacy.trackedDomains ?? [], + historyEntries: legacy.historyEntries ?? [], + workflows: legacy.workflows ?? [], + monitoringSettings: legacy.monitoringSettings, + monitoringLogs: legacy.monitoringLogs ?? [], + appSettings: AppSettingsSnapshot( + recentSearches: legacy.recentSearches ?? [], + savedDomains: legacy.savedDomains ?? [], + resolverURLString: DNSResolverOption.defaultURLString, + appDensityRawValue: AppDensity.compact.rawValue + ), + featureMetadata: nil + ) + } + + private static func applyBackup( + _ backup: DomainDigBackup, + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) throws { + if mode == .replace { + saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults) + saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults) + saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults) + saveMonitoringSettings( + MonitoringStorage.sanitizeSettings( + backup.monitoringSettings ?? MonitoringSettings(), + trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains) + ), + defaults: defaults + ) + saveMonitoringLogs(backup.monitoringLogs, defaults: defaults) + saveAppSettings(backup.appSettings, defaults: defaults) + saveFeatureMetadata(backup.featureMetadata, defaults: defaults) + return + } + + let existingTrackedDomains = loadTrackedDomains(defaults: defaults) + let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains) + saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults) + + let existingHistoryEntries = loadHistoryEntries(defaults: defaults) + let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) } + saveHistoryEntries( + mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory), + defaults: defaults + ) + + let existingWorkflows = loadWorkflows(defaults: defaults) + saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults) + + let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults) + saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults) + + let remappedMonitoringSettings = remapMonitoringSettings( + backup.monitoringSettings ?? MonitoringSettings(), + using: mergedTrackedDomains.idMap + ) + saveMonitoringSettings( + MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains), + defaults: defaults + ) + + let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings) + saveAppSettings(mergedSettings, defaults: defaults) + + let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata) + saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults) + } + + private static func applyTrackedDomainsImport( + _ trackedDomains: [TrackedDomain], + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) { + let importedDomains = deduplicatedTrackedDomains(trackedDomains) + let finalDomains: [TrackedDomain] + switch mode { + case .merge: + finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains + case .replace: + finalDomains = importedDomains + } + + saveTrackedDomains(finalDomains, defaults: defaults) + let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains) + saveMonitoringSettings(sanitizedSettings, defaults: defaults) + } + + private static func applyWorkflowsImport( + _ workflows: [DomainWorkflow], + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) { + let importedWorkflows = deduplicatedWorkflows(workflows) + let finalWorkflows: [DomainWorkflow] + switch mode { + case .merge: + finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows) + case .replace: + finalWorkflows = importedWorkflows + } + saveWorkflows(finalWorkflows, defaults: defaults) + } + + private static func projectCounts( + for payload: ImportPayload, + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) -> DataLifecycleSummary { + let current = lifecycleSummary(defaults: defaults) + switch payload { + case .backup(let backup, _): + if mode == .replace { + return DataLifecycleSummary( + trackedDomains: backup.trackedDomains.count, + historySnapshots: backup.historyEntries.count, + workflows: backup.workflows.count, + cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)), + monitoringLogs: backup.monitoringLogs.count + ) + } + + return DataLifecycleSummary( + trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count, + historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count, + workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count, + cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count), + monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count + ) + case .trackedDomains(let trackedDomains, _): + let total = mode == .replace + ? trackedDomains.count + : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count + return DataLifecycleSummary( + trackedDomains: total, + historySnapshots: current.historySnapshots, + workflows: current.workflows, + cachedItems: current.cachedItems, + monitoringLogs: current.monitoringLogs + ) + case .workflows(let workflows, _): + let total = mode == .replace + ? workflows.count + : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count + return DataLifecycleSummary( + trackedDomains: current.trackedDomains, + historySnapshots: current.historySnapshots, + workflows: total, + cachedItems: current.cachedItems, + monitoringLogs: current.monitoringLogs + ) + } + } + + private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains { + var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) }) + var idMap: [UUID: UUID] = [:] + + for trackedDomain in incoming { + let key = normalizeDomain(trackedDomain.domain) + guard !key.isEmpty else { continue } + + if let existingDomain = mergedByDomain[key] { + let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt + ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain) + : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain) + mergedByDomain[key] = chosen + idMap[trackedDomain.id] = chosen.id + } else { + let normalized = normalizedTrackedDomain(trackedDomain) + mergedByDomain[key] = normalized + idMap[trackedDomain.id] = normalized.id + } + } + + for trackedDomain in existing { + idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id + } + + return MergedTrackedDomains( + domains: mergedByDomain.values.sorted { + if $0.updatedAt != $1.updatedAt { + return $0.updatedAt > $1.updatedAt + } + return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending + }, + idMap: idMap + ) + } + + private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain { + let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain) + let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming + let note = winner.note ?? existing.note ?? incoming.note + + return TrackedDomain( + id: existing.id, + domain: normalizedDomain, + createdAt: min(existing.createdAt, incoming.createdAt), + updatedAt: max(existing.updatedAt, incoming.updatedAt), + note: note, + isPinned: existing.isPinned || incoming.isPinned, + monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled, + lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability, + lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID, + lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary, + lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity, + certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel), + certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining, + lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(), + lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max() + ) + } + + private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] { + var mergedByKey: [String: HistoryEntry] = [:] + + for entry in existing + incoming { + let key = historyKey(for: entry) + if let existingEntry = mergedByKey[key] { + mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry) + } else { + mergedByKey[key] = entry + } + } + + return mergedByKey.values.sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp > rhs.timestamp + } + return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending + } + } + + private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] { + var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) }) + + for workflow in incoming { + let normalized = normalizedWorkflow(workflow) + if let existingWorkflow = merged[workflow.id] { + let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized + merged[workflow.id] = DomainWorkflow( + id: existingWorkflow.id, + name: winner.name, + domains: deduplicated(existingWorkflow.domains + normalized.domains), + createdAt: min(existingWorkflow.createdAt, normalized.createdAt), + updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt), + notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes + ) + } else { + merged[workflow.id] = normalized + } + } + + return merged.values.sorted(by: workflowSort) + } + + private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] { + var merged: [UUID: MonitoringLog] = [:] + for log in existing + incoming { + if let current = merged[log.id] { + merged[log.id] = log.timestamp >= current.timestamp ? log : current + } else { + merged[log.id] = log + } + } + return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 } + } + + private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot { + AppSettingsSnapshot( + recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 }, + savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains), + resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString, + appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue + ) + } + + private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot { + guard let incoming else { return existing } + + let mergedUsageCredits: UsageCreditsSnapshot? + switch (existing.usageCredits, incoming.usageCredits) { + case let (.some(lhs), .some(rhs)): + let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in + (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0)) + }) + mergedUsageCredits = UsageCreditsSnapshot( + appVersion: rhs.appVersion, + remainingByFeature: mergedRemaining + ) + case (.some(let lhs), nil): + mergedUsageCredits = lhs + case (nil, .some(let rhs)): + mergedUsageCredits = rhs + case (nil, nil): + mergedUsageCredits = nil + } + + return FeatureMetadataSnapshot( + cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement, + usageCredits: mergedUsageCredits + ) + } + + private static func remapMonitoringSettings( + _ settings: MonitoringSettings, + using idMap: [UUID: UUID] + ) -> MonitoringSettings { + var remapped = settings + remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 }) + return remapped + } + + private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry { + var remapped = entry + if let trackedDomainID = entry.trackedDomainID { + remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID + } + return remapped + } + + private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry { + if historyQualityScore(lhs) == historyQualityScore(rhs) { + return lhs.timestamp >= rhs.timestamp ? lhs : rhs + } + return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs + } + + private static func historyQualityScore(_ entry: HistoryEntry) -> Int { + var score = 0 + score += entry.dnsSections.count + score += entry.httpHeaders.count + score += entry.reachabilityResults.count + score += entry.redirectChain.count + score += entry.subdomains.count + score += entry.portScanResults.count + score += entry.validationIssues.isEmpty ? 2 : 0 + score += entry.sslInfo == nil ? 0 : 4 + score += entry.ownership == nil ? 0 : 4 + score += entry.emailSecurity == nil ? 0 : 3 + score += entry.ipGeolocation == nil ? 0 : 2 + return score + } + + private static func historyKey(for entry: HistoryEntry) -> String { + if entry.id != UUID() { + return "id:\(entry.id.uuidString)" + } + return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)" + } + + private nonisolated static func normalizeDomain(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } + + private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain { + TrackedDomain( + id: trackedDomain.id, + domain: normalizeDomain(trackedDomain.domain), + createdAt: trackedDomain.createdAt, + updatedAt: trackedDomain.updatedAt, + note: trackedDomain.note, + isPinned: trackedDomain.isPinned, + monitoringEnabled: trackedDomain.monitoringEnabled, + lastKnownAvailability: trackedDomain.lastKnownAvailability, + lastSnapshotID: trackedDomain.lastSnapshotID, + lastChangeSummary: trackedDomain.lastChangeSummary, + lastChangeSeverity: trackedDomain.lastChangeSeverity, + certificateWarningLevel: trackedDomain.certificateWarningLevel, + certificateDaysRemaining: trackedDomain.certificateDaysRemaining, + lastMonitoredAt: trackedDomain.lastMonitoredAt, + lastAlertAt: trackedDomain.lastAlertAt + ) + } + + private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow { + DomainWorkflow( + id: workflow.id, + name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines), + domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }), + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + + private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] { + mergeTrackedDomains(existing: [], incoming: trackedDomains).domains + } + + private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] { + mergeHistoryEntries(existing: [], incoming: historyEntries) + } + + private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] { + mergeWorkflows(existing: [], incoming: workflows) + } + + private nonisolated static func deduplicated(_ values: [String]) -> [String] { + var seen = Set<String>() + return values.filter { seen.insert($0).inserted } + } + + private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] { + var seen = Set<UUID>() + return values.filter { seen.insert($0).inserted } + } + + private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool { + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + + private nonisolated static func higherCertificateWarningLevel( + _ lhs: CertificateWarningLevel, + _ rhs: CertificateWarningLevel + ) -> CertificateWarningLevel { + let rank: [CertificateWarningLevel: Int] = [ + .none: 0, + .warning: 1, + .critical: 2 + ] + return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} + +private struct MergedTrackedDomains { + let domains: [TrackedDomain] + let idMap: [UUID: UUID] +} + +private enum ImportPayload { + case backup(DomainDigBackup, DataValidationReport) + case trackedDomains([TrackedDomain], DataValidationReport) + case workflows([DomainWorkflow], DataValidationReport) + + var kind: DataPortabilityImportKind { + switch self { + case .backup: + return .backup + case .trackedDomains: + return .trackedDomains + case .workflows: + return .workflows + } + } + + var validationReport: DataValidationReport { + switch self { + case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report): + return report + } + } + + func summaryLines(mode: DataPortabilityImportMode) -> [String] { + switch self { + case .backup(let backup, _): + return [ + "Full backup import", + "\(backup.trackedDomains.count) tracked domains", + "\(backup.historyEntries.count) history snapshots", + "\(backup.workflows.count) workflows", + "\(backup.monitoringLogs.count) monitoring logs", + mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates." + ] + case .trackedDomains(let trackedDomains, _): + return [ + "Tracked domains import", + "\(trackedDomains.count) tracked domains", + mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain." + ] + case .workflows(let workflows, _): + return [ + "Workflow import", + "\(workflows.count) workflows", + mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains." + ] + } + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} |
