Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -23,6 +24,9 @@ let package = Package(
),
.target(
name: "WhereCore",
dependencies: [
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
],
path: "Where/WhereCore/Sources",
resources: [
.process("Resources"),
Expand Down
57 changes: 57 additions & 0 deletions Where/WhereCore/Sources/Backup/BackupArchive.swift
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
}
}
169 changes: 169 additions & 0 deletions Where/WhereCore/Sources/Backup/BackupService.swift
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
}
blobs[entry.evidenceId] = data
}
return ReadResult(archive: archive, blobs: blobs)
}
}
2 changes: 1 addition & 1 deletion Where/WhereCore/Sources/DayPresence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Region>
Expand Down
35 changes: 35 additions & 0 deletions Where/WhereCore/Sources/Persistence/SwiftDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}

public func allEvidence() async throws -> [Evidence] {
let context = readContext()
var descriptor = FetchDescriptor<SDEvidence>(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<SDEvidence>(predicate: #Predicate { $0.id == id })
Expand Down Expand Up @@ -315,6 +326,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}

public func allManualDays() async throws -> [DayPresence] {
let context = readContext()
var descriptor = FetchDescriptor<SDManualDay>(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
Expand Down Expand Up @@ -357,6 +379,19 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore {
}
}

public func clearAll() async throws {
let context = mutationContext()
for sample in try context.fetch(FetchDescriptor<SDLocationSample>()) {
context.delete(sample)
}
for evidence in try context.fetch(FetchDescriptor<SDEvidence>()) {
context.delete(evidence)
}
for manual in try context.fetch(FetchDescriptor<SDManualDay>()) {
context.delete(manual)
}
}

private static func logFault<Record>(forCorrupt _: Record) {
logger.fault(
"Dropped corrupt SwiftData record of type \(String(describing: Record.self), privacy: .public)",
Expand Down
10 changes: 10 additions & 0 deletions Where/WhereCore/Sources/Persistence/WhereStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,25 @@ 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.
/// Implementations should treat `day.date` as already normalized to the
/// 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
}
Loading
Loading