Files
notes/Notes/Model/Note.swift
T
rzen b2f16e8600 Add Projects: third tab, swipe-to-file, per-project note capture
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
2026-07-16 18:24:11 -04:00

251 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 v1v3 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) ?? ""
}
}