Compare commits
10
Commits
f002dbe249
...
b225718fb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b225718fb1 | ||
|
|
951e23ba22 | ||
|
|
876ca92ae1 | ||
|
|
f390d310f4 | ||
|
|
ab7359156b | ||
|
|
31c34a1d4a | ||
|
|
5542f9cf70 | ||
|
|
d34d3e92da | ||
|
|
9a5794c1e0 | ||
|
|
6fe1f34df2 |
@@ -22,8 +22,10 @@ struct AppCommands: Commands {
|
||||
.keyboardShortcut("1", modifiers: .command)
|
||||
Button(Strings.dictionary) { show(.dictionary) }
|
||||
.keyboardShortcut("2", modifiers: .command)
|
||||
Button(Strings.note) { show(.note) }
|
||||
.keyboardShortcut("3", modifiers: .command)
|
||||
Button(Strings.search) {
|
||||
show(.transcripts)
|
||||
show(model.section)
|
||||
model.focusSearch()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
|
||||
@@ -14,4 +14,5 @@ enum AppPaths {
|
||||
}
|
||||
static var dictionary: URL { support.appending(path: "dictionary.txt") }
|
||||
static var history: URL { support.appending(path: "history.json") }
|
||||
static var note: URL { support.appending(path: "note.txt") }
|
||||
}
|
||||
|
||||
+66
-18
@@ -6,20 +6,24 @@ import SstData
|
||||
import SstWhisper
|
||||
import SwiftUI
|
||||
|
||||
/// Decides at insertion time whether dictated text may be pasted.
|
||||
/// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window.
|
||||
/// Decides at insertion time where dictated text goes.
|
||||
/// Sst in front: into the Notitie editor if (and only if) it has the cursor, otherwise nowhere (it would land in Sst's own
|
||||
/// search field). Another app in front: pasted there, except for recordings started in the window.
|
||||
final class InsertionPolicy {
|
||||
var windowSession = false
|
||||
|
||||
func allowed() -> Bool {
|
||||
!windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier
|
||||
@MainActor
|
||||
func route() -> InsertionRoute {
|
||||
let sstInFront = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier
|
||||
if sstInFront { return NoteEditorRegistry.shared.active != nil ? .editor : .none }
|
||||
return windowSession ? .none : .system
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
enum Section: String, CaseIterable, Identifiable {
|
||||
case transcripts, dictionary
|
||||
case transcripts, dictionary, note
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ final class AppModel: ObservableObject {
|
||||
let history: HistoryStore
|
||||
let dictionary: DictionaryStore
|
||||
let storage = ModelStorage()
|
||||
let note: NoteStore
|
||||
let historyList: HistoryViewModel
|
||||
let dictionaryList: DictionaryViewModel
|
||||
|
||||
@@ -47,10 +52,13 @@ final class AppModel: ObservableObject {
|
||||
@Published private(set) var installedVariants: Set<String> = []
|
||||
@Published private(set) var recordingStartedAt: Date?
|
||||
@Published private(set) var lastDuration: TimeInterval?
|
||||
@Published private(set) var loadingVariant: String?
|
||||
/// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh.
|
||||
@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
|
||||
@@ -61,7 +69,6 @@ final class AppModel: ObservableObject {
|
||||
private let policy: InsertionPolicy
|
||||
private let controller: DictationController
|
||||
private var modelTask: Task<Void, Never>?
|
||||
private var loadingVariant: String?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
convenience init() {
|
||||
@@ -77,6 +84,7 @@ final class AppModel: ObservableObject {
|
||||
let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary)
|
||||
let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history)
|
||||
let note = NoteStore(fileURL: preview ? folder.appending(path: "note.txt") : AppPaths.note)
|
||||
let levelSource = self.levelSource
|
||||
let recorder = AudioRecorder()
|
||||
recorder.onLevel = { levelSource.update($0) }
|
||||
@@ -86,15 +94,17 @@ final class AppModel: ObservableObject {
|
||||
self.corrector = corrector
|
||||
self.dictionary = dictionary
|
||||
self.history = history
|
||||
self.note = note
|
||||
self.historyList = HistoryViewModel(store: history)
|
||||
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
|
||||
self.controller = DictationController(
|
||||
recorder: recorder,
|
||||
transcriber: transcriber,
|
||||
cleaner: PassthroughCleaner(),
|
||||
inserter: GatedInserter(
|
||||
inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
|
||||
shouldInsert: { policy.allowed() }),
|
||||
inserter: RoutingInserter(
|
||||
system: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
|
||||
editor: EditorInserter(),
|
||||
route: { await policy.route() }),
|
||||
corrector: corrector,
|
||||
vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] })
|
||||
|
||||
@@ -123,6 +133,11 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `recordingBlocker`, plus: a window recording is only kept when the history is on.
|
||||
var windowRecordingBlocker: String? {
|
||||
recordingBlocker ?? (settings.keepHistory ? nil : Strings.historyNeededForWindow)
|
||||
}
|
||||
|
||||
var statusLine: String {
|
||||
if let blocker = recordingBlocker, state == .idle { return blocker }
|
||||
switch state {
|
||||
@@ -153,14 +168,19 @@ final class AppModel: ObservableObject {
|
||||
controller.onStateChange = { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.state = state
|
||||
if state != .recording, let started = self.recordingStartedAt {
|
||||
// The counter shows the recording length, not the time spent transcribing and pasting.
|
||||
self.lastDuration = Date().timeIntervalSince(started)
|
||||
self.recordingStartedAt = nil
|
||||
}
|
||||
switch state {
|
||||
case .recording:
|
||||
self.lastError = nil
|
||||
self.recordingStartedAt = Date()
|
||||
self.levelSource.reset()
|
||||
case .idle:
|
||||
if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) }
|
||||
self.recordingStartedAt = nil
|
||||
// Also covers a window recording that was ended with the hotkey; the paste decision is already made.
|
||||
self.policy.windowSession = false
|
||||
self.levelSource.reset()
|
||||
default: break
|
||||
}
|
||||
@@ -192,6 +212,9 @@ final class AppModel: ObservableObject {
|
||||
permissions.objectWillChange
|
||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
settings.objectWillChange
|
||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
dictionary.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -199,31 +222,49 @@ final class AppModel: ObservableObject {
|
||||
self.dictionaryList.reload()
|
||||
self.dictionaryRevision += 1
|
||||
}
|
||||
dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() }
|
||||
history.onChange = { [weak self] in
|
||||
self?.historyRevision += 1
|
||||
self?.historyList.refresh()
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in
|
||||
MainActor.assumeIsolated { history.flush() }
|
||||
// Backstop for editors that write in place, which the folder watcher can miss.
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() }
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history, note] _ in
|
||||
MainActor.assumeIsolated {
|
||||
history.flush()
|
||||
note.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func start() async {
|
||||
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
||||
dictionary.startWatching()
|
||||
if case .recoveredCorrupt = history.load() {
|
||||
note.load()
|
||||
switch history.load() {
|
||||
case .recoveredCorrupt:
|
||||
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
||||
case .unreadable:
|
||||
lastError = Strings.historyUnreadable
|
||||
case .empty, .loaded:
|
||||
break
|
||||
}
|
||||
refreshInstalled()
|
||||
if !ModelCatalog.all.contains(where: { $0.id == settings.modelVariant }) {
|
||||
settings.modelVariant = ModelCatalog.defaultVariant
|
||||
}
|
||||
activateModel(settings.modelVariant) // the download must not wait for the microphone prompt
|
||||
await permissions.requestMissing()
|
||||
retryHotkey()
|
||||
activateModel(settings.modelVariant)
|
||||
}
|
||||
|
||||
func retryHotkey() {
|
||||
permissions.refresh()
|
||||
do {
|
||||
try hotkey.start()
|
||||
if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil }
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
@@ -232,7 +273,7 @@ final class AppModel: ObservableObject {
|
||||
// MARK: recording
|
||||
|
||||
private func handleHotkey(_ event: HotkeyEvent) {
|
||||
guard modelReady else { return }
|
||||
guard event == .released || modelReady else { return }
|
||||
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
||||
lastError = Strings.microphoneMissing
|
||||
return
|
||||
@@ -250,7 +291,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func startWindowRecording() {
|
||||
guard recordingBlocker == nil, state == .idle else { return }
|
||||
guard windowRecordingBlocker == nil, state == .idle else { return }
|
||||
policy.windowSession = true
|
||||
Task {
|
||||
await controller.handle(.pressed)
|
||||
@@ -305,7 +346,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func deleteModel(_ variant: String) {
|
||||
guard variant != activeVariant else { return }
|
||||
guard variant != activeVariant, variant != loadingVariant else { return }
|
||||
do {
|
||||
try storage.delete(variant)
|
||||
} catch {
|
||||
@@ -336,7 +377,14 @@ 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()
|
||||
try? dictionary.apply { file in
|
||||
file.append(.term("Anthropic"))
|
||||
|
||||
@@ -47,8 +47,8 @@ struct DeckView: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
|
||||
.disabled(!model.isRecording && model.recordingBlocker != nil)
|
||||
.help(model.recordingBlocker ?? "")
|
||||
.disabled(!model.isRecording && model.windowRecordingBlocker != nil)
|
||||
.help(model.windowRecordingBlocker ?? "")
|
||||
|
||||
Button(Strings.stop) { model.stopWindowRecording() }
|
||||
.buttonStyle(KeyButtonStyle())
|
||||
|
||||
@@ -2,8 +2,262 @@ import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
// Temporary: replaced in Task 5.
|
||||
struct DictionaryView: View {
|
||||
enum EditorTarget: Identifiable, Equatable {
|
||||
case new
|
||||
case existing(UUID)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .new: "new"
|
||||
case .existing(let id): id.uuidString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@ObservedObject var list: DictionaryViewModel
|
||||
var body: some View { PaperBackground() }
|
||||
@State private var selection: UUID?
|
||||
@State private var editing: EditorTarget?
|
||||
@FocusState private var searchFocused: Bool
|
||||
@FocusState private var listFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Layout.panelGap) {
|
||||
header
|
||||
ZStack {
|
||||
PaperBackground()
|
||||
table
|
||||
}
|
||||
footer
|
||||
}
|
||||
.sheet(item: $editing) { target in
|
||||
EntryEditor(list: list, target: target) { editing = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: Layout.panelGap) {
|
||||
HStack(spacing: Space.s4) {
|
||||
SearchField(text: $list.query, focused: $searchFocused)
|
||||
Button(Strings.add) { editing = .new }.buttonStyle(KeyButtonStyle())
|
||||
Button(Strings.openFile) { model.openDictionaryFile() }.buttonStyle(KeyButtonStyle())
|
||||
}
|
||||
HStack(spacing: Layout.panelGap) {
|
||||
Toggle(isOn: $settings.usePriming) {
|
||||
SilkLabel(Strings.priming)
|
||||
}
|
||||
.toggleStyle(RockerToggleStyle())
|
||||
Toggle(isOn: $settings.useCorrection) {
|
||||
SilkLabel(Strings.correcting)
|
||||
}
|
||||
.toggleStyle(RockerToggleStyle())
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var table: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: Space.s4) {
|
||||
Color.clear.frame(width: Size.iconMedium, height: Size.iconMedium)
|
||||
SilkLabel(Strings.heard).frame(maxWidth: .infinity, alignment: .leading)
|
||||
SilkLabel(Strings.wrote).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, Space.s4)
|
||||
.padding(.vertical, Space.s3)
|
||||
if list.visibleRows.isEmpty {
|
||||
Text(list.rows.isEmpty ? Strings.emptyDictionary : Strings.noResults)
|
||||
.textStyle(Typography.body)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
.padding(Layout.panelPadding)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
} else {
|
||||
ScrollList {
|
||||
ForEach(list.visibleRows) { row in
|
||||
DictionaryRow(row: row, isSelected: selection == row.id)
|
||||
.onTapGesture(count: 2) { editing = .existing(row.id) }
|
||||
.onTapGesture {
|
||||
selection = row.id
|
||||
listFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.focused($listFocused)
|
||||
.focusEffectDisabled()
|
||||
.onDeleteCommand {
|
||||
if let id = selection {
|
||||
list.remove(id: id)
|
||||
selection = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
||||
.onChange(of: list.rows) { _, rows in
|
||||
if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil }
|
||||
}
|
||||
.onKeyPress(KeyEquivalent("z"), phases: .down) { press in
|
||||
guard press.modifiers == .command, list.canUndo else { return .ignored }
|
||||
list.undo()
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: Layout.panelGap) {
|
||||
SilkLabel(Strings.entries(list.rows.count), style: Typography.labelSmall)
|
||||
if let message = list.errorMessage {
|
||||
Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(Strings.undo) { list.undo() }
|
||||
.buttonStyle(KeyButtonStyle())
|
||||
.disabled(!list.canUndo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DictionaryRow: View {
|
||||
let row: DictionaryViewModel.Row
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Space.s4) {
|
||||
lamp
|
||||
.frame(width: Size.iconMedium, height: Size.iconMedium)
|
||||
if let heard = heard {
|
||||
Text(heard).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text("—").textStyle(Typography.code).foregroundStyle(Palette.inkSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Text(row.entry.wrote).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, Space.s4)
|
||||
.padding(.vertical, Space.s3)
|
||||
.frame(minHeight: Size.rowHeight)
|
||||
.background(isSelected ? Palette.surfaceSelection : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.help(row.warnings.map(\.message).joined(separator: "\n"))
|
||||
}
|
||||
|
||||
private var heard: String? {
|
||||
if case .correction(let heard, _) = row.entry.kind { return heard }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var lamp: some View {
|
||||
switch row.worst {
|
||||
case .error: LampView(isOn: true)
|
||||
case .warning: LampView(isOn: true, tint: .amber)
|
||||
default: LampView(isOn: false, tint: .amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or edit one entry, with live warnings.
|
||||
private struct EntryEditor: View {
|
||||
@ObservedObject var list: DictionaryViewModel
|
||||
let target: DictionaryView.EditorTarget
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var isCorrection = false
|
||||
@State private var heard = ""
|
||||
@State private var wrote = ""
|
||||
|
||||
private var editingID: UUID? {
|
||||
if case .existing(let id) = target { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var kind: DictionaryEntry.Kind {
|
||||
let write = wrote.trimmingCharacters(in: .whitespaces)
|
||||
return isCorrection ? .correction(heard: heard.trimmingCharacters(in: .whitespaces), wrote: write) : .term(write)
|
||||
}
|
||||
|
||||
private var warnings: [EntryWarning] {
|
||||
let emptySource = isCorrection ? heard.trimmingCharacters(in: .whitespaces).isEmpty : wrote.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
guard !emptySource, !wrote.trimmingCharacters(in: .whitespaces).isEmpty else { return [] }
|
||||
return list.warnings(for: kind, editing: editingID)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let hasText = !wrote.trimmingCharacters(in: .whitespaces).isEmpty && (!isCorrection || !heard.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
return hasText && !warnings.contains { $0.level == .error }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
BrushedBackground().ignoresSafeArea()
|
||||
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||
HStack(spacing: Space.s3) {
|
||||
Button(Strings.term) { isCorrection = false }.buttonStyle(KeyButtonStyle(latched: !isCorrection))
|
||||
Button(Strings.correction) { isCorrection = true }.buttonStyle(KeyButtonStyle(latched: isCorrection))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||
if isCorrection { field(Strings.heard, text: $heard) }
|
||||
field(Strings.wrote, text: $wrote)
|
||||
ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in
|
||||
HStack(alignment: .firstTextBaseline, spacing: Space.s3) {
|
||||
LampView(isOn: warning.level != .note, tint: warning.level == .error ? .record : .amber)
|
||||
Text(warning.message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(PanelBackground())
|
||||
HStack(spacing: Space.s3) {
|
||||
Spacer()
|
||||
Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle())
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
}
|
||||
.frame(width: Layout.settingsWidth)
|
||||
.onAppear(perform: load)
|
||||
}
|
||||
|
||||
private func field(_ title: String, text: Binding<String>) -> some View {
|
||||
HStack(spacing: Space.s4) {
|
||||
SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading)
|
||||
TextField("", text: text)
|
||||
.textFieldStyle(.plain)
|
||||
.textStyle(Typography.code)
|
||||
.foregroundStyle(Palette.inkPrimary)
|
||||
.padding(Space.s3)
|
||||
.background(WellBackground())
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return }
|
||||
switch row.entry.kind {
|
||||
case .term(let term): (isCorrection, heard, wrote) = (false, "", term)
|
||||
case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w)
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let id = editingID {
|
||||
guard list.rows.contains(where: { $0.id == id }) else {
|
||||
list.errorMessage = Strings.entryGone
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
list.update(id: id, to: kind)
|
||||
} else {
|
||||
list.add(kind)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,136 @@
|
||||
import AppKit
|
||||
import SstCore
|
||||
import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
// Temporary: replaced in Task 4.
|
||||
private let dutch = Locale(identifier: "nl_NL")
|
||||
|
||||
struct HistoryView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@ObservedObject var list: HistoryViewModel
|
||||
var body: some View { PaperBackground() }
|
||||
@FocusState private var searchFocused: Bool
|
||||
@FocusState private var listFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Layout.panelGap) {
|
||||
SearchField(text: $list.query, focused: $searchFocused)
|
||||
ZStack {
|
||||
PaperBackground()
|
||||
content
|
||||
}
|
||||
}
|
||||
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if list.days.isEmpty {
|
||||
Text(emptyMessage)
|
||||
.textStyle(Typography.body)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
.padding(Layout.panelPadding)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
} else {
|
||||
ScrollList {
|
||||
ForEach(list.days) { day in
|
||||
Section {
|
||||
ForEach(day.entries) { entry in
|
||||
HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list)
|
||||
}
|
||||
} header: {
|
||||
SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch)))
|
||||
.padding(.horizontal, Space.s4)
|
||||
.padding(.vertical, Space.s3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Palette.surfacePaper)
|
||||
}
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.focused($listFocused)
|
||||
.focusEffectDisabled()
|
||||
.onChange(of: list.selection) { _, new in if new != nil { listFocused = true } }
|
||||
.onDeleteCommand {
|
||||
if let id = list.selection { list.delete(id: id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyMessage: String {
|
||||
if list.totalCount > 0 { return Strings.noResults }
|
||||
return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff
|
||||
}
|
||||
}
|
||||
|
||||
private struct HistoryRow: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
let entry: HistoryEntry
|
||||
let isSelected: Bool
|
||||
@ObservedObject var list: HistoryViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
|
||||
Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch)))
|
||||
.textStyle(Typography.counterSmall)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
VStack(alignment: .leading, spacing: Space.s2) {
|
||||
Text(markedText)
|
||||
.textStyle(Typography.reading)
|
||||
.foregroundStyle(Palette.inkPrimary)
|
||||
.textSelection(.enabled)
|
||||
if !entry.corrections.isEmpty {
|
||||
SilkLabel(correctionSummary, style: Typography.labelSmall)
|
||||
}
|
||||
if isSelected && entry.rawText != entry.finalText {
|
||||
Text("\(Strings.original): \(entry.rawText)")
|
||||
.textStyle(Typography.meta)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
}
|
||||
}
|
||||
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)
|
||||
.frame(minHeight: Size.rowHeight)
|
||||
.background(isSelected ? Palette.surfaceSelection : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { list.selection = entry.id }
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The final text with every dictionary replacement highlighted.
|
||||
private var markedText: AttributedString {
|
||||
var result = AttributedString()
|
||||
for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) {
|
||||
var piece = AttributedString(segment.text)
|
||||
if segment.correction != nil {
|
||||
piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark)
|
||||
}
|
||||
result += piece
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private var correctionSummary: String {
|
||||
let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ")
|
||||
return "\(Strings.corrections(entry.corrections.count)) · \(changes)"
|
||||
}
|
||||
|
||||
private func copy(_ text: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct MainWindow: View {
|
||||
switch model.section {
|
||||
case .transcripts: HistoryView(list: model.historyList)
|
||||
case .dictionary: DictionaryView(list: model.dictionaryList)
|
||||
case .note: NoteView(note: model.note)
|
||||
}
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import AppKit
|
||||
import SstCore
|
||||
import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
/// Text view of the Notitie editor; registers itself while it has the cursor.
|
||||
final class NoteTextView: NSTextView {
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let accepted = super.becomeFirstResponder()
|
||||
if accepted { NoteEditorRegistry.shared.focused = self }
|
||||
return accepted
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let accepted = super.resignFirstResponder()
|
||||
if accepted, NoteEditorRegistry.shared.focused === self { NoteEditorRegistry.shared.focused = nil }
|
||||
return accepted
|
||||
}
|
||||
|
||||
/// Types dictated text at the cursor (replacing a selection) through the normal edit path, so undo works.
|
||||
func insertDictation(_ text: String) {
|
||||
let range = selectedRange()
|
||||
let ns = string as NSString
|
||||
var before: Character?
|
||||
if range.location > 0, range.location <= ns.length {
|
||||
before = ns.substring(with: ns.rangeOfComposedCharacterSequence(at: range.location - 1)).first
|
||||
}
|
||||
let prepared = DictationSpacing.prepared(text, characterBefore: before)
|
||||
if shouldChangeText(in: range, replacementString: prepared) {
|
||||
insertText(prepared, replacementRange: range)
|
||||
}
|
||||
scrollRangeToVisible(selectedRange())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NoteEditorRegistry {
|
||||
static let shared = NoteEditorRegistry()
|
||||
weak var focused: NoteTextView?
|
||||
|
||||
/// The editor, but only while it really has the cursor in the key window.
|
||||
var active: NoteTextView? {
|
||||
guard let view = focused, let window = view.window, window.isKeyWindow, window.firstResponder === view else { return nil }
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends dictated text into the Notitie editor.
|
||||
struct EditorInserter: TextInserting {
|
||||
func insert(_ text: String) async throws {
|
||||
await MainActor.run { NoteEditorRegistry.shared.active?.insertDictation(text) }
|
||||
}
|
||||
}
|
||||
|
||||
struct NoteEditor: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var isEditable: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scroll = NSScrollView()
|
||||
scroll.drawsBackground = false
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.borderType = .noBorder
|
||||
|
||||
let view = NoteTextView(frame: .zero)
|
||||
view.minSize = .zero
|
||||
view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
view.isVerticallyResizable = true
|
||||
view.isHorizontallyResizable = false
|
||||
view.autoresizingMask = [.width]
|
||||
view.textContainer?.widthTracksTextView = true
|
||||
view.textContainerInset = NSSize(width: Space.s5, height: Space.s5)
|
||||
view.isRichText = false
|
||||
view.allowsUndo = true
|
||||
view.drawsBackground = false
|
||||
view.isAutomaticQuoteSubstitutionEnabled = false
|
||||
view.isAutomaticDashSubstitutionEnabled = false
|
||||
view.delegate = context.coordinator
|
||||
view.string = text
|
||||
scroll.documentView = view
|
||||
context.coordinator.view = view
|
||||
apply(to: view)
|
||||
return scroll
|
||||
}
|
||||
|
||||
func updateNSView(_ scroll: NSScrollView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
guard let view = context.coordinator.view else { return }
|
||||
if view.string != text, !view.hasMarkedText() {
|
||||
let selection = view.selectedRange()
|
||||
view.string = text
|
||||
let length = (text as NSString).length
|
||||
view.setSelectedRange(NSRange(location: min(selection.location, length), length: 0))
|
||||
}
|
||||
view.isEditable = isEditable
|
||||
apply(to: view)
|
||||
}
|
||||
|
||||
/// Font and colours come from the design tokens; colours follow the current appearance.
|
||||
private func apply(to view: NoteTextView) {
|
||||
let style = Typography.reading
|
||||
let font = NSFont.systemFont(ofSize: style.size, weight: .regular)
|
||||
view.font = font
|
||||
view.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||
let ink = NSColor(Palette.inkPrimary)
|
||||
view.textColor = ink
|
||||
view.insertionPointColor = ink
|
||||
}
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = max(0, style.line - style.size * 1.2)
|
||||
view.defaultParagraphStyle = paragraph
|
||||
view.typingAttributes = [.font: font, .foregroundColor: view.textColor ?? .labelColor, .paragraphStyle: paragraph]
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: NoteEditor
|
||||
weak var view: NoteTextView?
|
||||
|
||||
init(_ parent: NoteEditor) { self.parent = parent }
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let view else { return }
|
||||
parent.text = view.string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import AppKit
|
||||
import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
struct NoteView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@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<String> {
|
||||
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
|
||||
ZStack {
|
||||
PaperBackground()
|
||||
if SnapshotMode.isActive {
|
||||
// ImageRenderer cannot draw an NSView; the snapshot shows the text instead.
|
||||
Text(text.wrappedValue)
|
||||
.textStyle(Typography.reading)
|
||||
.foregroundStyle(Palette.inkPrimary)
|
||||
.padding(Space.s5)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
} else {
|
||||
NoteEditor(text: text, isEditable: editedEntry != nil || !note.isReadOnly)
|
||||
.id(editedEntry?.id)
|
||||
}
|
||||
}
|
||||
footer
|
||||
}
|
||||
.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) {
|
||||
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(text.wrappedValue.count))
|
||||
.textStyle(Typography.counterSmall)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
Button(Strings.copy) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text.wrappedValue, forType: .string)
|
||||
}
|
||||
.buttonStyle(KeyButtonStyle())
|
||||
.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(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)
|
||||
}
|
||||
}
|
||||
@@ -185,9 +185,12 @@ private struct ModelSettingsView: View {
|
||||
Spacer()
|
||||
if installed && !isActive {
|
||||
Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||
.disabled(model.loadingVariant != nil)
|
||||
Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||
.disabled(model.loadingVariant != nil)
|
||||
} else if !installed {
|
||||
Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||
.disabled(model.loadingVariant != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct Sidebar: View {
|
||||
switch section {
|
||||
case .transcripts: Strings.transcripts
|
||||
case .dictionary: Strings.dictionary
|
||||
case .note: Strings.note
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,21 @@ enum Strings {
|
||||
static let undo = "Herstel"
|
||||
static let save = "Bewaar"
|
||||
static let editEntry = "Invoer"
|
||||
static let entryGone = "Deze invoer bestaat niet meer; je wijziging is niet bewaard."
|
||||
static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +."
|
||||
|
||||
// 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?"
|
||||
static let noteUnreadable = "De notitie kon niet worden gelezen; wijzigingen worden niet bewaard."
|
||||
|
||||
// deck
|
||||
static let record = "Rec"
|
||||
static let stop = "Stop"
|
||||
@@ -66,6 +79,8 @@ enum Strings {
|
||||
static let microphoneMissing = "Microfoon-toegang ontbreekt"
|
||||
static let openSystemSettings = "Open Instellingen"
|
||||
static let open = "Open"
|
||||
static let historyNeededForWindow = "Zet ‘Transcripties bewaren’ aan om vanuit het venster op te nemen."
|
||||
static let historyUnreadable = "Het geschiedenisbestand kon niet worden gelezen; nieuwe transcripties worden niet bewaard."
|
||||
static let permissionsMissing = "Geef eerst de rechten onder Controles"
|
||||
|
||||
// settings
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings).
|
||||
public struct GatedInserter: TextInserting {
|
||||
private let inner: TextInserting
|
||||
private let shouldInsert: () -> Bool
|
||||
/// Where one dictation's text goes.
|
||||
public enum InsertionRoute: Equatable, Sendable {
|
||||
/// Pasted into whichever app is in front.
|
||||
case system
|
||||
/// Typed into the Notitie editor inside Sst.
|
||||
case editor
|
||||
/// Dropped (Sst is in front but nothing there takes text, or the recording started in the window).
|
||||
case none
|
||||
}
|
||||
|
||||
public init(inner: TextInserting, shouldInsert: @escaping () -> Bool) {
|
||||
self.inner = inner
|
||||
self.shouldInsert = shouldInsert
|
||||
/// Sends each dictation to the system paster or the in-app editor, asked at insertion time rather than at creation.
|
||||
public struct RoutingInserter: TextInserting {
|
||||
private let system: TextInserting
|
||||
private let editor: TextInserting
|
||||
private let route: () async -> InsertionRoute
|
||||
|
||||
public init(system: TextInserting, editor: TextInserting, route: @escaping () async -> InsertionRoute) {
|
||||
self.system = system
|
||||
self.editor = editor
|
||||
self.route = route
|
||||
}
|
||||
|
||||
public func insert(_ text: String) async throws {
|
||||
guard shouldInsert() else { return }
|
||||
try await inner.insert(text)
|
||||
switch await route() {
|
||||
case .system: try await system.insert(text)
|
||||
case .editor: try await editor.insert(text)
|
||||
case .none: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,21 @@ public struct DictionaryFile: Equatable, Sendable {
|
||||
return .entry(DictionaryEntry(kind: .term(trimmed)))
|
||||
}
|
||||
|
||||
/// Reuses the ids of `old` for entries with equal content (first unused match, in order), so ids survive a reload.
|
||||
public func preservingIDs(from old: DictionaryFile) -> DictionaryFile {
|
||||
var available = old.entries
|
||||
var result = self
|
||||
result.lines = lines.map { line in
|
||||
guard case .entry(let entry) = line else { return line }
|
||||
if let index = available.firstIndex(where: { $0.kind == entry.kind }) {
|
||||
let reused = available.remove(at: index)
|
||||
return .entry(DictionaryEntry(id: reused.id, kind: entry.kind))
|
||||
}
|
||||
return line
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func serialized() -> String {
|
||||
let out = lines.map { line -> String in
|
||||
switch line {
|
||||
|
||||
@@ -14,6 +14,8 @@ public final class DictionaryStore {
|
||||
public let fileURL: URL
|
||||
public private(set) var file = DictionaryFile()
|
||||
public var onChange: (() -> Void)?
|
||||
/// Fired (after `onChange`) when the file was changed by something other than this store.
|
||||
public var onExternalChange: (() -> Void)?
|
||||
|
||||
private var lastText = ""
|
||||
private var watcher: DispatchSourceFileSystemObject?
|
||||
@@ -53,8 +55,9 @@ public final class DictionaryStore {
|
||||
public func reloadIfChanged() {
|
||||
guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return }
|
||||
lastText = text
|
||||
file = DictionaryFile(parsing: text)
|
||||
file = DictionaryFile(parsing: text).preservingIDs(from: file)
|
||||
onChange?()
|
||||
onExternalChange?()
|
||||
}
|
||||
|
||||
/// Watches the folder (editors replace files instead of editing them in place).
|
||||
|
||||
@@ -35,6 +35,8 @@ public final class HistoryStore {
|
||||
case empty
|
||||
case loaded
|
||||
case recoveredCorrupt(backup: URL)
|
||||
/// The file exists but cannot be read; it is left alone and nothing is written over it.
|
||||
case unreadable(String)
|
||||
}
|
||||
|
||||
private struct Document: Codable {
|
||||
@@ -47,6 +49,7 @@ public final class HistoryStore {
|
||||
public var onChange: (() -> Void)?
|
||||
|
||||
private let fileURL: URL
|
||||
private var readOnly = false
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
public init(fileURL: URL) {
|
||||
@@ -68,18 +71,29 @@ public final class HistoryStore {
|
||||
|
||||
@discardableResult
|
||||
public func load() -> LoadOutcome {
|
||||
guard let data = try? Data(contentsOf: fileURL) else {
|
||||
entries = []
|
||||
return .empty
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
entries = []
|
||||
onChange?()
|
||||
return .empty
|
||||
}
|
||||
readOnly = true
|
||||
lastError = error.localizedDescription
|
||||
return .unreadable(error.localizedDescription)
|
||||
}
|
||||
do {
|
||||
entries = try Self.makeDecoder().decode(Document.self, from: data).entries
|
||||
onChange?()
|
||||
return .loaded
|
||||
} catch {
|
||||
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
||||
let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json")
|
||||
try? FileManager.default.moveItem(at: fileURL, to: backup)
|
||||
entries = []
|
||||
onChange?()
|
||||
return .recoveredCorrupt(backup: backup)
|
||||
}
|
||||
}
|
||||
@@ -94,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()
|
||||
@@ -103,6 +125,7 @@ public final class HistoryStore {
|
||||
public func flush() {
|
||||
saveTask?.cancel()
|
||||
saveTask = nil
|
||||
guard !readOnly else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
/// The text of the Notitie editor, kept in one plain file. Saves are debounced and atomic.
|
||||
@MainActor
|
||||
public final class NoteStore: ObservableObject {
|
||||
@Published public var text: String = "" {
|
||||
didSet {
|
||||
guard text != oldValue, !loading else { return }
|
||||
changed()
|
||||
}
|
||||
}
|
||||
public private(set) var lastError: String?
|
||||
/// True when the file exists but could not be read: saving would overwrite the user's text, so it is switched off.
|
||||
public private(set) var isReadOnly = false
|
||||
|
||||
private let fileURL: URL
|
||||
private var saveTask: Task<Void, Never>?
|
||||
private var loading = false
|
||||
|
||||
public init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
}
|
||||
|
||||
public func load() {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
do {
|
||||
text = try String(contentsOf: fileURL, encoding: .utf8)
|
||||
} catch {
|
||||
isReadOnly = true
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes immediately (also call this when the app quits).
|
||||
public func flush() {
|
||||
saveTask?.cancel()
|
||||
saveTask = nil
|
||||
guard !isReadOnly else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func changed() {
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a space between dictations: the text before the cursor decides whether one is needed.
|
||||
public enum DictationSpacing {
|
||||
public static func prepared(_ text: String, characterBefore: Character?) -> String {
|
||||
guard let before = characterBefore, !before.isWhitespace, let first = text.first else { return text }
|
||||
if first.isWhitespace || ".,;:!?)]}\u{201D}\u{2019}".contains(first) { return text }
|
||||
return " " + text
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -134,7 +143,14 @@ public final class DictionaryViewModel: ObservableObject {
|
||||
|
||||
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
|
||||
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
|
||||
EntryWarnings.check(kind, among: store.entries.filter { $0.id != id }, knowledge: knowledge)
|
||||
let entries = store.entries
|
||||
let others: [DictionaryEntry]
|
||||
if let id, let index = entries.firstIndex(where: { $0.id == id }) {
|
||||
others = Array(entries[..<index])
|
||||
} else {
|
||||
others = entries
|
||||
}
|
||||
return EntryWarnings.check(kind, among: others, knowledge: knowledge)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -149,20 +165,28 @@ public final class DictionaryViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
public func remove(id: UUID) {
|
||||
guard store.entries.contains(where: { $0.id == id }) else { return }
|
||||
mutate { $0.remove(id: id) }
|
||||
}
|
||||
|
||||
public func undo() {
|
||||
guard let snapshot = undoStack.popLast() else { return }
|
||||
canUndo = !undoStack.isEmpty
|
||||
guard let snapshot = undoStack.last else { return }
|
||||
do {
|
||||
try store.apply { $0 = snapshot }
|
||||
undoStack.removeLast()
|
||||
canUndo = !undoStack.isEmpty
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
/// The file was edited elsewhere: older snapshots no longer describe it, so undo is dropped.
|
||||
public func externalChangeDetected() {
|
||||
undoStack.removeAll()
|
||||
canUndo = false
|
||||
}
|
||||
|
||||
private func mutate(_ change: (inout DictionaryFile) -> Void) {
|
||||
let snapshot = store.file
|
||||
do {
|
||||
|
||||
@@ -2,26 +2,44 @@ import Foundation
|
||||
import Testing
|
||||
@testable import SstCore
|
||||
|
||||
struct GatedInserterTests {
|
||||
@Test func insertsWhenAllowed() async throws {
|
||||
let inner = FakeInserter()
|
||||
try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo")
|
||||
#expect(inner.inserted == ["hallo"])
|
||||
struct RoutingInserterTests {
|
||||
@Test func sendsToTheSystemRoute() async throws {
|
||||
let system = FakeInserter(), editor = FakeInserter()
|
||||
try await RoutingInserter(system: system, editor: editor, route: { .system }).insert("hallo")
|
||||
#expect(system.inserted == ["hallo"])
|
||||
#expect(editor.inserted.isEmpty)
|
||||
}
|
||||
|
||||
@Test func staysSilentWhenNotAllowed() async throws {
|
||||
let inner = FakeInserter()
|
||||
try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo")
|
||||
#expect(inner.inserted.isEmpty)
|
||||
@Test func sendsToTheEditorRoute() async throws {
|
||||
let system = FakeInserter(), editor = FakeInserter()
|
||||
try await RoutingInserter(system: system, editor: editor, route: { .editor }).insert("hallo")
|
||||
#expect(editor.inserted == ["hallo"])
|
||||
#expect(system.inserted.isEmpty)
|
||||
}
|
||||
|
||||
@Test func dropsTheTextOnNone() async throws {
|
||||
let system = FakeInserter(), editor = FakeInserter()
|
||||
try await RoutingInserter(system: system, editor: editor, route: { .none }).insert("hallo")
|
||||
#expect(system.inserted.isEmpty)
|
||||
#expect(editor.inserted.isEmpty)
|
||||
}
|
||||
|
||||
@Test func askedAtInsertionTimeNotAtCreation() async throws {
|
||||
let inner = FakeInserter()
|
||||
var allowed = false
|
||||
let gated = GatedInserter(inner: inner, shouldInsert: { allowed })
|
||||
allowed = true
|
||||
try await gated.insert("nu wel")
|
||||
#expect(inner.inserted == ["nu wel"])
|
||||
let system = FakeInserter(), editor = FakeInserter()
|
||||
var current = InsertionRoute.none
|
||||
let router = RoutingInserter(system: system, editor: editor, route: { current })
|
||||
current = .editor
|
||||
try await router.insert("nu wel")
|
||||
#expect(editor.inserted == ["nu wel"])
|
||||
}
|
||||
|
||||
@Test func propagatesInserterErrors() async {
|
||||
struct Boom: Error {}
|
||||
let system = FakeInserter()
|
||||
system.error = Boom()
|
||||
await #expect(throws: Boom.self) {
|
||||
try await RoutingInserter(system: system, editor: FakeInserter(), route: { .system }).insert("x")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,23 @@ struct DictionaryFileTests {
|
||||
#expect(file.entries.isEmpty)
|
||||
#expect(file.serialized() == "# c\n")
|
||||
}
|
||||
|
||||
@Test func preservingIDsKeepsIdsOfUnchangedLines() {
|
||||
let old = DictionaryFile(parsing: "a\nb => c\nd\n")
|
||||
let new = DictionaryFile(parsing: "z\na\nb => c\nd2\n").preservingIDs(from: old)
|
||||
let oldIDs = old.entries.map(\.id), newIDs = new.entries.map(\.id)
|
||||
#expect(newIDs[1] == oldIDs[0])
|
||||
#expect(newIDs[2] == oldIDs[1])
|
||||
#expect(!oldIDs.contains(newIDs[0]))
|
||||
#expect(!oldIDs.contains(newIDs[3]))
|
||||
#expect(new.serialized() == "z\na\nb => c\nd2\n")
|
||||
}
|
||||
|
||||
@Test func preservingIDsMapsDuplicatesOneToOneInOrder() {
|
||||
let old = DictionaryFile(parsing: "a\na\n")
|
||||
let new = DictionaryFile(parsing: "a\na\na\n").preservingIDs(from: old)
|
||||
let oldIDs = old.entries.map(\.id), newIDs = new.entries.map(\.id)
|
||||
#expect(Array(newIDs[0...1]) == oldIDs)
|
||||
#expect(!oldIDs.contains(newIDs[2]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,23 @@ struct DictionaryStoreTests {
|
||||
#expect(changes == 1)
|
||||
}
|
||||
|
||||
@Test func externalChangeFiresBothCallbacksAndKeepsIDs() throws {
|
||||
let url = tempFile()
|
||||
let store = DictionaryStore(fileURL: url)
|
||||
try store.load()
|
||||
try store.apply { $0.append(.term("Vercel")) }
|
||||
let id = store.entries[0].id
|
||||
var changes = 0, external = 0
|
||||
store.onChange = { changes += 1 }
|
||||
store.onExternalChange = { external += 1 }
|
||||
try "Nieuw\nVercel\n".write(to: url, atomically: true, encoding: .utf8)
|
||||
store.reloadIfChanged()
|
||||
#expect(changes == 1 && external == 1)
|
||||
#expect(store.entries[1].id == id)
|
||||
try store.apply { $0.append(.term("Eigen")) }
|
||||
#expect(changes == 2 && external == 1)
|
||||
}
|
||||
|
||||
@Test func ourOwnWriteDoesNotTriggerAReload() throws {
|
||||
let store = DictionaryStore(fileURL: tempFile())
|
||||
try store.load()
|
||||
|
||||
@@ -57,6 +57,35 @@ struct HistoryStoreTests {
|
||||
#expect(store.entries.isEmpty)
|
||||
}
|
||||
|
||||
@Test func loadNotifiesOnChange() {
|
||||
let url = tempFile()
|
||||
let writer = HistoryStore(fileURL: url)
|
||||
writer.add(entry("a", at: 0)); writer.add(entry("b", at: 1))
|
||||
writer.flush()
|
||||
|
||||
let store = HistoryStore(fileURL: url)
|
||||
var calls = 0
|
||||
store.onChange = { calls += 1 }
|
||||
#expect(store.load() == .loaded)
|
||||
#expect(calls == 1)
|
||||
#expect(store.entries.count == 2)
|
||||
}
|
||||
|
||||
@Test func unreadableFileIsReportedAndNeverOverwritten() throws {
|
||||
let url = tempFile()
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) // history.json is a directory
|
||||
let store = HistoryStore(fileURL: url)
|
||||
guard case .unreadable = store.load() else {
|
||||
Issue.record("expected unreadable"); return
|
||||
}
|
||||
store.add(entry("a", at: 0))
|
||||
store.flush()
|
||||
var isDirectory: ObjCBool = false
|
||||
#expect(FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory))
|
||||
#expect(isDirectory.boolValue)
|
||||
#expect(store.lastError != nil)
|
||||
}
|
||||
|
||||
@Test func onChangeFiresForEdits() {
|
||||
let store = HistoryStore(fileURL: tempFile())
|
||||
var calls = 0
|
||||
@@ -117,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import SstData
|
||||
|
||||
@MainActor
|
||||
struct NoteStoreTests {
|
||||
private func temp() -> URL {
|
||||
FileManager.default.temporaryDirectory.appending(path: "note-\(UUID().uuidString)/note.txt")
|
||||
}
|
||||
|
||||
@Test func missingFileLoadsEmpty() {
|
||||
let store = NoteStore(fileURL: temp())
|
||||
store.load()
|
||||
#expect(store.text == "")
|
||||
#expect(!store.isReadOnly)
|
||||
}
|
||||
|
||||
@Test func flushWritesAndAnotherStoreReadsIt() {
|
||||
let url = temp()
|
||||
let store = NoteStore(fileURL: url)
|
||||
store.text = "eerste regel\nté"
|
||||
store.flush()
|
||||
let other = NoteStore(fileURL: url)
|
||||
other.load()
|
||||
#expect(other.text == "eerste regel\nté")
|
||||
}
|
||||
|
||||
@Test func editsAreSavedAfterTheDebounce() async throws {
|
||||
let url = temp()
|
||||
let store = NoteStore(fileURL: url)
|
||||
store.text = "bewaard"
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
try await Task.sleep(for: .milliseconds(900))
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "bewaard")
|
||||
}
|
||||
|
||||
@Test func loadingDoesNotTriggerASave() async throws {
|
||||
let url = temp()
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try "oud".write(to: url, atomically: true, encoding: .utf8)
|
||||
let store = NoteStore(fileURL: url)
|
||||
store.load()
|
||||
#expect(store.text == "oud")
|
||||
try FileManager.default.removeItem(at: url)
|
||||
try await Task.sleep(for: .milliseconds(900))
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
@Test func unreadableFileIsNeverOverwritten() throws {
|
||||
let url = temp()
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data([0xFF, 0xFE, 0xFD]).write(to: url)
|
||||
let store = NoteStore(fileURL: url)
|
||||
store.load()
|
||||
#expect(store.isReadOnly)
|
||||
store.text = "nieuw"
|
||||
store.flush()
|
||||
#expect(try Data(contentsOf: url) == Data([0xFF, 0xFE, 0xFD]))
|
||||
}
|
||||
}
|
||||
|
||||
struct DictationSpacingTests {
|
||||
@Test func noSpaceAtTheStartOrAfterWhitespace() {
|
||||
#expect(DictationSpacing.prepared("hallo", characterBefore: nil) == "hallo")
|
||||
#expect(DictationSpacing.prepared("hallo", characterBefore: " ") == "hallo")
|
||||
#expect(DictationSpacing.prepared("hallo", characterBefore: "\n") == "hallo")
|
||||
}
|
||||
|
||||
@Test func spaceAfterAWordOrSentenceEnd() {
|
||||
#expect(DictationSpacing.prepared("hallo", characterBefore: "k") == " hallo")
|
||||
#expect(DictationSpacing.prepared("Nog een.", characterBefore: ".") == " Nog een.")
|
||||
}
|
||||
|
||||
@Test func noSpaceBeforeClosingPunctuationOrLeadingWhitespace() {
|
||||
#expect(DictationSpacing.prepared(", dus", characterBefore: "k") == ", dus")
|
||||
#expect(DictationSpacing.prepared(" dus", characterBefore: "k") == " dus")
|
||||
}
|
||||
|
||||
@Test func emptyTextStaysEmpty() {
|
||||
#expect(DictationSpacing.prepared("", characterBefore: "k") == "")
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,20 @@ struct HistoryViewModelTests {
|
||||
return store
|
||||
}
|
||||
|
||||
@Test func daysAreFilledAfterTheStoreLoads() {
|
||||
let url = FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json")
|
||||
let writer = HistoryStore(fileURL: url)
|
||||
writer.add(HistoryEntry(date: .now, duration: 1, rawText: "x", finalText: "x"))
|
||||
writer.flush()
|
||||
let store = HistoryStore(fileURL: url)
|
||||
let model = HistoryViewModel(store: store)
|
||||
store.onChange = { model.refresh() }
|
||||
#expect(model.days.isEmpty)
|
||||
store.load()
|
||||
#expect(!model.days.isEmpty)
|
||||
#expect(model.totalCount == 1)
|
||||
}
|
||||
|
||||
@Test func listsAllEntriesNewestFirstAndFiltersByQuery() {
|
||||
let model = HistoryViewModel(store: store(["eerste", "tweede zin", "derde"]))
|
||||
#expect(model.totalCount == 3)
|
||||
@@ -152,6 +166,31 @@ struct DictionaryViewModelTests {
|
||||
#expect(store.entries == [term("Supabase")])
|
||||
}
|
||||
|
||||
@Test func externalChangeClearsUndo() throws {
|
||||
let (model, store) = try makeModel("Supabase\n")
|
||||
model.add(.term("Vercel"))
|
||||
#expect(model.canUndo)
|
||||
model.externalChangeDetected()
|
||||
#expect(!model.canUndo)
|
||||
model.undo()
|
||||
#expect(store.entries == [term("Supabase"), term("Vercel")])
|
||||
}
|
||||
|
||||
@Test func failedUndoKeepsTheSnapshot() throws {
|
||||
let (model, store) = try makeModel("Supabase\n")
|
||||
model.add(.term("Vercel"))
|
||||
let folder = store.fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path)
|
||||
model.undo()
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: folder.path)
|
||||
#expect(model.errorMessage != nil)
|
||||
#expect(model.canUndo)
|
||||
model.errorMessage = nil
|
||||
model.undo()
|
||||
#expect(store.entries == [term("Supabase")])
|
||||
#expect(!model.canUndo)
|
||||
}
|
||||
|
||||
@Test func searchMatchesBothSidesIgnoringCaseAndDiacritics() throws {
|
||||
let (model, _) = try makeModel("cloud code => Claude Code\nCafé\nVercel\n")
|
||||
model.query = "CLAUDE"
|
||||
@@ -170,4 +209,21 @@ struct DictionaryViewModelTests {
|
||||
let asNew = model.warnings(for: .correction(heard: "cloud code", wrote: "Other"), editing: nil)
|
||||
#expect(asNew.contains { $0.level == .error })
|
||||
}
|
||||
|
||||
@Test func removingAnUnknownIdChangesNothing() throws {
|
||||
let (model, store) = try makeModel("Supabase\n")
|
||||
let before = store.entries
|
||||
model.remove(id: UUID())
|
||||
#expect(store.entries == before)
|
||||
#expect(model.canUndo == false)
|
||||
}
|
||||
|
||||
@Test func editingTheEarlierOfTwoDuplicatesShowsNoDuplicateError() throws {
|
||||
let (model, store) = try makeModel("cloud code => A\nCloud-Code => B\n")
|
||||
let first = store.entries[0]
|
||||
let second = store.entries[1]
|
||||
#expect(!model.warnings(for: first.kind, editing: first.id).contains { $0.level == .error })
|
||||
#expect(model.warnings(for: second.kind, editing: second.id).contains { $0.level == .error })
|
||||
#expect(model.warnings(for: first.kind, editing: nil).contains { $0.level == .error })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user