From b225718fb1ab220e7021550052f57b4fb5cd0b03 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 2 Oct 2026 11:19:24 +0200 Subject: [PATCH] feat: edit a transcript in the Notitie editor, delete button per transcript Co-Authored-By: Claude Sonnet 5.5 --- Sources/Sst/AppModel.swift | 8 ++++ Sources/Sst/HistoryView.swift | 6 +++ Sources/Sst/NoteView.swift | 55 ++++++++++++++++++++++----- Sources/Sst/Strings.swift | 4 ++ Sources/SstData/History.swift | 8 ++++ Sources/SstData/ViewSupport.swift | 9 +++++ Tests/SstDataTests/HistoryTests.swift | 21 ++++++++++ 7 files changed, 101 insertions(+), 10 deletions(-) diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift index 0197c8e..bb25478 100644 --- a/Sources/Sst/AppModel.swift +++ b/Sources/Sst/AppModel.swift @@ -57,6 +57,8 @@ final class AppModel: ObservableObject { @Published private(set) var historyRevision = 0 @Published private(set) var dictionaryRevision = 0 @Published private(set) var searchFocusTick = 0 + /// The transcript currently open in the Notitie editor, if any. + @Published var editingEntryID: UUID? @Published var lastError: String? @Published var section: Section = .transcripts @Published var confirmingClearHistory = false @@ -375,6 +377,12 @@ final class AppModel: ObservableObject { // MARK: preview + /// Opens a transcript in the editor; edits go straight back into the history. + func edit(entry id: UUID) { + editingEntryID = id + section = .note + } + private func loadPreviewData() { note.text = "Notities voor vrijdag: Supabase-migratie plannen en Claude Code uitproberen." try? dictionary.load() diff --git a/Sources/Sst/HistoryView.swift b/Sources/Sst/HistoryView.swift index 452cef3..526427c 100644 --- a/Sources/Sst/HistoryView.swift +++ b/Sources/Sst/HistoryView.swift @@ -64,6 +64,7 @@ struct HistoryView: View { } private struct HistoryRow: View { + @EnvironmentObject private var model: AppModel let entry: HistoryEntry let isSelected: Bool @ObservedObject var list: HistoryViewModel @@ -90,6 +91,10 @@ private struct HistoryRow: View { Spacer(minLength: Space.s4) Button(Strings.copy) { copy(entry.finalText) } .buttonStyle(KeyButtonStyle()) + Button(Strings.edit) { model.edit(entry: entry.id) } + .buttonStyle(KeyButtonStyle()) + Button(Strings.delete) { list.delete(id: entry.id) } + .buttonStyle(KeyButtonStyle()) } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3) @@ -100,6 +105,7 @@ private struct HistoryRow: View { .contextMenu { Button(Strings.copy) { copy(entry.finalText) } Button(Strings.copyOriginal) { copy(entry.rawText) } + Button(Strings.edit) { model.edit(entry: entry.id) } Divider() Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) } } diff --git a/Sources/Sst/NoteView.swift b/Sources/Sst/NoteView.swift index 82da6d1..eede459 100644 --- a/Sources/Sst/NoteView.swift +++ b/Sources/Sst/NoteView.swift @@ -8,6 +8,20 @@ struct NoteView: View { @ObservedObject var note: NoteStore @State private var confirmingClear = false + private var editedEntry: HistoryEntry? { + model.editingEntryID.flatMap { model.historyList.entry(id: $0) } + } + + /// The note, or the transcript being edited. + private var text: Binding { + if let entry = editedEntry { + return Binding( + get: { model.historyList.entry(id: entry.id)?.finalText ?? "" }, + set: { model.historyList.updateText(id: entry.id, to: $0) }) + } + return $note.text + } + var body: some View { VStack(spacing: Layout.panelGap) { header @@ -15,13 +29,14 @@ struct NoteView: View { PaperBackground() if SnapshotMode.isActive { // ImageRenderer cannot draw an NSView; the snapshot shows the text instead. - Text(note.text) + Text(text.wrappedValue) .textStyle(Typography.reading) .foregroundStyle(Palette.inkPrimary) .padding(Space.s5) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { - NoteEditor(text: $note.text, isEditable: !note.isReadOnly) + NoteEditor(text: text, isEditable: editedEntry != nil || !note.isReadOnly) + .id(editedEntry?.id) } } footer @@ -29,31 +44,51 @@ struct NoteView: View { .confirmationDialog(Strings.clearNoteConfirm, isPresented: $confirmingClear) { Button(Strings.clear, role: .destructive) { note.text = "" } } + .onChange(of: model.section) { _, new in + if new != .note { model.editingEntryID = nil } + } } private var header: some View { HStack(spacing: Space.s4) { - SilkLabel(Strings.note) + if let entry = editedEntry { + SilkLabel("\(Strings.transcript) ยท \(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(Locale(identifier: "nl_NL"))))") + } else { + SilkLabel(Strings.note) + } Spacer() - Text(Strings.characters(note.text.count)) + Text(Strings.characters(text.wrappedValue.count)) .textStyle(Typography.counterSmall) .foregroundStyle(Palette.inkSecondary) Button(Strings.copy) { NSPasteboard.general.clearContents() - NSPasteboard.general.setString(note.text, forType: .string) + NSPasteboard.general.setString(text.wrappedValue, forType: .string) } .buttonStyle(KeyButtonStyle()) - .disabled(note.text.isEmpty) - Button(Strings.clear) { confirmingClear = true } - .buttonStyle(KeyButtonStyle()) - .disabled(note.text.isEmpty) + .disabled(text.wrappedValue.isEmpty) + if editedEntry != nil { + Button(Strings.done) { + model.editingEntryID = nil + model.section = .transcripts + } + .buttonStyle(KeyButtonStyle(kind: .record)) + } else { + Button(Strings.clear) { confirmingClear = true } + .buttonStyle(KeyButtonStyle()) + .disabled(note.text.isEmpty) + } } } private var footer: some View { - Text(note.isReadOnly ? Strings.noteUnreadable : Strings.noteHint(model.hotkeyBinding.displayName)) + Text(footerText) .textStyle(Typography.label) .foregroundStyle(Palette.inkTertiary) .frame(maxWidth: .infinity, alignment: .leading) } + + private var footerText: String { + if editedEntry != nil { return Strings.editingTranscriptHint } + return note.isReadOnly ? Strings.noteUnreadable : Strings.noteHint(model.hotkeyBinding.displayName) + } } diff --git a/Sources/Sst/Strings.swift b/Sources/Sst/Strings.swift index 8751a09..cd3a51b 100644 --- a/Sources/Sst/Strings.swift +++ b/Sources/Sst/Strings.swift @@ -36,6 +36,10 @@ enum Strings { // note static let note = "Notitie" static let clear = "Wis" + static let edit = "Bewerk" + static let done = "Klaar" + static let transcript = "Transcriptie" + static let editingTranscriptHint = "Wijzigingen worden direct in de transcriptie bewaard." static func characters(_ count: Int) -> String { count == 1 ? "1 teken" : "\(count) tekens" } static func noteHint(_ keyName: String) -> String { "Zet de cursor in het vak en houd \(keyName) ingedrukt om hier te dicteren." } static let clearNoteConfirm = "Notitie wissen?" diff --git a/Sources/SstData/History.swift b/Sources/SstData/History.swift index eb888e5..adae5b1 100644 --- a/Sources/SstData/History.swift +++ b/Sources/SstData/History.swift @@ -108,6 +108,14 @@ public final class HistoryStore { changed() } + /// Replaces the final text of an entry. The highlighted corrections no longer match an edited text, so they are dropped. + public func updateText(id: UUID, to text: String) { + guard let index = entries.firstIndex(where: { $0.id == id }), entries[index].finalText != text else { return } + entries[index].finalText = text + entries[index].corrections = [] + changed() + } + public func removeAll() { entries = [] changed() diff --git a/Sources/SstData/ViewSupport.swift b/Sources/SstData/ViewSupport.swift index f7cfbd5..64c4085 100644 --- a/Sources/SstData/ViewSupport.swift +++ b/Sources/SstData/ViewSupport.swift @@ -78,6 +78,15 @@ public final class HistoryViewModel: ObservableObject { store.remove(id: id) refresh() } + + public func entry(id: UUID) -> HistoryEntry? { + store.entries.first { $0.id == id } + } + + public func updateText(id: UUID, to text: String) { + store.updateText(id: id, to: text) + refresh() + } } /// Dictionary rows with warnings, search, editing and undo. diff --git a/Tests/SstDataTests/HistoryTests.swift b/Tests/SstDataTests/HistoryTests.swift index 2571c8c..d521bb1 100644 --- a/Tests/SstDataTests/HistoryTests.swift +++ b/Tests/SstDataTests/HistoryTests.swift @@ -146,3 +146,24 @@ struct HistoryGroupingTests { #expect(days[0].day > days[1].day) } } + +@MainActor +struct HistoryEditTests { + @Test func updateTextReplacesFinalTextAndDropsCorrections() { + let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json")) + let item = HistoryEntry( + date: Date(), duration: 2, rawText: "cloud code", finalText: "Claude Code", + corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)]) + store.add(item) + store.updateText(id: item.id, to: "Claude Code is fijn") + #expect(store.entries[0].finalText == "Claude Code is fijn") + #expect(store.entries[0].rawText == "cloud code") + #expect(store.entries[0].corrections.isEmpty) + } + + @Test func updateTextOfUnknownIdIsHarmless() { + let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json")) + store.updateText(id: UUID(), to: "x") + #expect(store.entries.isEmpty) + } +}