Files
notes/Notes/Model/NoteEntity.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

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 }
}