Decouple transcripts from note text; end-to-end watch transfer acks

Schema v3: transcription attempts accumulate on AudioInfo (text, locale,
confidence, forced flag) — latest attempt is displayed, payload.text is
purely user writing, v2 notes migrate at read time. Editor shows the
transcript in its own live-updating section with insert-into-note; search
matches transcripts weighted by confidence. Watch recordings are now
deleted only on the phone's durable-ingest ack (transferUserInfo), closing
every phone-side loss window; failed sidecar writes unstage instead of
orphaning, and unreadable inbox recordings are surfaced in logs.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
This commit is contained in:
2026-07-16 12:29:49 -04:00
parent c29d25748a
commit c5c90e9441
15 changed files with 546 additions and 55 deletions
+30 -6
View File
@@ -40,6 +40,10 @@ final class NoteEntity {
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
@@ -82,19 +86,31 @@ extension NoteEntity {
}
/// 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.
/// 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
transcribedAt: nil,
attempts: attempts
)
}
set {
@@ -103,12 +119,20 @@ extension NoteEntity {
transcriberDeviceID = newValue?.transcriberDeviceID
transcriptionStatusRaw = newValue?.transcriptionStatus.rawValue
transcriptionLocaleID = newValue?.transcriptionLocaleID
transcriptText = newValue?.latestAttempt?.text
transcriptConfidence = newValue?.latestAttempt?.confidence
}
}
/// First line of the text, used as the display title.
/// 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 {
text.split(separator: "\n", omittingEmptySubsequences: true)
displayText.split(separator: "\n", omittingEmptySubsequences: true)
.first.map(String.init) ?? ""
}
}