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:
@@ -43,6 +43,13 @@ final class SyncEngine {
|
||||
/// transcription stack exists.
|
||||
var onAudioNoteIngested: ((ULID) -> Void)?
|
||||
|
||||
/// Fired when a staged watch recording reaches a **terminal** state on this
|
||||
/// device — durably ingested, or permanently vetoed (tombstoned / already
|
||||
/// present). The composition root wires this to send the ingest ack that
|
||||
/// lets the watch delete its local copy; the watch keeps (and redelivers)
|
||||
/// anything never acked, so transient failures stay silent here on purpose.
|
||||
var onWatchRecordingHandled: ((String) -> Void)?
|
||||
|
||||
static let recordsDirectory = "Records"
|
||||
static let stubsDirectory = "Stubs"
|
||||
|
||||
@@ -255,22 +262,35 @@ final class SyncEngine {
|
||||
|
||||
// MARK: - Transcription
|
||||
|
||||
/// Land a completed transcription onto a note. Re-reads the file (the
|
||||
/// authority) so a concurrent edit isn't clobbered, then **appends** rather
|
||||
/// than replaces when the user typed while the note was pending — non-empty
|
||||
/// text whose `modifiedAt` moved past the note's `createdAt`. Otherwise the
|
||||
/// transcript replaces the placeholder-empty text. The cache updates through
|
||||
/// the monitor as usual (via `updateNote`).
|
||||
func applyTranscription(id: ULID, text: String, localeID: String) async throws {
|
||||
/// Land a completed transcription onto a note as a new attempt. Re-reads
|
||||
/// the file (the authority) so a concurrent edit isn't clobbered.
|
||||
/// `payload.text` — the user's own writing — is never touched: transcripts
|
||||
/// accumulate in `audio.attempts` and the latest one is what the UI shows.
|
||||
/// The cache updates through the monitor as usual (via `updateNote`).
|
||||
func applyTranscription(
|
||||
id: ULID,
|
||||
text: String,
|
||||
localeID: String,
|
||||
confidence: Double? = nil,
|
||||
languageWasForced: Bool = false
|
||||
) async throws {
|
||||
guard let store else { throw SyncError.iCloudUnavailable }
|
||||
guard var note = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
|
||||
note.isReadable, var audio = note.payload.audio else { return }
|
||||
guard let raw = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
|
||||
raw.isReadable else { return }
|
||||
var note = migrateIfNeeded(raw)
|
||||
guard var audio = note.payload.audio else { return }
|
||||
|
||||
let userEdited = !note.payload.text.isEmpty && note.meta.modifiedAt > note.meta.createdAt
|
||||
note.payload.text = userEdited ? note.payload.text + "\n\n" + text : text
|
||||
let now = Date()
|
||||
audio.attempts.append(Note.TranscriptionAttempt(
|
||||
text: text,
|
||||
localeID: localeID,
|
||||
confidence: confidence,
|
||||
transcribedAt: now,
|
||||
languageWasForced: languageWasForced
|
||||
))
|
||||
audio.transcriptionStatus = .done
|
||||
audio.transcriptionLocaleID = localeID
|
||||
audio.transcribedAt = Date()
|
||||
audio.transcribedAt = now
|
||||
note.payload.audio = audio
|
||||
try await updateNote(note)
|
||||
}
|
||||
@@ -337,6 +357,11 @@ final class SyncEngine {
|
||||
context: staged.context
|
||||
)
|
||||
}
|
||||
|
||||
let orphans = Self.watchInboxOrphanCount()
|
||||
if orphans > 0 {
|
||||
print("[Sync] Watch inbox holds \(orphans) recording(s) without readable metadata — not ingestible by this build")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest one recording handed over from the watch — the single sanctioned
|
||||
@@ -358,14 +383,17 @@ final class SyncEngine {
|
||||
) async -> Bool {
|
||||
guard let store, let tombstones else { return false }
|
||||
|
||||
// Permanent skips — clear the stale staged copy so it stops re-draining.
|
||||
// Permanent skips — clear the stale staged copy so it stops re-draining,
|
||||
// and ack so the watch drops its copy too (redelivery would just loop).
|
||||
if await tombstones.stubExists(id: id.stringValue) {
|
||||
Self.removeStagedFiles(audioURL: audioURL)
|
||||
onWatchRecordingHandled?(id.stringValue)
|
||||
return false
|
||||
}
|
||||
let jsonPath = Note.relativePath(forID: id)
|
||||
if await store.fileExists(Self.dataPath(jsonPath)) {
|
||||
Self.removeStagedFiles(audioURL: audioURL)
|
||||
onWatchRecordingHandled?(id.stringValue)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -392,10 +420,24 @@ final class SyncEngine {
|
||||
lastSyncError = nil
|
||||
|
||||
print("[Sync] Ingested watch recording \(id.stringValue) recorded \(recordedAt)")
|
||||
onWatchRecordingHandled?(id.stringValue)
|
||||
onAudioNoteIngested?(id)
|
||||
return true
|
||||
}
|
||||
|
||||
/// `.m4a` files sitting in the watch inbox with no readable metadata sidecar
|
||||
/// — quarantined forward-gate deliveries or the residue of a failed staging.
|
||||
/// The drain can never ingest them; surfaced so they're at least visible.
|
||||
nonisolated static func watchInboxOrphanCount() -> Int {
|
||||
let fm = FileManager.default
|
||||
let entries = (try? fm.contentsOfDirectory(at: watchInboxURL, includingPropertiesForKeys: nil)) ?? []
|
||||
let sidecarBases = Set(entries.filter { $0.lastPathComponent.hasSuffix(".meta.plist") }
|
||||
.map { $0.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "") })
|
||||
return entries.filter {
|
||||
$0.pathExtension == "m4a" && !sidecarBases.contains($0.deletingPathExtension().lastPathComponent)
|
||||
}.count
|
||||
}
|
||||
|
||||
/// One recording staged in the watch inbox: the `.m4a` and the metadata the
|
||||
/// receiver wrote in its `<ULID>.meta.plist` sidecar.
|
||||
private struct StagedRecording {
|
||||
@@ -711,13 +753,35 @@ final class SyncEngine {
|
||||
|
||||
// MARK: - Schema migration
|
||||
|
||||
/// Read-time upgrade for documents behind the current schema. A no-op
|
||||
/// while `currentSchemaVersion` is 1, but wired into both read paths so
|
||||
/// the first schema bump migrates instead of silently skipping. Write-back
|
||||
/// happens lazily on the next save — never from live monitor events.
|
||||
private func migrateIfNeeded(_ note: Note) -> Note {
|
||||
/// Read-time upgrade for documents behind the current schema. Wired into
|
||||
/// both read paths so a schema bump migrates instead of silently skipping.
|
||||
/// Write-back happens lazily on the next save — never from live monitor
|
||||
/// events.
|
||||
///
|
||||
/// v2 → v3: transcripts moved from `payload.text` into `audio.attempts`.
|
||||
/// A v2 voice note whose transcription completed holds its transcript (or
|
||||
/// the transcript merged with user typing — indistinguishable by then) in
|
||||
/// `text`; move that whole text into the first attempt so the v3 invariant
|
||||
/// "text is user writing only" holds. Confidence was never recorded ⇒ nil.
|
||||
/// Internal (not private) so the migration policy is unit-testable.
|
||||
func migrateIfNeeded(_ note: Note) -> Note {
|
||||
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
|
||||
var migrated = note
|
||||
if note.meta.schemaVersion < 3,
|
||||
var audio = migrated.payload.audio,
|
||||
audio.transcriptionStatus == .done,
|
||||
audio.attempts.isEmpty,
|
||||
!migrated.payload.text.isEmpty {
|
||||
audio.attempts = [Note.TranscriptionAttempt(
|
||||
text: migrated.payload.text,
|
||||
localeID: audio.transcriptionLocaleID ?? "",
|
||||
confidence: nil,
|
||||
transcribedAt: audio.transcribedAt ?? migrated.meta.modifiedAt,
|
||||
languageWasForced: false
|
||||
)]
|
||||
migrated.payload.text = ""
|
||||
migrated.payload.audio = audio
|
||||
}
|
||||
migrated.meta.schemaVersion = Note.currentSchemaVersion
|
||||
return migrated
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user