From 7b8f3b1c2d0146f77208aa07f3d80dd2846e58f0 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 13:56:43 -0400 Subject: [PATCH 1/7] Add whole-store readers, clearAll, and DayPresence Codable Backup groundwork: extend WhereStore with allEvidence()/allManualDays() (predicate-less reads) and clearAll() (full wipe for the replace import strategy), implement them in SwiftDataStore, make DayPresence Codable so it can ride in the backup manifest, and keep the ToggleFailingStore test double conforming. Closes plan step: store-readers. Co-authored-by: Cursor --- Where/WhereCore/Sources/DayPresence.swift | 2 +- .../Sources/Persistence/SwiftDataStore.swift | 35 +++++++++++++++++++ .../Sources/Persistence/WhereStore.swift | 10 ++++++ .../Tests/WhereControllerTests.swift | 12 +++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Where/WhereCore/Sources/DayPresence.swift b/Where/WhereCore/Sources/DayPresence.swift index a8ccbfc..205cd80 100644 --- a/Where/WhereCore/Sources/DayPresence.swift +++ b/Where/WhereCore/Sources/DayPresence.swift @@ -4,7 +4,7 @@ import Foundation /// /// `regions` is a `Set` because we want union semantics (a day in CA + NY /// counts for both). -public struct DayPresence: Hashable, Sendable { +public struct DayPresence: Hashable, Sendable, Codable { /// Start-of-day in whichever calendar/timezone produced this value. public let date: Date public let regions: Set diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 0418e28..a2b4da4 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -257,6 +257,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + public func allEvidence() async throws -> [Evidence] { + let context = readContext() + var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.capturedAt)]) + descriptor.includePendingChanges = true + return try context.fetch(descriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + } + public func evidenceBlob(for id: UUID) async throws -> Data? { let context = readContext() let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) @@ -315,6 +326,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + public func allManualDays() async throws -> [DayPresence] { + let context = readContext() + var descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.dateKey)]) + descriptor.includePendingChanges = true + return try context.fetch(descriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + } + public func clear(in interval: DateInterval) async throws { let context = mutationContext() let start = interval.start @@ -357,6 +379,19 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + public func clearAll() async throws { + let context = mutationContext() + for sample in try context.fetch(FetchDescriptor()) { + context.delete(sample) + } + for evidence in try context.fetch(FetchDescriptor()) { + context.delete(evidence) + } + for manual in try context.fetch(FetchDescriptor()) { + context.delete(manual) + } + } + private static func logFault(forCorrupt _: Record) { logger.fault( "Dropped corrupt SwiftData record of type \(String(describing: Record.self), privacy: .public)", diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 4d90fb1..3c6b51d 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -32,6 +32,9 @@ public protocol WhereStore: Sendable { func write(evidence: Evidence, blob: Data?) async throws func evidence(in interval: DateInterval) async throws -> [Evidence] + /// Every evidence record in the store, regardless of `capturedAt`. Used + /// by the whole-database backup export. + func allEvidence() async throws -> [Evidence] func evidenceBlob(for id: UUID) async throws -> Data? /// Set (or replace) the manual presence record for a given calendar day. @@ -39,8 +42,15 @@ public protocol WhereStore: Sendable { /// start-of-day key (callers via `WhereController` do this for them). func setManualDay(_ day: DayPresence) async throws func manualDays(in interval: DateInterval) async throws -> [DayPresence] + /// Every manual-day record in the store, regardless of `dateKey`. Used + /// by the whole-database backup export. + func allManualDays() async throws -> [DayPresence] /// Erase all samples / evidence / manual entries whose timestamp lies in /// the given interval. Used by `WhereController.clearYear`. func clear(in interval: DateInterval) async throws + + /// Erase every sample / evidence / manual entry in the store. Used by the + /// "replace" backup-import strategy to mirror the imported file exactly. + func clearAll() async throws } diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index 72dac76..fb2e04b 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -338,6 +338,10 @@ private actor ToggleFailingStore: WhereStore { try await backing.evidence(in: interval) } + func allEvidence() async throws -> [Evidence] { + try await backing.allEvidence() + } + func evidenceBlob(for id: UUID) async throws -> Data? { try await backing.evidenceBlob(for: id) } @@ -350,7 +354,15 @@ private actor ToggleFailingStore: WhereStore { try await backing.manualDays(in: interval) } + func allManualDays() async throws -> [DayPresence] { + try await backing.allManualDays() + } + func clear(in interval: DateInterval) async throws { try await backing.clear(in: interval) } + + func clearAll() async throws { + try await backing.clearAll() + } } From e52284ac5f83d183d62c41fded725c6c2f31edb2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 13:58:54 -0400 Subject: [PATCH 2/7] Add ZIPFoundation dependency to WhereCore Backup export/import needs a real .zip reader/writer; Foundation has no public unzip API. Pin ZIPFoundation 0.9.20 (same external-SPM pattern as swift-snapshot-testing) and link it into the WhereCore target. Unused until the BackupService step; committed on its own as groundwork. Closes plan step: package-dep. Co-authored-by: Cursor --- Package.resolved | 11 ++++++++++- Package.swift | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index b56dbad..9e11eca 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "30b9f82075bc9036518a6dae80048048656ba3d6f12f33c2601928c4e8e4e821", + "originHash" : "a997fcdd820c308bb5302625c065dfd8abc2d0ebc0d50a9834df01f4dda68ace", "pins" : [ { "identity" : "swift-custom-dump", @@ -36,6 +36,15 @@ "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", "version" : "1.9.0" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 99280ad..c603479 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.19.2"), + .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), ], targets: [ .target( @@ -23,6 +24,9 @@ let package = Package( ), .target( name: "WhereCore", + dependencies: [ + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + ], path: "Where/WhereCore/Sources", resources: [ .process("Resources"), From 1e0f9172f182bc1a8bdc284149f6f7cc0a0382eb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 14:04:13 -0400 Subject: [PATCH 3/7] Add BackupArchive manifest and BackupService zip codec Define the versioned, Codable BackupArchive (samples + evidence + manual days + an asset index) and BackupService, which marshals a whole-database backup to a deflate-compressed .zip (manifest.json + assets/) and reads one back into value types + blob bytes. Covered by round-trip tests for the manifest JSON, the full zip, and a non-zip rejection. Closes plan step: backup-types. Co-authored-by: Cursor --- .../Sources/Backup/BackupArchive.swift | 57 ++++++ .../Sources/Backup/BackupService.swift | 169 ++++++++++++++++++ .../WhereCore/Tests/BackupServiceTests.swift | 127 +++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 Where/WhereCore/Sources/Backup/BackupArchive.swift create mode 100644 Where/WhereCore/Sources/Backup/BackupService.swift create mode 100644 Where/WhereCore/Tests/BackupServiceTests.swift diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift new file mode 100644 index 0000000..92f46bc --- /dev/null +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Versioned, `Codable` manifest describing a whole-database backup of the +/// Where feature. Serialized to `manifest.json` at the root of the backup +/// `.zip`; evidence blob bytes live alongside it under `assets/` and are +/// linked back to their records by `BackupAssetEntry`. +/// +/// The three arrays mirror the three SwiftData tables exactly +/// (`SDLocationSample` / `SDEvidence` / `SDManualDay`) via their value-type +/// representations, so an export captures everything and an import can +/// upsert it back row-for-row. +public struct BackupArchive: Codable, Sendable, Hashable { + /// Bumped whenever the archive's on-disk shape changes in a way older + /// readers can't understand, so an importer can refuse a file it doesn't + /// know how to read instead of silently dropping data. + public static let currentFormatVersion = 1 + + public let formatVersion: Int + public let exportedAt: Date + public let samples: [LocationSample] + public let evidence: [Evidence] + public let manualDays: [DayPresence] + /// One entry per evidence record that has blob bytes in the archive. + /// Evidence without bytes simply has no entry here. + public let assets: [BackupAssetEntry] + + public init( + formatVersion: Int = BackupArchive.currentFormatVersion, + exportedAt: Date, + samples: [LocationSample], + evidence: [Evidence], + manualDays: [DayPresence], + assets: [BackupAssetEntry], + ) { + self.formatVersion = formatVersion + self.exportedAt = exportedAt + self.samples = samples + self.evidence = evidence + self.manualDays = manualDays + self.assets = assets + } +} + +/// Links one `Evidence` record to the file holding its blob bytes inside the +/// backup archive. A named struct (rather than a bare dictionary entry or +/// tuple) so the mapping stays self-describing in the public Codable surface. +public struct BackupAssetEntry: Codable, Sendable, Hashable { + public let evidenceId: UUID + /// Path of the blob file relative to the archive root, e.g. + /// `assets/`. + public let filename: String + + public init(evidenceId: UUID, filename: String) { + self.evidenceId = evidenceId + self.filename = filename + } +} diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift new file mode 100644 index 0000000..dadfcbf --- /dev/null +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -0,0 +1,169 @@ +import Foundation +import os +import ZIPFoundation + +/// Serializes a whole-database backup to a `.zip` and reads one back. +/// +/// Archive layout: +/// ``` +/// manifest.json // BackupArchive: samples, evidence, manual days, asset index +/// assets/ // one file per evidence blob +/// ``` +/// +/// The service is pure file I/O over value types — it never touches +/// SwiftData. `WhereController` owns reading the store and committing an +/// import transaction; this type only marshals bytes to and from the zip. +public struct BackupService: Sendable { + /// Decoded contents of a backup archive: the manifest plus the evidence + /// blob bytes, keyed by evidence id so the importer can pair them with + /// the matching `Evidence` metadata. + public struct ReadResult: Sendable { + public let archive: BackupArchive + public let blobs: [UUID: Data] + + public init(archive: BackupArchive, blobs: [UUID: Data]) { + self.archive = archive + self.blobs = blobs + } + } + + /// Failures specific to reading a backup file. Transport / file-system + /// errors surface as the underlying `Error` instead. + public enum BackupError: Error, LocalizedError { + /// The zip opened but contained no `manifest.json` at its root — it + /// is almost certainly not a Where backup. + case manifestMissing + /// The manifest declares a `formatVersion` newer than this build can + /// read (the file was produced by a later app version). + case unsupportedFormatVersion(Int) + + public var errorDescription: String? { + switch self { + case .manifestMissing: + "This file isn't a Where backup (no manifest was found)." + case let .unsupportedFormatVersion(version): + "This backup was created by a newer version of Where (format \(version)) and can't be imported." + } + } + } + + private static let manifestFilename = "manifest.json" + private static let assetsDirectory = "assets" + private static let logger = Logger(subsystem: "com.stuff.where", category: "BackupService") + + public init() {} + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + // MARK: - Export + + /// Build a backup `.zip` from the supplied data and return a URL to the + /// file in the temporary directory. The caller owns the file and should + /// delete it (or its parent directory) once it has been shared. + /// + /// `blobs` holds the evidence bytes keyed by `Evidence.id`; evidence + /// without an entry is exported as metadata only. + public func makeArchiveFile( + samples: [LocationSample], + evidence: [Evidence], + manualDays: [DayPresence], + blobs: [UUID: Data], + exportedAt: Date = Date(), + archiveName: String? = nil, + ) throws -> URL { + let fileManager = FileManager.default + let workRoot = fileManager.temporaryDirectory + .appendingPathComponent("where-backup-\(UUID().uuidString)", isDirectory: true) + let staging = workRoot.appendingPathComponent("contents", isDirectory: true) + let assetsDir = staging.appendingPathComponent(Self.assetsDirectory, isDirectory: true) + try fileManager.createDirectory(at: assetsDir, withIntermediateDirectories: true) + + var assetEntries: [BackupAssetEntry] = [] + for item in evidence { + guard let blob = blobs[item.id] else { continue } + let filename = "\(Self.assetsDirectory)/\(item.id.uuidString)" + try blob.write(to: staging.appendingPathComponent(filename)) + assetEntries.append(BackupAssetEntry(evidenceId: item.id, filename: filename)) + } + + let archive = BackupArchive( + exportedAt: exportedAt, + samples: samples, + evidence: evidence, + manualDays: manualDays, + assets: assetEntries, + ) + let manifestData = try Self.makeEncoder().encode(archive) + try manifestData.write(to: staging.appendingPathComponent(Self.manifestFilename)) + + let name = archiveName ?? Self.defaultArchiveName(for: exportedAt) + let zipURL = workRoot.appendingPathComponent(name) + try fileManager.zipItem( + at: staging, + to: zipURL, + shouldKeepParent: false, + compressionMethod: .deflate, + ) + Self.logger.info( + "Wrote backup with \(samples.count) samples, \(evidence.count) evidence, \(manualDays.count) manual days", + ) + return zipURL + } + + /// A human-friendly, email-ready filename like `Where Backup 2026-06-05.zip`. + static func defaultArchiveName(for date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return "Where Backup \(formatter.string(from: date)).zip" + } + + // MARK: - Import + + /// Unzip and decode a backup file. The archive is extracted into a unique + /// temporary directory that is removed before returning; the decoded + /// manifest and blob bytes are held in memory in the result. + public func readArchive(at url: URL) throws -> ReadResult { + let fileManager = FileManager.default + let extractDir = fileManager.temporaryDirectory + .appendingPathComponent("where-import-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: extractDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: extractDir) } + + try fileManager.unzipItem(at: url, to: extractDir) + + let manifestURL = extractDir.appendingPathComponent(Self.manifestFilename) + guard fileManager.fileExists(atPath: manifestURL.path) else { + throw BackupError.manifestMissing + } + let manifestData = try Data(contentsOf: manifestURL) + let archive = try Self.makeDecoder().decode(BackupArchive.self, from: manifestData) + guard archive.formatVersion <= BackupArchive.currentFormatVersion else { + throw BackupError.unsupportedFormatVersion(archive.formatVersion) + } + + var blobs: [UUID: Data] = [:] + for entry in archive.assets { + let assetURL = extractDir.appendingPathComponent(entry.filename) + guard let data = try? Data(contentsOf: assetURL) else { + Self.logger.fault( + "Backup asset missing for evidence \(entry.evidenceId, privacy: .public); skipping blob", + ) + continue + } + blobs[entry.evidenceId] = data + } + return ReadResult(archive: archive, blobs: blobs) + } +} diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift new file mode 100644 index 0000000..337681d --- /dev/null +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +import WhereCore + +struct BackupServiceTests { + // Whole-second timestamps so the `.iso8601` date strategy (no + // fractional seconds) round-trips exactly. + private static let exportDate = Date(timeIntervalSince1970: 1_700_000_000) + private static let evidenceWithBlobId = + UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private static let evidenceNoBlobId = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + + private static func sampleFixtures() -> [LocationSample] { + [ + LocationSample( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + ), + LocationSample( + id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, + timestamp: Date(timeIntervalSince1970: 1_700_100_000), + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 10, + source: .evidenceImplied(id: evidenceWithBlobId, kind: .boardingPass), + ), + ] + } + + private static func evidenceFixtures() -> [Evidence] { + [ + Evidence( + id: evidenceWithBlobId, + kind: .boardingPass, + capturedAt: Date(timeIntervalSince1970: 1_700_050_000), + region: .california, + note: "SFO → JFK", + contentType: .pdf, + ), + Evidence( + id: evidenceNoBlobId, + kind: .other("ferry ticket"), + capturedAt: Date(timeIntervalSince1970: 1_700_060_000), + region: nil, + note: nil, + contentType: .other(nil), + ), + ] + } + + private static func manualDayFixtures() -> [DayPresence] { + [ + DayPresence( + date: Date(timeIntervalSince1970: 1_700_000_000), + regions: [.california, .newYork], + ), + ] + } + + @Test func archiveFileRoundTripsEveryTableAndBlobs() throws { + let service = BackupService() + let samples = Self.sampleFixtures() + let evidence = Self.evidenceFixtures() + let manualDays = Self.manualDayFixtures() + let blobs: [UUID: Data] = [Self.evidenceWithBlobId: Data("boarding-pass-pdf".utf8)] + + let url = try service.makeArchiveFile( + samples: samples, + evidence: evidence, + manualDays: manualDays, + blobs: blobs, + exportedAt: Self.exportDate, + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + // Email-friendly, date-stamped filename. + #expect(url.pathExtension == "zip") + #expect(url.lastPathComponent.hasPrefix("Where Backup ")) + + let result = try service.readArchive(at: url) + #expect(result.archive.formatVersion == BackupArchive.currentFormatVersion) + #expect(result.archive.exportedAt == Self.exportDate) + #expect(result.archive.samples == samples) + #expect(result.archive.evidence == evidence) + #expect(result.archive.manualDays == manualDays) + // Only the evidence with bytes gets an asset; the other is metadata-only. + #expect(result.archive.assets.map(\.evidenceId) == [Self.evidenceWithBlobId]) + #expect(result.blobs == blobs) + } + + @Test func manifestRoundTripsThroughJSON() throws { + let archive = BackupArchive( + exportedAt: Self.exportDate, + samples: Self.sampleFixtures(), + evidence: Self.evidenceFixtures(), + manualDays: Self.manualDayFixtures(), + assets: [BackupAssetEntry( + evidenceId: Self.evidenceWithBlobId, + filename: "assets/\(Self.evidenceWithBlobId.uuidString)", + )], + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(archive) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(BackupArchive.self, from: data) + + #expect(decoded == archive) + } + + @Test func readingAFileThatIsNotAZipThrows() throws { + let service = BackupService() + let bogus = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString).zip") + try Data("definitely not a zip".utf8).write(to: bogus) + defer { try? FileManager.default.removeItem(at: bogus) } + + #expect(throws: (any Error).self) { + _ = try service.readArchive(at: bogus) + } + } +} From fb241c15c722bed412a191c0ae91eaafe8ace69e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 14:07:05 -0400 Subject: [PATCH 4/7] Add exportBackup/importBackup to WhereController Expose the whole-database backup entry points: exportBackup() reads all three tables plus evidence blobs and hands them to BackupService; importBackup(from:strategy:) reads an archive (bracketing the security-scoped document-picker URL) and writes it back in one perform transaction, with .replace clearing the store first and .merge relying on upsert semantics. ImportSummary reports the row counts. Covered by merge/replace round-trip tests and a clearAll table-wipe test. Closes plan step: controller-api. Co-authored-by: Cursor --- Where/WhereCore/Sources/WhereController.swift | 87 ++++++++++++++ .../Tests/WhereControllerTests.swift | 106 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index e1034d5..9f6819c 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -19,6 +19,7 @@ public actor WhereController { private let locationSource: any LocationSource private let attributor: RegionAttributor private let aggregator: DayAggregator + private let backupService = BackupService() private var ingestTask: Task? @@ -131,6 +132,92 @@ public actor WhereController { try await store.perform { try await store.clear(in: interval) } } + // MARK: - Backup + + /// How an imported backup combines with whatever is already on the device. + public enum ImportStrategy: Sendable { + /// Upsert the imported rows into the existing data (by `id` for + /// samples/evidence, by day key for manual days), leaving anything + /// not present in the file untouched. + case merge + /// Erase the whole store first so the device ends up mirroring the + /// file exactly. + case replace + } + + /// Counts of what an import wrote, for a user-facing confirmation. + public struct ImportSummary: Sendable, Hashable { + public let sampleCount: Int + public let evidenceCount: Int + public let manualDayCount: Int + + public init(sampleCount: Int, evidenceCount: Int, manualDayCount: Int) { + self.sampleCount = sampleCount + self.evidenceCount = evidenceCount + self.manualDayCount = manualDayCount + } + } + + /// Serialize the entire store (all three tables plus evidence blobs) to a + /// `.zip` in the temporary directory and return its URL. The caller owns + /// the file: share it, then delete it (or its parent directory). + public func exportBackup() async throws -> URL { + let samples = try await store.allSamples() + let evidence = try await store.allEvidence() + let manualDays = try await store.allManualDays() + var blobs: [UUID: Data] = [:] + for item in evidence { + if let blob = try await store.evidenceBlob(for: item.id) { + blobs[item.id] = blob + } + } + return try backupService.makeArchiveFile( + samples: samples, + evidence: evidence, + manualDays: manualDays, + blobs: blobs, + ) + } + + /// Read a backup `.zip` and write its contents back into the store inside + /// a single transaction. `.replace` wipes the store first; `.merge` relies + /// on the store's upsert semantics. Returns counts of what was imported. + public func importBackup( + from url: URL, + strategy: ImportStrategy, + ) async throws -> ImportSummary { + // Files handed over by the document picker are security-scoped; we + // must bracket the read with start/stop access or `Data(contentsOf:)` + // fails with a permissions error. + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + let result = try backupService.readArchive(at: url) + let archive = result.archive + let blobs = result.blobs + + try await store.perform { + if strategy == .replace { + try await store.clearAll() + } + for sample in archive.samples { + try await store.add(sample: sample) + } + for item in archive.evidence { + try await store.write(evidence: item, blob: blobs[item.id]) + } + for day in archive.manualDays { + try await store.setManualDay(day) + } + } + + return ImportSummary( + sampleCount: archive.samples.count, + evidenceCount: archive.evidence.count, + manualDayCount: archive.manualDays.count, + ) + } + // MARK: - GPS lifecycle /// Begin (or resume) GPS ingestion. Idempotent: a second call while diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index fb2e04b..9146155 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -267,6 +267,112 @@ struct WhereControllerTests { let fetchedBlob = try await controller.evidenceBlob(for: evidence.id) #expect(fetchedBlob == blob) } + + // MARK: - Backup + + private static let backupEvidence = Evidence( + id: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!, + kind: .boardingPass, + capturedAt: Date(timeIntervalSince1970: 1_700_050_000), + region: .california, + note: "SFO → JFK", + contentType: .pdf, + ) + private static let backupBlob = Data("boarding-pass-pdf".utf8) + + /// Seed a controller's store with one sample, one evidence (with a blob), + /// and one manual day so backup tests have all three tables populated. + private func seedBackupData(_ controller: WhereController) async throws { + try await controller.ingest(sample(at: "2026-03-15T12:00:00-07:00")) + try await controller.addEvidence(Self.backupEvidence, blob: Self.backupBlob) + try await controller.addManualDay( + date: iso("2026-07-04T15:00:00-07:00"), + regions: [.newYork], + ) + } + + @Test func backupExportThenMergeImportReproducesEveryTable() async throws { + let (source, sourceStore, _) = try Self.makeController() + try await seedBackupData(source) + + let url = try await source.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let (destination, destinationStore, _) = try Self.makeController() + let summary = try await destination.importBackup(from: url, strategy: .merge) + + #expect(summary.sampleCount == 1) + #expect(summary.evidenceCount == 1) + #expect(summary.manualDayCount == 1) + + #expect(try await destinationStore.allSamples() == sourceStore.allSamples()) + #expect(try await destinationStore.allEvidence() == sourceStore.allEvidence()) + #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) + #expect( + try await destinationStore.evidenceBlob(for: Self.backupEvidence.id) == Self.backupBlob, + ) + } + + @Test func backupMergeImportKeepsPreexistingRows() async throws { + let (source, _, _) = try Self.makeController() + try await seedBackupData(source) + let url = try await source.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let (destination, destinationStore, _) = try Self.makeController() + // A row that exists only on the destination and is absent from the + // backup; merge must leave it in place. + let preexisting = sample(at: "2026-01-01T09:00:00-08:00") + try await destination.addManualSample(preexisting) + + _ = try await destination.importBackup(from: url, strategy: .merge) + + let ids = try await destinationStore.allSamples().map(\.id) + #expect(ids.contains(preexisting.id)) + #expect(ids.count == 2) + } + + @Test func backupReplaceImportWipesPreexistingRows() async throws { + let (source, sourceStore, _) = try Self.makeController() + try await seedBackupData(source) + let url = try await source.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let (destination, destinationStore, _) = try Self.makeController() + // Pre-existing destination data that the backup does not contain. + try await destination.addManualSample(sample(at: "2026-01-01T09:00:00-08:00")) + try await destination.addManualDay( + date: iso("2026-02-02T10:00:00-08:00"), + regions: [.canada], + ) + + _ = try await destination.importBackup(from: url, strategy: .replace) + + // The store now mirrors the backup exactly — none of the pre-existing + // rows survive. + #expect(try await destinationStore.allSamples() == sourceStore.allSamples()) + #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) + } + + @Test func clearAll_removesEveryTable() async throws { + let store = try SwiftDataStore.inMemory() + let seedSample = sample(at: "2026-03-15T12:00:00-07:00") + let seedDay = DayPresence( + date: Date(timeIntervalSince1970: 1_700_000_000), + regions: [.california], + ) + try await store.perform { + try await store.add(sample: seedSample) + try await store.write(evidence: Self.backupEvidence, blob: Self.backupBlob) + try await store.setManualDay(seedDay) + } + + try await store.perform { try await store.clearAll() } + + #expect(try await store.allSamples().isEmpty) + #expect(try await store.allEvidence().isEmpty) + #expect(try await store.allManualDays().isEmpty) + } } private func iso(_ string: String) -> Date { From f86189e1131e95caa5dd89eced095966e8e13771 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 14:09:24 -0400 Subject: [PATCH 5/7] Bridge backup export/import through WhereModel Add exportBackup()/importBackup(from:strategy:) plus a BackupState (idle/exporting/importing) and a backupError channel so the Settings UI can show progress, present the share sheet, and surface failures. Keep the TestStore double conforming to the extended WhereStore. Covered by a two-store round-trip and a bogus-file failure test. Closes plan step: model-api. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/WhereModel.swift | 51 ++++++++++++ Where/WhereUI/Tests/Support/TestStore.swift | 12 +++ .../WhereUI/Tests/WhereModelBackupTests.swift | 77 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 Where/WhereUI/Tests/WhereModelBackupTests.swift diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index ac53f34..53f8466 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -273,4 +273,55 @@ public final class WhereModel { loadState = .failed(error.localizedDescription) } } + + // MARK: - Backup + + /// Where a backup export/import is in its lifecycle, so the UI can show a + /// spinner and disable the relevant row while work is in flight. + public enum BackupState: Equatable { + case idle + case exporting + case importing + } + + public private(set) var backupState: BackupState = .idle + + /// Last backup failure, surfaced as an alert. Mutable so the alert binding + /// can clear it on dismiss (mirrors `permissionDenied`). + public var backupError: String? + + /// Build a backup `.zip` of the entire database and return its URL for the + /// share sheet, or `nil` if the export failed (in which case `backupError` + /// is set). The caller is responsible for the returned temporary file. + public func exportBackup() async -> URL? { + guard let controller else { return nil } + backupState = .exporting + defer { backupState = .idle } + do { + return try await controller.exportBackup() + } catch { + backupError = error.localizedDescription + return nil + } + } + + /// Import a backup file with the chosen merge/replace strategy, refreshing + /// the current year afterward. Returns the import summary on success, or + /// `nil` on failure (with `backupError` set). + public func importBackup( + from url: URL, + strategy: WhereController.ImportStrategy, + ) async -> WhereController.ImportSummary? { + guard let controller else { return nil } + backupState = .importing + defer { backupState = .idle } + do { + let summary = try await controller.importBackup(from: url, strategy: strategy) + await refresh() + return summary + } catch { + backupError = error.localizedDescription + return nil + } + } } diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 4db61b0..266c284 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -81,6 +81,10 @@ actor TestStore: WhereStore { try await backing.evidence(in: interval) } + func allEvidence() async throws -> [Evidence] { + try await backing.allEvidence() + } + func evidenceBlob(for id: UUID) async throws -> Data? { try await backing.evidenceBlob(for: id) } @@ -94,7 +98,15 @@ actor TestStore: WhereStore { try await backing.manualDays(in: interval) } + func allManualDays() async throws -> [DayPresence] { + try await backing.allManualDays() + } + func clear(in interval: DateInterval) async throws { try await backing.clear(in: interval) } + + func clearAll() async throws { + try await backing.clearAll() + } } diff --git a/Where/WhereUI/Tests/WhereModelBackupTests.swift b/Where/WhereUI/Tests/WhereModelBackupTests.swift new file mode 100644 index 0000000..1f0c3ab --- /dev/null +++ b/Where/WhereUI/Tests/WhereModelBackupTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +/// Exercises `WhereModel`'s backup export/import bridging: a successful +/// round-trip across two independent stores, and the failure path that +/// surfaces `backupError` without leaving the model stuck "working". +@MainActor +struct WhereModelBackupTests { + private func date(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date( + from: DateComponents(year: year, month: month, day: day, hour: 12), + )! + } + + private func seed(_ controller: WhereController) async throws { + try await controller.addManualDay( + date: date(year: 2026, month: 3, day: 1), + regions: [.california], + ) + try await controller.addEvidence( + Evidence( + kind: .boardingPass, + capturedAt: date(year: 2026, month: 3, day: 1), + region: .california, + contentType: .pdf, + ), + blob: Data("boarding-pass".utf8), + ) + } + + @Test func exportThenImportRoundTripsThroughTheModel() async throws { + let sourceStore = try SwiftDataStore.inMemory() + let source = WhereController(store: sourceStore, locationSource: ScriptedLocationSource()) + try await seed(source) + let sourceModel = WhereModel(controller: source, selectedYear: 2026) + + let url = try #require(await sourceModel.exportBackup()) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + #expect(sourceModel.backupState == .idle) + #expect(sourceModel.backupError == nil) + + let destinationStore = try SwiftDataStore.inMemory() + let destination = WhereController( + store: destinationStore, + locationSource: ScriptedLocationSource(), + ) + let destinationModel = WhereModel(controller: destination, selectedYear: 2026) + + let summary = try #require( + await destinationModel.importBackup(from: url, strategy: .merge), + ) + #expect(summary.evidenceCount == 1) + #expect(summary.manualDayCount == 1) + #expect(destinationModel.backupState == .idle) + + #expect(try await destinationStore.allEvidence() == sourceStore.allEvidence()) + #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) + } + + @Test func importingABogusFileSetsBackupError() async throws { + let store = try SwiftDataStore.inMemory() + let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + let model = WhereModel(controller: controller, selectedYear: 2026) + + let bogus = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString).zip") + try Data("not a backup".utf8).write(to: bogus) + defer { try? FileManager.default.removeItem(at: bogus) } + + let summary = await model.importBackup(from: bogus, strategy: .replace) + #expect(summary == nil) + #expect(model.backupError != nil) + #expect(model.backupState == .idle) + } +} From 464064e346eeb6a60953b7f93dc809cfa9192f26 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 14:13:53 -0400 Subject: [PATCH 6/7] Add Backup section to Settings (export share / import) New Backup section above the erase action: Export builds the .zip and presents it in a UIActivityViewController share sheet (email / AirDrop / Files), cleaning up the temp file on dismiss; Import opens a .zip via fileImporter, then a merge/replace confirmation dialog, and reports a success summary or error alert. Both rows show a spinner and disable while work is in flight. Adds the settings.backup.* string catalog entries and Strings accessors. Closes plan step: settings-ui. Co-authored-by: Cursor --- .../Sources/Resources/Localizable.xcstrings | 143 ++++++++++++++++ .../Sources/Settings/SettingsView.swift | 153 +++++++++++++++++- Where/WhereUI/Sources/Shared/Strings.swift | 62 +++++++ 3 files changed, 357 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index dd162f4..8ca6b2d 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -467,6 +467,149 @@ } } }, + "settings.backup.errorTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backup failed" + } + } + } + }, + "settings.backup.export" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export data" + } + } + } + }, + "settings.backup.exporting" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing backup…" + } + } + } + }, + "settings.backup.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export your whole history as a .zip you can email or save to Files, then import it on another device to restore everything." + } + } + } + }, + "settings.backup.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backup" + } + } + } + }, + "settings.backup.import" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import data" + } + } + } + }, + "settings.backup.imported.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imported %lld location samples, %lld pieces of evidence, and %lld manual days." + } + } + } + }, + "settings.backup.imported.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backup imported" + } + } + } + }, + "settings.backup.importing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importing…" + } + } + } + }, + "settings.backup.importStrategy.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merge keeps everything already on this device and adds the file's records. Replace erases this device first, then restores only what's in the file." + } + } + } + }, + "settings.backup.importStrategy.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import backup" + } + } + } + }, + "settings.backup.merge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merge" + } + } + } + }, + "settings.backup.replace" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Replace all" + } + } + } + }, "settings.data.erase" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index e6b382b..d7725ce 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,15 +1,30 @@ import SwiftUI import UIKit +import UniformTypeIdentifiers import WhereCore /// Settings tab: location permission + tracking, retroactive manual entry, -/// and the destructive "erase a year" action. +/// whole-database backup export/import, and the destructive "erase a year" +/// action. struct SettingsView: View { @Environment(WhereModel.self) private var model @Environment(\.openURL) private var openURL @State private var showClearConfirmation = false + // Backup export: the built archive, presented in a share sheet, plus the + // URL to clean up once that sheet is dismissed. + @State private var exportedFile: ExportedFile? + @State private var cleanupURL: URL? + + // Backup import: the picked file, the merge/replace choice, and the + // success confirmation. + @State private var showImporter = false + @State private var pendingImportURL: URL? + @State private var showStrategyDialog = false + @State private var showImportSuccess = false + @State private var lastImportSummary: WhereController.ImportSummary? + var body: some View { @Bindable var model = model @@ -17,6 +32,7 @@ struct SettingsView: View { Form { trackingSection manualEntrySection + backupSection dataSection } .navigationTitle(Strings.settingsTitle) @@ -26,6 +42,50 @@ struct SettingsView: View { } message: { Text(Strings.settingsPermissionAlertMessage) } + .sheet(item: $exportedFile, onDismiss: cleanupExportedFile) { file in + ShareSheet(items: [file.url]) + } + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.zip], + onCompletion: handleImportSelection, + ) + .confirmationDialog( + Strings.settingsBackupImportStrategyTitle, + isPresented: $showStrategyDialog, + titleVisibility: .visible, + presenting: pendingImportURL, + ) { url in + Button(Strings.settingsBackupMerge) { runImport(url: url, strategy: .merge) } + Button(Strings.settingsBackupReplace, role: .destructive) { + runImport(url: url, strategy: .replace) + } + Button(Strings.settingsDataCancel, role: .cancel) { pendingImportURL = nil } + } message: { _ in + Text(Strings.settingsBackupImportStrategyMessage) + } + .alert( + Strings.settingsBackupImportedTitle, + isPresented: $showImportSuccess, + presenting: lastImportSummary, + ) { _ in + Button(Strings.commonOK, role: .cancel) {} + } message: { summary in + Text(Strings.settingsBackupImportedMessage( + samples: summary.sampleCount, + evidence: summary.evidenceCount, + manualDays: summary.manualDayCount, + )) + } + .alert( + Strings.settingsBackupErrorTitle, + isPresented: backupErrorBinding, + presenting: model.backupError, + ) { _ in + Button(Strings.commonOK, role: .cancel) { model.backupError = nil } + } message: { message in + Text(message) + } } } @@ -90,6 +150,78 @@ struct SettingsView: View { } } + private var backupSection: some View { + Section { + Button { + Task { + if let url = await model.exportBackup() { + cleanupURL = url + exportedFile = ExportedFile(url: url) + } + } + } label: { + if model.backupState == .exporting { + Label { Text(Strings.settingsBackupExporting) } icon: { ProgressView() } + } else { + Label(Strings.settingsBackupExport, systemImage: "square.and.arrow.up") + } + } + .disabled(model.backupState != .idle) + + Button { + showImporter = true + } label: { + if model.backupState == .importing { + Label { Text(Strings.settingsBackupImporting) } icon: { ProgressView() } + } else { + Label(Strings.settingsBackupImport, systemImage: "square.and.arrow.down") + } + } + .disabled(model.backupState != .idle) + } header: { + Text(Strings.settingsBackupHeader) + } footer: { + Text(Strings.settingsBackupFooter) + } + } + + /// Bridges the model's optional `backupError` to the Bool an `.alert` + /// presentation needs, clearing it when the alert is dismissed. + private var backupErrorBinding: Binding { + Binding( + get: { model.backupError != nil }, + set: { presented in if !presented { model.backupError = nil } }, + ) + } + + private func handleImportSelection(_ result: Result) { + switch result { + case let .success(url): + pendingImportURL = url + showStrategyDialog = true + case let .failure(error): + model.backupError = error.localizedDescription + } + } + + private func runImport(url: URL, strategy: WhereController.ImportStrategy) { + Task { + if let summary = await model.importBackup(from: url, strategy: strategy) { + lastImportSummary = summary + showImportSuccess = true + } + pendingImportURL = nil + } + } + + private func cleanupExportedFile() { + guard let url = cleanupURL else { return } + // The archive lives in a unique temporary subdirectory; remove the + // whole thing once the share sheet is gone. + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + cleanupURL = nil + } + private var dataSection: some View { Section { Button(role: .destructive) { @@ -141,6 +273,25 @@ struct SettingsView: View { } } +/// Identifiable wrapper so a freshly-built export URL can drive a +/// `.sheet(item:)` presentation. +private struct ExportedFile: Identifiable { + let id = UUID() + let url: URL +} + +/// Thin `UIActivityViewController` bridge so the exported `.zip` can be +/// emailed, AirDropped, or saved to Files via the system share sheet. +private struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} + #if DEBUG #Preview { SettingsView() diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 8edb1c5..605194b 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -250,6 +250,68 @@ enum Strings { ) } + // MARK: Settings backup + + static var settingsBackupHeader: String { + localized("settings.backup.header") + } + + static var settingsBackupFooter: String { + localized("settings.backup.footer") + } + + static var settingsBackupExport: String { + localized("settings.backup.export") + } + + static var settingsBackupExporting: String { + localized("settings.backup.exporting") + } + + static var settingsBackupImport: String { + localized("settings.backup.import") + } + + static var settingsBackupImporting: String { + localized("settings.backup.importing") + } + + static var settingsBackupErrorTitle: String { + localized("settings.backup.errorTitle") + } + + static var settingsBackupImportStrategyTitle: String { + localized("settings.backup.importStrategy.title") + } + + static var settingsBackupImportStrategyMessage: String { + localized("settings.backup.importStrategy.message") + } + + static var settingsBackupMerge: String { + localized("settings.backup.merge") + } + + static var settingsBackupReplace: String { + localized("settings.backup.replace") + } + + static var settingsBackupImportedTitle: String { + localized("settings.backup.imported.title") + } + + static func settingsBackupImportedMessage( + samples: Int, + evidence: Int, + manualDays: Int, + ) -> String { + String( + localized: "settings.backup.imported.message", + defaultValue: "Imported \(samples) location samples, \(evidence) pieces of evidence, and \(manualDays) manual days.", + bundle: .module, + ) + } + // MARK: Manual entry static var manualEntryPickerLabel: String { From c3560d96195fc3ecf65e266099e05eb468dea6d5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 5 Jun 2026 14:16:02 -0400 Subject: [PATCH 7/7] Cover backup strings and 3-arg import summary message Round out the backup test coverage (BackupService, controller merge/ replace round-trips, clearAll, and the WhereModel bridge were added with their respective steps): assert the new settings.backup.* catalog keys resolve, and that the imported-summary message substitutes sample / evidence / manual-day counts in the right order. Settings hosting stays covered by the existing settingsViewHosts() test, which now builds the new Backup section. Closes plan step: tests. Co-authored-by: Cursor --- Where/WhereUI/Tests/StringsTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift index 075353b..a1acdfd 100644 --- a/Where/WhereUI/Tests/StringsTests.swift +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -47,4 +47,21 @@ struct StringsTests { #expect(Strings.primaryHeaderSubtitle(count: 1) == "1 day on the map so far") #expect(Strings.primaryHeaderSubtitle(count: 12) == "12 days on the map so far") } + + @Test func backupStringsResolveToCatalogValues() { + #expect(Strings.settingsBackupHeader == "Backup") + #expect(Strings.settingsBackupExport == "Export data") + #expect(Strings.settingsBackupImport == "Import data") + #expect(Strings.settingsBackupMerge == "Merge") + #expect(Strings.settingsBackupReplace == "Replace all") + #expect(Strings.settingsBackupImportedTitle == "Backup imported") + } + + @Test func backupImportedMessageSubstitutesAllThreeCountsInOrder() { + #expect( + Strings.settingsBackupImportedMessage(samples: 3, evidence: 2, manualDays: 5) + == + "Imported 3 location samples, 2 pieces of evidence, and 5 manual days.", + ) + } }