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
148 lines
5.6 KiB
Swift
148 lines
5.6 KiB
Swift
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 }
|
|
}
|