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:
@@ -44,6 +44,10 @@ struct NoteEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var text = ""
|
||||
/// The note text as last loaded from the entity, so an external change
|
||||
/// (a transcription landing while this editor is open) can be told apart
|
||||
/// from the user's own typing: refresh only while `text == loadedText`.
|
||||
@State private var loadedText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var saveError: String?
|
||||
@State private var recordedAudio: AudioRecorderService.Recording?
|
||||
@@ -115,9 +119,18 @@ struct NoteEditorView: View {
|
||||
await contextService.refresh()
|
||||
case .edit(let note):
|
||||
text = note.text
|
||||
loadedText = note.text
|
||||
tagsText = note.tags.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
// A transcription finishing while this editor is open updates the
|
||||
// entity underneath us; mirror it into the text field as long as the
|
||||
// user hasn't typed (their edits always win over a live refresh).
|
||||
.onChange(of: editedNote?.text) { _, newValue in
|
||||
guard let newValue, text == loadedText else { return }
|
||||
text = newValue
|
||||
loadedText = newValue
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 420)
|
||||
#endif
|
||||
@@ -190,6 +203,21 @@ struct NoteEditorView: View {
|
||||
transcriptionStatusRow(for: note)
|
||||
retranscribeMenu(for: note)
|
||||
}
|
||||
// The transcript is machine output, kept apart from the user's own
|
||||
// text: shown read-only here (it updates live as attempts land) with
|
||||
// a one-tap copy into the editable note body.
|
||||
if let transcript = note.transcriptText, !transcript.isEmpty {
|
||||
Section("Transcript") {
|
||||
Text(transcript)
|
||||
.textSelection(.enabled)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
text = text.isEmpty ? transcript : text + "\n\n" + transcript
|
||||
} label: {
|
||||
Label("Insert into Note", systemImage: "text.insert")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -263,6 +291,11 @@ struct NoteEditorView: View {
|
||||
return false
|
||||
}
|
||||
|
||||
private var editedNote: NoteEntity? {
|
||||
if case .edit(let note) = mode { return note }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
|
||||
if recordedAudio != nil { return true }
|
||||
|
||||
@@ -56,7 +56,12 @@ struct SearchView: View {
|
||||
|
||||
private var results: [NoteEntity] {
|
||||
guard !searchText.isEmpty else { return [] }
|
||||
return notes.filter { $0.matches(search: searchText) }
|
||||
return notes
|
||||
.compactMap { note in note.searchScore(for: searchText).map { (note, $0) } }
|
||||
.sorted { lhs, rhs in
|
||||
lhs.1 != rhs.1 ? lhs.1 > rhs.1 : lhs.0.createdAt > rhs.0.createdAt
|
||||
}
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func delete(_ note: NoteEntity) {
|
||||
@@ -68,12 +73,24 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
extension NoteEntity {
|
||||
/// Case-insensitive match against text, tags, and capture location.
|
||||
func matches(search: String) -> Bool {
|
||||
/// Case-insensitive relevance of this note to a search, or nil when nothing
|
||||
/// matches. The score is the strongest matching field: the user's own text
|
||||
/// and tags outrank transcript matches, and a transcript match is weighted
|
||||
/// by the engine's confidence in it — a shaky transcription can still be
|
||||
/// found, it just ranks below solid matches. Confidence-less engines get a
|
||||
/// mid-scale weight rather than sinking to the bottom.
|
||||
func searchScore(for search: String) -> Double? {
|
||||
let needle = search.lowercased()
|
||||
return text.lowercased().contains(needle)
|
||||
|| tags.contains { $0.lowercased().contains(needle) }
|
||||
|| (placeName?.lowercased().contains(needle) ?? false)
|
||||
|| (locality?.lowercased().contains(needle) ?? false)
|
||||
var score = 0.0
|
||||
if text.lowercased().contains(needle) { score = max(score, 1.0) }
|
||||
if tags.contains(where: { $0.lowercased().contains(needle) }) { score = max(score, 0.9) }
|
||||
if let transcript = transcriptText, transcript.lowercased().contains(needle) {
|
||||
score = max(score, 0.5 + 0.4 * (transcriptConfidence ?? 0.5))
|
||||
}
|
||||
if placeName?.lowercased().contains(needle) == true
|
||||
|| locality?.lowercased().contains(needle) == true {
|
||||
score = max(score, 0.4)
|
||||
}
|
||||
return score > 0 ? score : nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user