Notes gain an optional project (schema v4, additive). Projects derive from note values like tags — no separate entity. Tab naming (Projects/Categories) is a Settings choice applied across the UI. Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
251 lines
11 KiB
Swift
251 lines
11 KiB
Swift
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/<ULID>.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) ?? ""
|
||
}
|
||
}
|