import Foundation import IndieSync /// The context snapshot captured at the moment a note is taken — the raw /// material the relevance ranking later matches against "where am I, what /// time is it" at recall time. Every field is optional: capture is /// best-effort and a note taken with location off is still a note. struct CaptureContext: Codable, Sendable, Equatable { var latitude: Double? var longitude: Double? var horizontalAccuracy: Double? /// Point-of-interest or venue name from reverse geocoding ("Blue Bottle /// Coffee"). Stored as text so recall works even when geocoding is /// unavailable later. var placeName: String? var thoroughfare: String? var locality: String? var administrativeArea: String? var country: String? /// Time zone the note was taken in; local hour / weekday are derived /// from `meta.createdAt` under this zone rather than stored. var timeZoneID: String? /// "iPhone" / "Mac" — which device captured the note. var device: String? static let empty = CaptureContext() var hasCoordinate: Bool { latitude != nil && longitude != nil } /// Short human summary for rows and the composer ("Blue Bottle Coffee · Oakland"). var summary: String? { let parts = [placeName, locality].compactMap { $0 } return parts.isEmpty ? nil : parts.joined(separator: " · ") } } /// One note = one JSON file in the iCloud container, filed under /// `Records/YYYY/MM/.json` (UTC-bucketed by the ULID's timestamp). struct Note: SyncDocument, VersionedDocument, Equatable { /// v2 added `Payload.audio` for voice notes. Because it is optional, a v1 /// file decodes unchanged (missing key ⇒ nil) and `migrateIfNeeded` only /// bumps the stamped version — no field back-fill is needed. static let currentSchemaVersion = 2 struct Meta: Codable, Sendable, Equatable { var id: ULID var schemaVersion: Int var createdAt: Date var modifiedAt: Date } /// How the note's text came to be. `transcribed` marks a voice note whose /// text is (or will be) produced by on-device transcription. enum Source: String, Codable, Sendable { case typed case transcribed } /// Where a voice note is in its transcription lifecycle. enum TranscriptionStatus: String, Codable, Sendable { case pending case done case failed } /// The audio-note sidecar metadata. Present only on voice notes; the audio /// bytes themselves live in a `.m4a` next to the note JSON (path derived, /// never stored — see `audioRelativePath`). struct AudioInfo: Codable, Sendable, Equatable { var duration: TimeInterval /// Per-install UUID of the device that owns pending transcription. Only /// the device whose `InstallIdentity.installID` matches this transcribes; /// every other device renders status only. Re-transcribe reassigns it. var transcriberDeviceID: String var transcriptionStatus: TranscriptionStatus /// BCP-47 locale the transcript was produced in; nil while pending. var transcriptionLocaleID: String? var transcribedAt: Date? } struct Payload: Codable, Sendable, Equatable { var text: String var source: Source var tags: [String] var context: CaptureContext /// Voice-note sidecar metadata, nil for a plain typed note. Optional so /// a v1 document (no `audio` key) decodes as a text note unchanged. var audio: AudioInfo? } var meta: Meta var payload: Payload var id: ULID { meta.id } var schemaVersion: Int { meta.schemaVersion } var relativePath: String { Self.relativePath(forID: meta.id) } static func relativePath(forID id: ULID) -> String { TimeBucketedLayout.relativePath(for: id) } /// The `.m4a` sidecar path for a note, derived by swapping `.json` → `.m4a` /// on the note's **actual** JSON path. The audio path is never stored — it /// is always this swap — so it cannot drift from the note it belongs to, and /// deleting a note whose file sits under a legacy (pre-fix) month bucket /// still targets the sidecar that shares that bucket. static func audioRelativePath(forJSONPath jsonPath: String) -> String { guard jsonPath.hasSuffix(".json") else { return jsonPath + ".m4a" } return String(jsonPath.dropLast(".json".count)) + ".m4a" } /// The `.m4a` sidecar path recomputed from a note id, sharing the note's /// UTC-bucketed layout. Safe for load/warm paths because audio exists only /// on v2+ records, which always file under the current UTC bucket — the /// legacy local-month concern applies solely to v1 text notes (no audio). static func audioRelativePath(forID id: ULID) -> String { audioRelativePath(forJSONPath: relativePath(forID: id)) } static func create( text: String, source: Source = .typed, tags: [String] = [], context: CaptureContext, audio: AudioInfo? = nil ) -> Note { let now = Date() return Note( meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now), payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio) ) } /// Factory for a note ingested from another device's recording (the watch, /// which has no iCloud of its own). The audio was captured elsewhere; this /// device only stores and transcribes it. `createdAt` is the ULID's embedded /// timestamp — the record-start instant on the capturing device — so the note /// buckets under the month it was *recorded*, not the (possibly much later) /// month it was ingested. Text is empty until transcription lands. static func createIngested( id: ULID, duration: TimeInterval, transcriberDeviceID: String, tags: [String] = [], context: CaptureContext ) -> Note { let recordedAt = id.timestamp return Note( meta: Meta(id: id, schemaVersion: currentSchemaVersion, createdAt: recordedAt, modifiedAt: recordedAt), payload: Payload( text: "", source: .transcribed, tags: tags, context: context, audio: AudioInfo( duration: duration, transcriberDeviceID: transcriberDeviceID, transcriptionStatus: .pending, transcriptionLocaleID: nil, transcribedAt: nil ) ) ) } /// First line of the text, used as the display title. var title: String { payload.text .split(separator: "\n", omittingEmptySubsequences: true) .first.map(String.init) ?? "" } }