Where: import/export backup (whole-database .zip)#11
Conversation
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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/<evidence-id>) 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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
|
@codex review |
|
@cursoragent review |
|
Addressed the review comment proactively.
Committed and pushed:
Verification:
|
Co-authored-by: Kyle Van Essen <k@kve.me>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c3560d9619
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| private static func makeEncoder() -> JSONEncoder { | ||
| let encoder = JSONEncoder() | ||
| encoder.dateEncodingStrategy = .iso8601 |
There was a problem hiding this comment.
Preserve sub-second timestamps in backups
When a user exports real GPS/evidence data whose Date values include fractional seconds, JSONEncoder.DateEncodingStrategy.iso8601 serializes only whole seconds, so importing the backup changes LocationSample.timestamp and Evidence.capturedAt instead of restoring rows exactly. This can collapse ordering for samples captured within the same second and makes the advertised row-for-row backup lossy; use a fractional-seconds or numeric date representation for the archive.
Useful? React with 👍 / 👎.
| guard let data = try? Data(contentsOf: assetURL) else { | ||
| Self.logger.fault( | ||
| "Backup asset missing for evidence \(entry.evidenceId, privacy: .public); skipping blob", | ||
| ) | ||
| continue |
There was a problem hiding this comment.
Fail imports when listed evidence blobs are missing
When a backup is damaged or incomplete and the manifest lists an evidence asset that is absent or unreadable, this path logs the fault and continues, so the import later reports success while restoring the evidence metadata without its proof bytes. In the .replace flow that means the existing store is cleared and the user silently loses attachments from the backup; a missing manifest-listed asset should make readArchive throw before any mutation happens.
Useful? React with 👍 / 👎.
| } | ||
| } | ||
|
|
||
| private func runImport(url: URL, strategy: WhereController.ImportStrategy) { |
There was a problem hiding this comment.
Shouldn't this be async or something so we can show the progress?
| /// Bridges the model's optional `backupError` to the Bool an `.alert` | ||
| /// presentation needs, clearing it when the alert is dismissed. | ||
| private var backupErrorBinding: Binding<Bool> { | ||
| Binding( |
There was a problem hiding this comment.
We shouldn't be using get/set closure-based bindings. Please change this, AND update AGENTS.md so we stop adding these.
|
|
||
| /// Thin `UIActivityViewController` bridge so the exported `.zip` can be | ||
| /// emailed, AirDropped, or saved to Files via the system share sheet. | ||
| private struct ShareSheet: UIViewControllerRepresentable { |
There was a problem hiding this comment.
Do we need this? I thought SwiftUI had a native share sheet? Or does it not for zips?
Addresses PR #11 review feedback: - Export now uses a native `ShareLink` driven by a lazy `BackupArchiveFile` (`Transferable`) instead of the `UIActivityViewController` bridge, so the system shows its own export progress. The previous temp archive is cleaned up on the next export (ShareLink has no dismissal hook). - Import shows determinate per-row progress: `WhereController.importBackup` takes an `@Sendable` progress callback (throttled to whole-percent steps), surfaced through an `AsyncStream` into `WhereModel.backupProgress` and a `ProgressView(value:)`. - Replace the `backupErrorBinding` and `trackingBinding` closure `Binding`s with `WhereModel.isShowingBackupError` / `trackingEnabled` computed properties, bound directly; add an AGENTS.md convention banning `Binding(get:set:)` in views. - Strings: drop the now-unused `settings.backup.exporting`, add `settings.backup.shareTitle` for the share preview. Co-authored-by: Cursor <cursoragent@cursor.com>
…xport Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # Where/WhereCore/Sources/WhereController.swift # Where/WhereCore/Tests/WhereControllerTests.swift


Summary
Adds a whole-database backup feature to the Where app, surfaced from a new Backup section in the Settings tab.
.zip, then share it via the system share sheet (email / AirDrop / Files)..zip, choosing Merge (upsert into existing data) or Replace all (wipe first, then restore) at import time.Archive format
Layering (UI never touches SwiftData)
WhereStore/SwiftDataStorewithallEvidence(),allManualDays(),clearAll(); makeDayPresenceCodable; addBackupArchive+BackupService(zip codec); addWhereController.exportBackup()/importBackup(from:strategy:)(singleperformtransaction, security-scoped URL bracketing).WhereModelbridges export/import with aBackupState+backupError;SettingsViewadds Export (share sheet + temp cleanup) and Import (fileImporter-> merge/replaceconfirmationDialog-> success/error alert), both showing progress while in flight. Newsettings.backup.*string-catalog entries.Dependency
WhereCoretarget (Foundation has no public zip reader). Committed as standalone groundwork, matching the existingswift-snapshot-testingexternal-SPM pattern.Notes
formatVersion) so future schema changes can refuse/migrate.Test plan
tuist test WhereCoreTests— manifest + zip round-trip, non-zip rejection (BackupServiceTests); controller merge/replace round-trips, merge keeps pre-existing rows, replace wipes them,clearAllempties all tables (WhereControllerTests).tuist test WhereUITests— model two-store round-trip + bogus-file failure (WhereModelBackupTests); backup string resolution + 3-arg import-summary ordering (StringsTests);SettingsViewhosts the new section crash-free (ScreenHostingTests)../swiftformat --lintclean.Made with Cursor