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. v3 moved transcripts out of /// `payload.text` into `AudioInfo.attempts`: text is now purely the user's /// own writing, and every transcription attempt is preserved. v1/v2 files /// decode unchanged (missing keys ⇒ nil/empty); `migrateIfNeeded` back-fills /// v2 voice notes by moving a completed transcript from `text` into /// `attempts` at read time. v4 added optional `payload.project` — purely /// additive, so v1–v3 files decode unchanged (missing key ⇒ nil) with no /// read-time transformation needed. static let currentSchemaVersion = 4 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 } /// One finished transcription of a voice note. Attempts accumulate — a /// re-transcription appends rather than overwrites, so no machine output is /// ever lost and the user can be shown alternatives. struct TranscriptionAttempt: Codable, Sendable, Equatable { var text: String /// BCP-47 locale the transcript was produced in. var localeID: String /// Character-weighted mean confidence 0…1, or nil when the engine /// emitted none (`DictationTranscriber` locales need not report it). /// Confidences are not comparable across locales/engines — display and /// search-weighting metadata, never a cross-attempt selector. var confidence: Double? var transcribedAt: Date /// Whether the user forced this language (re-transcribe menu) rather /// than auto-detection picking it. var languageWasForced: Bool } /// 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? /// Every finished transcription, oldest first. The **latest** attempt is /// the displayed one — a forced re-transcribe is a statement of intent, /// so recency wins, not confidence (which isn't cross-locale comparable). var attempts: [TranscriptionAttempt] = [] /// The attempt currently shown for this note. var latestAttempt: TranscriptionAttempt? { attempts.last } private enum CodingKeys: String, CodingKey { case duration, transcriberDeviceID, transcriptionStatus, transcriptionLocaleID, transcribedAt, attempts } init( duration: TimeInterval, transcriberDeviceID: String, transcriptionStatus: TranscriptionStatus, transcriptionLocaleID: String? = nil, transcribedAt: Date? = nil, attempts: [TranscriptionAttempt] = [] ) { self.duration = duration self.transcriberDeviceID = transcriberDeviceID self.transcriptionStatus = transcriptionStatus self.transcriptionLocaleID = transcriptionLocaleID self.transcribedAt = transcribedAt self.attempts = attempts } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) duration = try container.decode(TimeInterval.self, forKey: .duration) transcriberDeviceID = try container.decode(String.self, forKey: .transcriberDeviceID) transcriptionStatus = try container.decode(TranscriptionStatus.self, forKey: .transcriptionStatus) transcriptionLocaleID = try container.decodeIfPresent(String.self, forKey: .transcriptionLocaleID) transcribedAt = try container.decodeIfPresent(Date.self, forKey: .transcribedAt) // Absent on v2 files — decode to empty, back-filled by migration. attempts = try container.decodeIfPresent([TranscriptionAttempt].self, forKey: .attempts) ?? [] } } 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? /// The single project (or "category" — user's choice, see /// `ProjectsNaming`) this note is filed under. Nil means unfiled. /// Optional so a pre-v4 document (no `project` key) decodes unchanged. var project: String? = nil } 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, project: String? = 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, project: project) ) } /// 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 ) ) ) } /// What a reader should see as this note's body: the user's own text when /// they wrote any, else the latest transcript. Transcripts live in /// `audio.attempts`, never in `payload.text`. var displayText: String { payload.text.isEmpty ? (payload.audio?.latestAttempt?.text ?? "") : payload.text } /// First line of the display text, used as the display title. var title: String { displayText .split(separator: "\n", omittingEmptySubsequences: true) .first.map(String.init) ?? "" } }