- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud - AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline with enabled-language set and confidence-based auto-pick, re-transcribe menu - Settings > Transcription > Languages with asset download/reserve handling - watchOS app + accessory complication: one-button recorder, WCSession transferFile to phone, WatchInbox staging, direct-upsert ingestion - Player UI, transcription status chips, quick-capture mic in notes list - Version 0.2, changelog + README updated
122 lines
4.4 KiB
Swift
122 lines
4.4 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] = []
|
|
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?
|
|
|
|
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` is not cached, so it comes back nil even for a
|
|
/// completed transcription — acceptable for the cache-only offline fallback,
|
|
/// where the file (which carries it) simply wasn't reachable.
|
|
var audio: Note.AudioInfo? {
|
|
get {
|
|
guard hasAudio else { return nil }
|
|
return Note.AudioInfo(
|
|
duration: audioDuration ?? 0,
|
|
transcriberDeviceID: transcriberDeviceID ?? "",
|
|
transcriptionStatus: transcriptionStatusRaw
|
|
.flatMap(Note.TranscriptionStatus.init(rawValue:)) ?? .pending,
|
|
transcriptionLocaleID: transcriptionLocaleID,
|
|
transcribedAt: nil
|
|
)
|
|
}
|
|
set {
|
|
hasAudio = newValue != nil
|
|
audioDuration = newValue?.duration
|
|
transcriberDeviceID = newValue?.transcriberDeviceID
|
|
transcriptionStatusRaw = newValue?.transcriptionStatus.rawValue
|
|
transcriptionLocaleID = newValue?.transcriptionLocaleID
|
|
}
|
|
}
|
|
|
|
/// First line of the text, used as the display title.
|
|
var title: String {
|
|
text.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 }
|
|
}
|