import Foundation import SwiftData /// Rebuildable cache row for one note. Holds nothing that isn't also, more /// authoritatively, in the note's JSON file — the metadata observer (via /// `NoteMapper`) is the only writer. @Model final class NoteEntity { @Attribute(.unique) var id: String = "" var text: String = "" var sourceRaw: String = Note.Source.typed.rawValue var tags: [String] = [] /// The project (or "category") this note is filed under, nil when unfiled. var project: String? var createdAt: Date = Date() var modifiedAt: Date = Date() /// Back-pointer to the authoritative file, so a "removed" event can find /// the row to delete by path when it lacks the id. var jsonRelativePath: String = "" // Flattened CaptureContext — @Model can't store a nested Codable struct // as one column; call sites use the `context` computed property instead. var latitude: Double? var longitude: Double? var horizontalAccuracy: Double? var placeName: String? var thoroughfare: String? var locality: String? var administrativeArea: String? var country: String? var timeZoneID: String? var device: String? // Flattened AudioInfo — nil columns mean a plain text note. `hasAudio` is // stored rather than derived from `audioDuration` so a zero-length recording // is still recognizably a voice note. `transcribedAt` is deliberately not // cached (the file is authoritative for that timestamp), so a cache-only // reconstruction of a completed note has a nil `transcribedAt`. var hasAudio: Bool = false var audioDuration: Double? var transcriptionStatusRaw: String? var transcriptionLocaleID: String? var transcriberDeviceID: String? // Latest transcription attempt, cached flat for list display and search. // The full attempt history lives only in the file (like `transcribedAt`). var transcriptText: String? var transcriptConfidence: Double? init(id: String, jsonRelativePath: String) { self.id = id self.jsonRelativePath = jsonRelativePath } } extension NoteEntity { var context: CaptureContext { get { CaptureContext( latitude: latitude, longitude: longitude, horizontalAccuracy: horizontalAccuracy, placeName: placeName, thoroughfare: thoroughfare, locality: locality, administrativeArea: administrativeArea, country: country, timeZoneID: timeZoneID, device: device ) } set { latitude = newValue.latitude longitude = newValue.longitude horizontalAccuracy = newValue.horizontalAccuracy placeName = newValue.placeName thoroughfare = newValue.thoroughfare locality = newValue.locality administrativeArea = newValue.administrativeArea country = newValue.country timeZoneID = newValue.timeZoneID device = newValue.device } } var source: Note.Source { Note.Source(rawValue: sourceRaw) ?? .typed } /// Reconstructs `AudioInfo` from the flattened columns (nil for a text /// note). `transcribedAt` and the attempt history are not fully cached — /// only the latest attempt survives (with a placeholder date), acceptable /// for the cache-only offline fallback, where the file (which carries the /// full history) simply wasn't reachable. var audio: Note.AudioInfo? { get { guard hasAudio else { return nil } var attempts: [Note.TranscriptionAttempt] = [] if let transcriptText { attempts = [Note.TranscriptionAttempt( text: transcriptText, localeID: transcriptionLocaleID ?? "", confidence: transcriptConfidence, transcribedAt: modifiedAt, languageWasForced: false )] } return Note.AudioInfo( duration: audioDuration ?? 0, transcriberDeviceID: transcriberDeviceID ?? "", transcriptionStatus: transcriptionStatusRaw .flatMap(Note.TranscriptionStatus.init(rawValue:)) ?? .pending, transcriptionLocaleID: transcriptionLocaleID, transcribedAt: nil, attempts: attempts ) } set { hasAudio = newValue != nil audioDuration = newValue?.duration transcriberDeviceID = newValue?.transcriberDeviceID transcriptionStatusRaw = newValue?.transcriptionStatus.rawValue transcriptionLocaleID = newValue?.transcriptionLocaleID transcriptText = newValue?.latestAttempt?.text transcriptConfidence = newValue?.latestAttempt?.confidence } } /// What a reader should see as this note's body: the user's own text when /// they wrote any, else the latest transcript. var displayText: String { text.isEmpty ? (transcriptText ?? "") : 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) ?? "" } } extension PersistentModel { /// Safe-to-read guard for objects reached through `@Query` or a /// relationship during a rebuild/sync burst. `isDeleted` alone only /// covers the unsaved window. var isLive: Bool { modelContext != nil && !isDeleted } }