summaryrefslogtreecommitdiff
path: root/DomainDataPortabilityService.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDataPortabilityService.swift')
-rw-r--r--DomainDataPortabilityService.swift1397
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
+ }
+}