-
Notifications
You must be signed in to change notification settings - Fork 0
Where: import/export backup (whole-database .zip) #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7b8f3b1
e52284a
1e0f917
fb241c1
f86189e
464064e
c3560d9
e116210
acc221f
71865b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<uuid>`. | ||
| public let filename: String | ||
|
|
||
| public init(evidenceId: UUID, filename: String) { | ||
| self.evidenceId = evidenceId | ||
| self.filename = filename | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<evidence-id> // 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 | ||
|
Comment on lines
+159
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎. |
||
| } | ||
| blobs[entry.evidenceId] = data | ||
| } | ||
| return ReadResult(archive: archive, blobs: blobs) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user exports real GPS/evidence data whose
Datevalues include fractional seconds,JSONEncoder.DateEncodingStrategy.iso8601serializes only whole seconds, so importing the backup changesLocationSample.timestampandEvidence.capturedAtinstead 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 👍 / 👎.