From 23d273458a88ab2f397ef9712e7719eeafd255cf Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 26 Jun 2026 15:47:02 -0700 Subject: [PATCH 1/4] Add custom dictionary import and export --- Sources/Fluid/Services/ASRService.swift | 10 +- .../Services/DictionaryTransferService.swift | 377 ++++++++++++++++++ Sources/Fluid/UI/CustomDictionaryView.swift | 122 +++++- .../DictationE2ETests.swift | 259 ++++++++++++ 4 files changed, 756 insertions(+), 12 deletions(-) create mode 100644 Sources/Fluid/Services/DictionaryTransferService.swift diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 6b21b482..b250dbd2 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2833,16 +2833,16 @@ final class ASRService: ObservableObject { // MARK: - Custom Dictionary (Cached Regex) /// Cache for compiled custom dictionary regexes. - /// Key: trigger word, Value: (compiled regex, replacement text) + /// Key: trigger word, Value: (compiled regex, escaped replacement template) /// Cleared when dictionary entries change. - private static var cachedDictionaryPatterns: [(regex: NSRegularExpression, replacement: String)] = [] + private static var cachedDictionaryPatterns: [(regex: NSRegularExpression, template: String)] = [] private static var dictionaryCacheNeedsRebuild: Bool = true /// Rebuilds the regex cache if dictionary has changed. /// Called lazily on first apply after settings change. private static func rebuildDictionaryCache() { let entries = SettingsStore.shared.customDictionaryEntries - var patterns: [(regex: NSRegularExpression, replacement: String)] = [] + var patterns: [(regex: NSRegularExpression, template: String)] = [] for entry in entries { for trigger in entry.triggers { @@ -2854,7 +2854,7 @@ final class ASRService: ObservableObject { options: .caseInsensitive ) else { continue } - patterns.append((regex: regex, replacement: entry.replacement)) + patterns.append((regex: regex, template: NSRegularExpression.escapedTemplate(for: entry.replacement))) } } @@ -2892,7 +2892,7 @@ final class ASRService: ObservableObject { result = pattern.regex.stringByReplacingMatches( in: result, range: NSRange(result.startIndex..., in: result), - withTemplate: pattern.replacement + withTemplate: pattern.template ) } diff --git a/Sources/Fluid/Services/DictionaryTransferService.swift b/Sources/Fluid/Services/DictionaryTransferService.swift new file mode 100644 index 00000000..0fa9b83c --- /dev/null +++ b/Sources/Fluid/Services/DictionaryTransferService.swift @@ -0,0 +1,377 @@ +import Foundation + +struct DictionaryTransferReplacement: Codable, Equatable { + let from: [String] + let to: String + + init(from: [String], to: String) { + self.from = from + self.to = to + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.from) { + self.from = try Self.decodeStringList(from: container, key: .from) + } else if container.contains(.triggers) { + self.from = try Self.decodeStringList(from: container, key: .triggers) + } else { + throw DecodingError.keyNotFound( + CodingKeys.from, + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected from or triggers.") + ) + } + + self.to = try container.decodeIfPresent(String.self, forKey: .to) + ?? container.decode(String.self, forKey: .replacement) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.from, forKey: .from) + try container.encode(self.to, forKey: .to) + } + + private enum CodingKeys: String, CodingKey { + case from + case to + case triggers + case replacement + } + + private static func decodeStringList( + from container: KeyedDecodingContainer, + key: CodingKeys + ) throws -> [String] { + if let values = try? container.decodeIfPresent([String].self, forKey: key) { + return values + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return [value] + } + if try container.decodeNil(forKey: key) { + return [] + } + var codingPath = container.codingPath + codingPath.append(key) + throw DecodingError.typeMismatch( + [String].self, + DecodingError.Context(codingPath: codingPath, debugDescription: "Expected a string or list of strings.") + ) + } +} + +struct DictionaryTransferDocument: Codable, Equatable { + let replacements: [DictionaryTransferReplacement] + let customWords: [String] + + init(replacements: [DictionaryTransferReplacement], customWords: [String]) { + self.replacements = replacements + self.customWords = customWords + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let replacements = try Self.decodeReplacements(from: container) + let customWords = try Self.decodeCustomWords(from: container) + + guard replacements.found || customWords.found else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected replacements, customWords, terms, or API items/entries." + ) + ) + } + + self.replacements = replacements.values + self.customWords = customWords.values + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.replacements, forKey: .replacements) + try container.encode(self.customWords, forKey: .customWords) + } + + private enum CodingKeys: String, CodingKey { + case replacements + case customWords + case terms + case items + case entries + } + + private static func decodeReplacements( + from container: KeyedDecodingContainer + ) throws -> (found: Bool, values: [DictionaryTransferReplacement]) { + if container.contains(.replacements) { + return try (true, container.decode([DictionaryTransferReplacement].self, forKey: .replacements)) + } + if container.contains(.items), + let replacements = try? container.decode([DictionaryTransferReplacement].self, forKey: .items) + { + return (true, replacements) + } + if container.contains(.entries), + let replacements = try? container.decode([DictionaryTransferReplacement].self, forKey: .entries) + { + return (true, replacements) + } + return (false, []) + } + + private static func decodeCustomWords( + from container: KeyedDecodingContainer + ) throws -> (found: Bool, values: [String]) { + let customWords = try Self.decodeCustomWordValues(from: container, key: .customWords) + if customWords.found { + return customWords + } + let terms = try Self.decodeCustomWordValues(from: container, key: .terms) + if terms.found { + return terms + } + if container.contains(.items), + let items = try? Self.decodeCustomWordValues(from: container, key: .items), + items.found + { + return items + } + if container.contains(.entries), + let entries = try? Self.decodeCustomWordValues(from: container, key: .entries), + entries.found + { + return entries + } + return (false, []) + } + + private static func decodeCustomWordValues( + from container: KeyedDecodingContainer, + key: CodingKeys + ) throws -> (found: Bool, values: [String]) { + guard container.contains(key) else { return (false, []) } + return try (true, container.decode([DictionaryTransferCustomWord].self, forKey: key).map(\.text)) + } +} + +private struct DictionaryTransferCustomWord: Decodable { + let text: String + + init(from decoder: Decoder) throws { + if let text = try? decoder.singleValueContainer().decode(String.self) { + self.text = text + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.text = try container.decode(String.self, forKey: .text) + } + + private enum CodingKeys: String, CodingKey { + case text + } +} + +enum DictionaryTransferImportMode { + case merge + case replace +} + +struct DictionaryTransferState { + let replacements: [SettingsStore.CustomDictionaryEntry] + let customWords: [ParakeetVocabularyStore.VocabularyConfig.Term] +} + +struct DictionaryTransferSummary { + let replacementCount: Int + let customWordCount: Int +} + +enum DictionaryTransferServiceError: LocalizedError { + case invalidJSON + + var errorDescription: String? { + switch self { + case .invalidJSON: + return "The selected file is not a valid FluidVoice dictionary file." + } + } +} + +@MainActor +final class DictionaryTransferService { + static let shared = DictionaryTransferService() + + private static let importedCustomWordWeight: Float = 10.0 + private static let maxCustomWords = 256 + + private init() {} + + func makeExportDocument() throws -> DictionaryTransferDocument { + try DictionaryTransferDocument( + replacements: SettingsStore.shared.customDictionaryEntries.compactMap(Self.exportReplacement(from:)), + customWords: Self.normalizedUniqueStrings( + ParakeetVocabularyStore.shared.loadUserBoostTerms().map(\.text), + lowercased: false + ) + ) + } + + func encode(_ document: DictionaryTransferDocument) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(Self.normalizedDocument(document)) + } + + func decode(_ data: Data) throws -> DictionaryTransferDocument { + do { + let document = try JSONDecoder().decode(DictionaryTransferDocument.self, from: data) + return Self.normalizedDocument(document) + } catch { + throw DictionaryTransferServiceError.invalidJSON + } + } + + @discardableResult + func restore(_ document: DictionaryTransferDocument, mode: DictionaryTransferImportMode) throws -> DictionaryTransferSummary { + let state = try Self.importState( + document: document, + mode: mode, + currentReplacements: SettingsStore.shared.customDictionaryEntries, + currentCustomWords: ParakeetVocabularyStore.shared.loadUserBoostTerms() + ) + + try ParakeetVocabularyStore.shared.saveUserBoostTerms(state.customWords) + SettingsStore.shared.customDictionaryEntries = state.replacements + ASRService.invalidateDictionaryCache() + NotificationCenter.default.post(name: .parakeetVocabularyDidChange, object: nil) + + return DictionaryTransferSummary( + replacementCount: state.replacements.count, + customWordCount: state.customWords.count + ) + } + + func suggestedFilename(for date: Date = Date()) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm" + return "FluidVoice_Dictionary_\(formatter.string(from: date)).json" + } + + static func importState( + document: DictionaryTransferDocument, + mode: DictionaryTransferImportMode, + currentReplacements: [SettingsStore.CustomDictionaryEntry], + currentCustomWords: [ParakeetVocabularyStore.VocabularyConfig.Term] + ) throws -> DictionaryTransferState { + let normalizedDocument = self.normalizedDocument(document) + var replacements = mode == .replace ? [] : currentReplacements + for replacement in normalizedDocument.replacements { + guard let entry = self.storeReplacement(from: replacement) else { continue } + self.upsert(entry, into: &replacements) + } + + var customWords = mode == .replace ? [] : currentCustomWords + for word in normalizedDocument.customWords { + guard customWords.count < self.maxCustomWords else { break } + if customWords.contains(where: { $0.text.caseInsensitiveCompare(word) == .orderedSame }) { + continue + } + customWords.append( + ParakeetVocabularyStore.VocabularyConfig.Term( + text: word, + weight: self.importedCustomWordWeight, + aliases: [] + ) + ) + } + + return DictionaryTransferState(replacements: replacements, customWords: customWords) + } + + private static func normalizedDocument(_ document: DictionaryTransferDocument) -> DictionaryTransferDocument { + DictionaryTransferDocument( + replacements: document.replacements.compactMap(self.exportReplacement(from:)), + customWords: self.normalizedUniqueStrings(document.customWords, lowercased: false) + ) + } + + private static func exportReplacement(from entry: SettingsStore.CustomDictionaryEntry) -> DictionaryTransferReplacement? { + let from = self.normalizedUniqueStrings(entry.triggers, lowercased: true) + let to = entry.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !from.isEmpty, !to.isEmpty else { return nil } + return DictionaryTransferReplacement(from: from, to: to) + } + + private static func exportReplacement(from replacement: DictionaryTransferReplacement) -> DictionaryTransferReplacement? { + let from = self.normalizedUniqueStrings(replacement.from, lowercased: true) + let to = replacement.to.trimmingCharacters(in: .whitespacesAndNewlines) + guard !from.isEmpty, !to.isEmpty else { return nil } + return DictionaryTransferReplacement(from: from, to: to) + } + + private static func storeReplacement( + from replacement: DictionaryTransferReplacement + ) -> SettingsStore.CustomDictionaryEntry? { + let from = self.normalizedUniqueStrings(replacement.from, lowercased: true) + let to = replacement.to.trimmingCharacters(in: .whitespacesAndNewlines) + guard !from.isEmpty, !to.isEmpty else { return nil } + return SettingsStore.CustomDictionaryEntry(triggers: from, replacement: to) + } + + private static func upsert( + _ entry: SettingsStore.CustomDictionaryEntry, + into entries: inout [SettingsStore.CustomDictionaryEntry] + ) { + let matchingIndex = entries.firstIndex { + $0.replacement.caseInsensitiveCompare(entry.replacement) == .orderedSame + } + let replacementID = matchingIndex.map { entries[$0].id } ?? entry.id + let replacementText = matchingIndex.map { entries[$0].replacement } ?? entry.replacement + let previousTriggers = matchingIndex.map { entries[$0].triggers } ?? [] + let combinedTriggers = self.normalizedUniqueStrings(previousTriggers + entry.triggers, lowercased: true) + let triggerKeys = Set(combinedTriggers.map { $0.lowercased() }) + + entries.removeAll { + $0.replacement.caseInsensitiveCompare(entry.replacement) == .orderedSame + } + + entries = entries.compactMap { existing in + let remainingTriggers = existing.triggers.filter { !triggerKeys.contains($0.lowercased()) } + guard !remainingTriggers.isEmpty else { return nil } + return SettingsStore.CustomDictionaryEntry( + id: existing.id, + triggers: remainingTriggers, + replacement: existing.replacement + ) + } + + entries.append( + SettingsStore.CustomDictionaryEntry( + id: replacementID, + triggers: combinedTriggers, + replacement: replacementText + ) + ) + } + + private static func normalizedUniqueStrings(_ values: [String], lowercased: Bool) -> [String] { + var seen: Set = [] + var result: [String] = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let output = lowercased ? trimmed.lowercased() : trimmed + let key = output.lowercased() + guard !seen.contains(key) else { continue } + seen.insert(key) + result.append(output) + } + + return result + } +} diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 14e93ede..4a8c49f2 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -6,7 +6,9 @@ // Created: 2025-12-21 // +import AppKit import SwiftUI +import UniformTypeIdentifiers struct CustomDictionaryView: View { @Environment(\.theme) private var theme @@ -80,13 +82,31 @@ struct CustomDictionaryView: View { private var pageHeader: some View { VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "text.book.closed.fill") - .font(.title2) - .foregroundStyle(self.theme.palette.accent) - Text("Custom Dictionary") - .font(.title2) - .fontWeight(.semibold) + HStack(alignment: .center, spacing: 12) { + HStack { + Image(systemName: "text.book.closed.fill") + .font(.title2) + .foregroundStyle(self.theme.palette.accent) + Text("Custom Dictionary") + .font(.title2) + .fontWeight(.semibold) + } + + Spacer(minLength: 16) + + HStack(spacing: 8) { + Button(action: self.exportDictionary) { + Label("Export", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button(action: self.importDictionary) { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .controlSize(.small) + } } Text("Fix recurring transcription mistakes with Instant Replacement, or add Custom Words for names and product terms.") @@ -461,6 +481,94 @@ struct CustomDictionaryView: View { } } + private func exportDictionary() { + do { + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = DictionaryTransferService.shared.suggestedFilename() + + guard panel.runModal() == .OK, let url = panel.url else { return } + + let document = try DictionaryTransferService.shared.makeExportDocument() + let data = try DictionaryTransferService.shared.encode(document) + try data.write(to: url, options: .atomic) + + self.presentInfoAlert( + title: "Dictionary Exported", + message: "Saved \(document.replacements.count) replacement rules and \(document.customWords.count) custom words." + ) + } catch { + self.presentErrorAlert(title: "Dictionary Export Failed", message: error.localizedDescription) + } + } + + private func importDictionary() { + do { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.json] + + guard panel.runModal() == .OK, let url = panel.url else { return } + + let data = try Data(contentsOf: url) + let document = try DictionaryTransferService.shared.decode(data) + guard let mode = self.confirmDictionaryImport(document) else { return } + + let summary = try DictionaryTransferService.shared.restore(document, mode: mode) + self.entries = SettingsStore.shared.customDictionaryEntries + self.loadBoostTerms() + + self.presentInfoAlert( + title: "Dictionary Imported", + message: "Now using \(summary.replacementCount) replacement rules and \(summary.customWordCount) custom words." + ) + } catch { + self.presentErrorAlert(title: "Dictionary Import Failed", message: error.localizedDescription) + } + } + + private func confirmDictionaryImport(_ document: DictionaryTransferDocument) -> DictionaryTransferImportMode? { + let confirm = NSAlert() + confirm.messageText = "Import this dictionary?" + confirm.informativeText = """ + Found \(document.replacements.count) replacement rules and \(document.customWords.count) custom words. + + Merge adds them to your current dictionary. Replace clears the current dictionary first. + """ + confirm.alertStyle = .warning + confirm.addButton(withTitle: "Merge") + confirm.addButton(withTitle: "Replace") + confirm.addButton(withTitle: "Cancel") + + switch confirm.runModal() { + case .alertFirstButtonReturn: + return .merge + case .alertSecondButtonReturn: + return .replace + default: + return nil + } + } + + private func presentInfoAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.runModal() + } + + private func presentErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.runModal() + } + private func deleteBoostTerm(at index: Int) { guard self.boostTerms.indices.contains(index) else { return } self.boostTerms.remove(at: index) diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 3f97b50a..87f45ec0 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -18,6 +18,7 @@ final class DictationE2ETests: XCTestCase { private let selectedProviderIDKey = "SelectedProviderID" private let availableModelsByProviderKey = "AvailableModelsByProvider" private let selectedModelByProviderKey = "SelectedModelByProvider" + private let customDictionaryEntriesKey = "CustomDictionaryEntries" private let commandModeLinkedToGlobalKey = "CommandModeLinkedToGlobal" private let commandModeSelectedProviderIDKey = "CommandModeSelectedProviderID" private let commandModeSelectedModelKey = "CommandModeSelectedModel" @@ -68,6 +69,264 @@ final class DictationE2ETests: XCTestCase { } } + func testDictionaryTransferDocument_encodesSimpleUserFormat() throws { + let document = DictionaryTransferDocument( + replacements: [ + DictionaryTransferReplacement(from: ["fluid voice", "fluid boys"], to: "FluidVoice"), + ], + customWords: ["FluidVoice", "GEMBA-E"] + ) + + let data = try DictionaryTransferService.shared.encode(document) + let json = String(data: data, encoding: .utf8) ?? "" + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let replacements = try XCTUnwrap(root["replacements"] as? [[String: Any]]) + let firstReplacement = try XCTUnwrap(replacements.first) + + XCTAssertEqual(firstReplacement["from"] as? [String], ["fluid voice", "fluid boys"]) + XCTAssertEqual(firstReplacement["to"] as? String, "FluidVoice") + XCTAssertEqual(root["customWords"] as? [String], ["FluidVoice", "GEMBA-E"]) + XCTAssertFalse(json.contains("\"triggers\"")) + XCTAssertFalse(json.contains("\"replacement\"")) + XCTAssertFalse(json.contains("\"aliases\"")) + } + + func testDictionaryTransferImport_replaceMapsSimpleFormatToStores() throws { + let document = DictionaryTransferDocument( + replacements: [ + DictionaryTransferReplacement(from: [" Fluid Voice ", "FLUID BOYS", ""], to: " FluidVoice "), + ], + customWords: [" FluidVoice ", "fluidvoice", " Barath "] + ) + let existingReplacement = SettingsStore.CustomDictionaryEntry(triggers: ["old"], replacement: "Old") + let existingWord = ParakeetVocabularyStore.VocabularyConfig.Term(text: "OldWord", weight: 13.0) + + let state = try DictionaryTransferService.importState( + document: document, + mode: .replace, + currentReplacements: [existingReplacement], + currentCustomWords: [existingWord] + ) + + XCTAssertEqual(state.replacements.count, 1) + XCTAssertEqual(state.replacements.first?.triggers, ["fluid voice", "fluid boys"]) + XCTAssertEqual(state.replacements.first?.replacement, "FluidVoice") + XCTAssertEqual(state.customWords.map(\.text), ["FluidVoice", "Barath"]) + XCTAssertEqual(state.customWords.map(\.weight), [10.0, 10.0]) + } + + func testDictionaryTransferImport_mergeDedupesAndMovesDuplicateTriggers() throws { + let oldReplacement = SettingsStore.CustomDictionaryEntry( + triggers: ["fluid voice", "old trigger"], + replacement: "Old" + ) + let existingReplacement = SettingsStore.CustomDictionaryEntry( + triggers: ["fluid boys"], + replacement: "FluidVoice" + ) + let existingWord = ParakeetVocabularyStore.VocabularyConfig.Term( + text: "Barath", + weight: 13.0, + aliases: ["barath w"] + ) + let document = DictionaryTransferDocument( + replacements: [ + DictionaryTransferReplacement(from: ["fluid voice", "fluid boys"], to: "FluidVoice"), + ], + customWords: ["barath", "GEMBA-E"] + ) + + let state = try DictionaryTransferService.importState( + document: document, + mode: .merge, + currentReplacements: [oldReplacement, existingReplacement], + currentCustomWords: [existingWord] + ) + + let fluidVoiceEntry = try XCTUnwrap(state.replacements.first { $0.replacement == "FluidVoice" }) + let oldEntry = try XCTUnwrap(state.replacements.first { $0.replacement == "Old" }) + let barathTerm = try XCTUnwrap(state.customWords.first { $0.text == "Barath" }) + let gembaeTerm = try XCTUnwrap(state.customWords.first { $0.text == "GEMBA-E" }) + + XCTAssertEqual(Set(fluidVoiceEntry.triggers), Set(["fluid voice", "fluid boys"])) + XCTAssertEqual(oldEntry.triggers, ["old trigger"]) + XCTAssertEqual(barathTerm.weight, 13.0) + XCTAssertEqual(barathTerm.aliases, ["barath w"]) + XCTAssertEqual(gembaeTerm.weight, 10.0) + } + + func testDictionaryTransferImport_acceptsAppStyleReplacementKeysAndSingleFromValue() throws { + let json = """ + { + "replacements": [ + { + "from": "fluid voice", + "to": "FluidVoice" + }, + { + "triggers": ["gemba e"], + "replacement": "GEMBA-E" + } + ] + } + """ + + let document = try DictionaryTransferService.shared.decode(Data(json.utf8)) + let state = try DictionaryTransferService.importState( + document: document, + mode: .replace, + currentReplacements: [], + currentCustomWords: [] + ) + + XCTAssertEqual(state.replacements.map(\.triggers), [["fluid voice"], ["gemba e"]]) + XCTAssertEqual(state.replacements.map(\.replacement), ["FluidVoice", "GEMBA-E"]) + } + + func testDictionaryTransferImport_acceptsLocalAPIReplacementItemsResponse() throws { + let json = """ + { + "count": 1, + "items": [ + { + "triggers": ["fluid voice"], + "replacement": "FluidVoice" + } + ] + } + """ + + let document = try DictionaryTransferService.shared.decode(Data(json.utf8)) + let state = try DictionaryTransferService.importState( + document: document, + mode: .replace, + currentReplacements: [], + currentCustomWords: [] + ) + + XCTAssertEqual(state.replacements.first?.triggers, ["fluid voice"]) + XCTAssertEqual(state.replacements.first?.replacement, "FluidVoice") + XCTAssertEqual(state.customWords.count, 0) + } + + func testDictionaryTransferImportFeedsActualReplacementPath() throws { + defer { ASRService.invalidateDictionaryCache() } + let document = DictionaryTransferDocument( + replacements: [ + DictionaryTransferReplacement(from: ["fluid voice"], to: "FluidVoice"), + ], + customWords: [] + ) + let state = try DictionaryTransferService.importState( + document: document, + mode: .replace, + currentReplacements: [], + currentCustomWords: [] + ) + + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = state.replacements + ASRService.invalidateDictionaryCache() + + XCTAssertEqual( + ASRService.applyCustomDictionary("I use fluid voice daily."), + "I use FluidVoice daily." + ) + } + } + + func testCustomDictionaryReplacementTreatsReplacementTextLiterally() { + defer { ASRService.invalidateDictionaryCache() } + let entry = SettingsStore.CustomDictionaryEntry( + triggers: ["dollar path"], + replacement: #"$5 \path"# + ) + + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = [entry] + ASRService.invalidateDictionaryCache() + + XCTAssertEqual( + ASRService.applyCustomDictionary("Use dollar path now."), + #"Use $5 \path now."# + ) + } + } + + func testDictionaryTransferImport_rejectsInvalidReplacementTriggerType() { + let json = """ + { + "replacements": [ + { + "from": 42, + "to": "FluidVoice" + } + ] + } + """ + + XCTAssertThrowsError(try DictionaryTransferService.shared.decode(Data(json.utf8))) + } + + func testDictionaryTransferImport_acceptsParakeetVocabularyTermsFile() throws { + let json = """ + { + "alpha": 2.8, + "terms": [ + { + "text": "FluidVoice", + "aliases": ["fluid voice"], + "weight": 10.0 + }, + { + "text": "GEMBA-E" + } + ] + } + """ + + let document = try DictionaryTransferService.shared.decode(Data(json.utf8)) + let state = try DictionaryTransferService.importState( + document: document, + mode: .replace, + currentReplacements: [], + currentCustomWords: [] + ) + + XCTAssertEqual(state.replacements.count, 0) + XCTAssertEqual(state.customWords.map(\.text), ["FluidVoice", "GEMBA-E"]) + XCTAssertEqual(state.customWords.map(\.weight), [10.0, 10.0]) + } + + func testDictionaryTransferImport_acceptsLocalAPICustomWordsResponse() throws { + let json = """ + { + "count": 2, + "items": [ + { + "text": "FluidVoice", + "weight": 10.0, + "aliases": ["fluid voice"] + }, + { + "text": "Barath" + } + ] + } + """ + + let document = try DictionaryTransferService.shared.decode(Data(json.utf8)) + let state = try DictionaryTransferService.importState( + document: document, + mode: .replace, + currentReplacements: [], + currentCustomWords: [] + ) + + XCTAssertEqual(state.replacements.count, 0) + XCTAssertEqual(state.customWords.map(\.text), ["FluidVoice", "Barath"]) + } + func testDictationEndToEnd_whisperTiny_transcribesFixture() async throws { // Arrange SettingsStore.shared.shareAnonymousAnalytics = false From aafe4da246f644fcf81331a0a630a73de4db73c0 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 26 Jun 2026 16:35:31 -0700 Subject: [PATCH 2/4] polish dictionary transfer ui --- .gitignore | 1 + Sources/Fluid/UI/CustomDictionaryView.swift | 665 ++++++++------------ 2 files changed, 280 insertions(+), 386 deletions(-) diff --git a/.gitignore b/.gitignore index c1d804a2..1ca829cb 100644 --- a/.gitignore +++ b/.gitignore @@ -186,6 +186,7 @@ SettingsStore.xcuserstate .mcp_temp/ # Build and release scripts (developer-specific) +build_* build_dev.sh build_and_notarize.sh release.sh diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 4a8c49f2..36a7e10c 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -19,26 +19,23 @@ struct CustomDictionaryView: View { @State private var showAddBoostSheet = false @State private var editingBoostTerm: EditableBoostTerm? - // Collapsible section states - @State private var isInstantReplacementSectionExpanded = true - @State private var isAISectionExpanded = true - @State private var boostStatusMessage = "Add custom words for better Parakeet recognition." @State private var boostHasError = false @State private var vocabBoostingEnabled: Bool = SettingsStore.shared.vocabularyBoostingEnabled + @State private var isBoostingInfoPresented = false var body: some View { ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.xl) { self.pageHeader - // Section 1: Instant Replacement - self.instantReplacementSection - - // Section 2: Custom Words (Parakeet) - self.aiPostProcessingSection + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.xxl) { + self.instantReplacementSection + self.aiPostProcessingSection + } } - .padding(20) + .frame(maxWidth: 860, alignment: .leading) + .padding(self.theme.metrics.spacing.xl) } .sheet(isPresented: self.$showAddSheet) { AddDictionaryEntrySheet(existingTriggers: self.allExistingTriggers()) { newEntry in @@ -81,204 +78,107 @@ struct CustomDictionaryView: View { // MARK: - Page Header private var pageHeader: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 12) { - HStack { - Image(systemName: "text.book.closed.fill") - .font(.title2) - .foregroundStyle(self.theme.palette.accent) - Text("Custom Dictionary") - .font(.title2) - .fontWeight(.semibold) - } + HStack(alignment: .center, spacing: self.theme.metrics.spacing.md) { + self.settingsIconTile(systemName: "text.book.closed.fill") + + VStack(alignment: .leading, spacing: 2) { + Text("Custom Dictionary") + .font(self.theme.typography.title) + Text("Correct recurring mistakes and teach the voice engine the words you use.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.theme.palette.secondaryText) + } - Spacer(minLength: 16) + Spacer(minLength: self.theme.metrics.spacing.md) - HStack(spacing: 8) { - Button(action: self.exportDictionary) { - Label("Export", systemImage: "square.and.arrow.up") - } - .buttonStyle(.bordered) - .controlSize(.small) + HStack(spacing: self.theme.metrics.spacing.sm) { + Button(action: self.importDictionary) { + Label("Import", systemImage: "square.and.arrow.down") + } + .fluidButton(.compact, size: .compact) - Button(action: self.importDictionary) { - Label("Import", systemImage: "square.and.arrow.down") - } - .buttonStyle(.bordered) - .controlSize(.small) + Button(action: self.exportDictionary) { + Label("Export", systemImage: "square.and.arrow.up") } + .fluidButton(.compact, size: .compact) } - - Text("Fix recurring transcription mistakes with Instant Replacement, or add Custom Words for names and product terms.") - .font(.subheadline) - .foregroundStyle(.secondary) } } - // MARK: - Section 1: Instant Replacement - - private var instantReplacementSection: some View { - ThemedCard(hoverEffect: false) { - VStack(alignment: .leading, spacing: 0) { - // Collapsible Header - Button { - withAnimation(.easeInOut(duration: 0.2)) { - self.isInstantReplacementSectionExpanded.toggle() - } - } label: { - HStack { - Image(systemName: self.isInstantReplacementSectionExpanded ? "chevron.down" : "chevron.right") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 16) - - Text("Instant Replacement") - .font(.headline) + private func settingsIconTile(systemName: String) -> some View { + ZStack { + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.82)) + .overlay( + LinearGradient( + colors: [.white.opacity(0.1), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .clipShape(RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous)) + ) + .overlay( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .stroke(self.theme.palette.accent.opacity(0.35), lineWidth: 1) + ) - Spacer() + Image(systemName: systemName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(self.theme.palette.accent) + } + .frame(width: 34, height: 34) + } - if !self.entries.isEmpty { - Text("\(self.entries.count)") - .font(.caption.weight(.medium)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Capsule().fill(.quaternary)) - .foregroundStyle(.secondary) - } + // MARK: - Instant Replacement - // Add button (only when expanded and has entries) - if self.isInstantReplacementSectionExpanded && !self.entries.isEmpty { - Button { - self.showAddSheet = true - } label: { - Image(systemName: "plus") + private var instantReplacementSection: some View { + ThemedCard(style: .standard, hoverEffect: false) { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.lg) { + HStack(alignment: .center, spacing: self.theme.metrics.spacing.md) { + self.settingsIconTile(systemName: "arrow.left.arrow.right") + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("Instant Replacement") + .font(self.theme.typography.sectionTitle) + if !self.entries.isEmpty { + Text("(\(self.entries.count))") + .font(self.theme.typography.captionSmall) + .foregroundStyle(self.theme.palette.tertiaryText) } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if self.isInstantReplacementSectionExpanded { - Divider() - .padding(.vertical, 12) - - // Description - VStack(alignment: .leading, spacing: 8) { - Text("Fix recurring transcription mistakes or create shortcuts for text you use often.") - - VStack(alignment: .leading, spacing: 6) { - self.instantReplacementExampleRow( - label: "Common mistake", - trigger: "fluid voice", - replacement: "FluidVoice" - ) - self.instantReplacementExampleRow( - label: "Email", - trigger: "email me", - replacement: "you@example.com" - ) - self.instantReplacementExampleRow( - label: "Punctuation", - trigger: "colon", - replacement: ":" - ) - self.instantReplacementExampleRow( - label: "Punctuation", - trigger: "exclamation", - replacement: "!" - ) } + Text("Replace phrases that are consistently transcribed incorrectly.") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) } - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 12) - // Features - HStack(spacing: 12) { - Label("Find-and-replace", systemImage: "arrow.left.arrow.right") - .font(.caption2) - .foregroundStyle(.secondary) - - Label("Instant apply", systemImage: "bolt.fill") - .font(.caption2) - .foregroundStyle(.secondary) - - Label("Case insensitive", systemImage: "textformat") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(.bottom, 12) + Spacer() - // Content - if self.entries.isEmpty { - self.instantReplacementEmptyState - } else { - self.entriesListView + Button { + self.showAddSheet = true + } label: { + Label("Add Replacement", systemImage: "plus") } + .fluidButton(.accent, size: .small) } - } - .padding(14) - } - } - - private func instantReplacementExampleRow(label: String, trigger: String, replacement: String) -> some View { - HStack(spacing: 6) { - Text(label) - .font(.caption2.weight(.semibold)) - .foregroundStyle(self.theme.palette.accent) - .frame(width: 108, alignment: .leading) - - Text("\"\(trigger)\"") - .font(.caption2.monospaced()) - .foregroundStyle(.primary) - - Image(systemName: "arrow.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - Text("\"\(replacement)\"") - .font(.caption2.monospaced()) - .foregroundStyle(.primary) - } - .lineLimit(1) - .minimumScaleFactor(0.9) - } - - // MARK: - Instant Replacement Empty State - - private var instantReplacementEmptyState: some View { - VStack(spacing: 12) { - Image(systemName: "plus.circle.dashed") - .font(.system(size: 32)) - .foregroundStyle(.tertiary) - - Text("No entries yet") - .font(.subheadline) - .foregroundStyle(.secondary) - - Button { - self.showAddSheet = true - } label: { - HStack(spacing: 4) { - Image(systemName: "plus") - Text("Add Entry") + if self.entries.isEmpty { + self.dictionaryEmptyState( + title: "No replacements yet", + detail: "Add a phrase and the text it should become." + ) { + self.showAddSheet = true + } + } else { + self.entriesListView } } - .buttonStyle(.borderedProminent) - .tint(self.theme.palette.accent) - .controlSize(.small) } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - Entries List - private var entriesListView: some View { - VStack(spacing: 8) { + VStack(spacing: self.theme.metrics.spacing.sm) { ForEach(self.entries) { entry in DictionaryEntryRow( entry: entry, @@ -289,164 +189,147 @@ struct CustomDictionaryView: View { } } - // MARK: - Section 2: Custom Words (Parakeet) + // MARK: - Custom Words private var aiPostProcessingSection: some View { - ThemedCard(hoverEffect: false) { - VStack(alignment: .leading, spacing: 0) { - // Collapsible Header - Button { - withAnimation(.easeInOut(duration: 0.2)) { - self.isAISectionExpanded.toggle() + ThemedCard(style: .standard, hoverEffect: false) { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.lg) { + HStack(alignment: .center, spacing: self.theme.metrics.spacing.md) { + self.settingsIconTile(systemName: "character.book.closed") + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("Custom Words") + .font(self.theme.typography.sectionTitle) + if !self.boostTerms.isEmpty { + Text("(\(self.boostTerms.count))") + .font(self.theme.typography.captionSmall) + .foregroundStyle(self.theme.palette.tertiaryText) + } + } + Text("Help the Parakeet voice engine recognize names, products, and uncommon terms.") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) } - } label: { - HStack { - Image(systemName: self.isAISectionExpanded ? "chevron.down" : "chevron.right") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 16) - - Text("Custom Words (Parakeet)") - .font(.headline) - Text("PARAKEET") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(RoundedRectangle(cornerRadius: 4).fill(self.theme.palette.accent.opacity(0.2))) - .foregroundStyle(self.theme.palette.accent) + Spacer() - Text("\(self.boostTerms.count)") - .font(.caption2.weight(.medium)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(.quaternary)) - .foregroundStyle(.secondary) + Toggle("Boosting", isOn: self.$vocabBoostingEnabled) + .font(self.theme.typography.captionStrong) + .toggleStyle(.switch) + .controlSize(.mini) + .help("Improve recognition of your custom words when using Parakeet.") + .onChange(of: self.vocabBoostingEnabled) { _, newValue in + SettingsStore.shared.vocabularyBoostingEnabled = newValue + } - Spacer() + Button { + self.isBoostingInfoPresented.toggle() + } label: { + Image(systemName: "info.circle") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(SquareIconButtonStyle()) + .help("About Vocabulary Boosting") + .popover(isPresented: self.$isBoostingInfoPresented, arrowEdge: .top) { + self.boostingInfoPopover + } - if self.isAISectionExpanded && !self.boostTerms.isEmpty { - Button { - self.showAddBoostSheet = true - } label: { - Image(systemName: "plus") - } - .buttonStyle(.bordered) - .controlSize(.small) - } + Button { + self.showAddBoostSheet = true + } label: { + Label("Add Word", systemImage: "plus") } - .contentShape(Rectangle()) + .fluidButton(.accent, size: .small) } - .buttonStyle(.plain) - if self.isAISectionExpanded { - Divider() - .padding(.vertical, 12) - - VStack(alignment: .leading, spacing: 12) { - Text("Add names, product words, and uncommon terms in a simple form.") - .font(.caption) - .foregroundStyle(.secondary) - - Text("Words from Instant Replacement are also used here automatically.") - .font(.caption2) - .foregroundStyle(.secondary) - - Text("Applies when using a Parakeet voice engine.") - .font(.caption2) - .foregroundStyle(.secondary) - - HStack { - Toggle(isOn: self.$vocabBoostingEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text("Vocabulary Boosting") - .font(.subheadline.weight(.medium)) - Text("Uses a secondary ML model to improve recognition of custom words. Disable if you experience issues.") - .font(.caption2) - .foregroundStyle(.secondary) + if self.boostTerms.isEmpty { + self.dictionaryEmptyState( + title: "No custom words yet", + detail: "Add a name or term that needs a little extra recognition help." + ) { + self.showAddBoostSheet = true + } + } else { + VStack(spacing: self.theme.metrics.spacing.sm) { + ForEach(Array(self.boostTerms.enumerated()), id: \.offset) { index, term in + BoostTermRow( + term: term, + onEdit: { + self.editingBoostTerm = EditableBoostTerm(index: index, term: term) + }, + onDelete: { + self.deleteBoostTerm(at: index) } - } - .toggleStyle(.switch) - .controlSize(.small) - .onChange(of: self.vocabBoostingEnabled) { _, newValue in - SettingsStore.shared.vocabularyBoostingEnabled = newValue - } + ) } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(self.theme.palette.contentBackground.opacity(0.6)) - ) + } + } - if self.boostTerms.isEmpty { - VStack(spacing: 10) { - Image(systemName: "waveform.and.magnifyingglass") - .font(.system(size: 28)) - .foregroundStyle(.tertiary) - Text("No custom words yet") - .font(.subheadline) - .foregroundStyle(.secondary) - Button { - self.showAddBoostSheet = true - } label: { - HStack(spacing: 4) { - Image(systemName: "plus") - Text("Add Custom Word") - } - } - .buttonStyle(.borderedProminent) - .tint(self.theme.palette.accent) - .controlSize(.small) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } else { - VStack(spacing: 8) { - ForEach(Array(self.boostTerms.enumerated()), id: \.offset) { index, term in - BoostTermRow( - term: term, - onEdit: { - self.editingBoostTerm = EditableBoostTerm(index: index, term: term) - }, - onDelete: { - self.deleteBoostTerm(at: index) - } - ) - } - } + if self.boostHasError { + Label(self.boostStatusMessage, systemImage: "exclamationmark.triangle.fill") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } - HStack { - Button { - self.showAddBoostSheet = true - } label: { - Label("Add Word", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - .tint(self.theme.palette.accent) - .controlSize(.small) + private var boostingInfoPopover: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + HStack(spacing: 8) { + Image(systemName: "testtube.2") + .foregroundStyle(self.theme.palette.accent) + Text("Vocabulary Boosting ยท Alpha") + .font(self.theme.typography.bodySmallStrong) + } - Spacer() - } - } + Text("Vocabulary Boosting is an experimental feature that helps Parakeet recognize your custom words.") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) - HStack { - Image(systemName: self.boostHasError ? "xmark.circle.fill" : "checkmark.circle.fill") - .foregroundStyle(self.boostHasError ? .red : .secondary) - Text(self.boostStatusMessage) - .font(.caption) - .foregroundStyle(self.boostHasError ? .red : .secondary) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(self.boostHasError ? Color.red.opacity(0.08) : self.theme.palette.contentBackground.opacity(0.6)) - ) - } - } + Text("If recognition gets worse, the model behaves unexpectedly, or you notice other issues after enabling it, turn Boosting off.") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) + } + .padding(self.theme.metrics.spacing.lg) + .frame(width: 310, alignment: .leading) + } + + private func dictionaryEmptyState( + title: String, + detail: String, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: self.theme.metrics.spacing.sm) { + Image(systemName: "plus.circle") + .font(.title3) + .foregroundStyle(self.theme.palette.tertiaryText) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(self.theme.typography.bodySmallStrong) + Text(detail) + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) } - .padding(14) + + Spacer() + + Button("Add", action: action) + .fluidButton(.compact, size: .compact) } + .padding(self.theme.metrics.spacing.md) + .background( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.5)) + .overlay( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.25), lineWidth: 1) + ) + ) } // MARK: - Actions @@ -629,6 +512,14 @@ private enum BoostStrengthPreset: String, CaseIterable, Identifiable { } } + var badgeColor: Color { + switch self { + case .mild: return .blue + case .balanced: return Color.fluidGreen + case .strong: return .orange + } + } + static func nearest(for weight: Float) -> Self { if weight < 8.5 { return .mild } if weight > 11.5 { return .strong } @@ -646,46 +537,54 @@ struct BoostTermRow: View { @Environment(\.theme) private var theme var body: some View { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(self.term.text) - .font(.callout.weight(.medium)) - .foregroundStyle(self.theme.palette.accent) - } + HStack(spacing: self.theme.metrics.spacing.sm) { + Text(self.term.text) + .font(self.theme.typography.bodySmallStrong) Spacer() if let weight = self.term.weight { - Text("\(BoostStrengthPreset.nearest(for: weight).rawValue) priority") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Capsule().fill(.quaternary)) - .foregroundStyle(.secondary) + let strength = BoostStrengthPreset.nearest(for: weight) + Text(strength.rawValue) + .font(self.theme.typography.bodySmallStrong) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Capsule().fill(strength.badgeColor.opacity(0.25))) + .foregroundStyle(strength.badgeColor) } - HStack(spacing: 6) { + HStack(spacing: 2) { Button { self.onEdit() } label: { - Image(systemName: "pencil") - .font(.caption2) + Image(systemName: "slider.horizontal.3") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 32, height: 32) } - .buttonStyle(.bordered) - .controlSize(.mini) + .buttonStyle(SquareIconButtonStyle()) + .help("Configure \(self.term.text)") Button(role: .destructive) { self.onDelete() } label: { Image(systemName: "trash") - .font(.caption2) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 32, height: 32) } - .buttonStyle(.bordered) - .controlSize(.mini) + .buttonStyle(SquareIconButtonStyle(foreground: .red, borderColor: .red)) + .help("Delete \(self.term.text)") } } - .padding(10) - .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.5))) + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.52)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.28), lineWidth: 1) + ) + ) } } @@ -881,65 +780,59 @@ struct DictionaryEntryRow: View { @Environment(\.theme) private var theme var body: some View { - HStack(alignment: .center, spacing: 12) { - // Triggers (left side) - VStack(alignment: .leading, spacing: 4) { - Text("When heard:") - .font(.caption2) - .foregroundStyle(.tertiary) - - FlowLayout(spacing: 4) { - ForEach(self.entry.triggers, id: \.self) { trigger in - Text(trigger) - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(RoundedRectangle(cornerRadius: 4).fill(.quaternary)) - } + HStack(alignment: .center, spacing: self.theme.metrics.spacing.sm) { + FlowLayout(spacing: 4) { + ForEach(self.entry.triggers, id: \.self) { trigger in + Text(trigger) + .font(self.theme.typography.caption) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(RoundedRectangle(cornerRadius: 4).fill(.quaternary)) } } .frame(maxWidth: .infinity, alignment: .leading) - // Arrow Image(systemName: "arrow.right") - .font(.caption2) - .foregroundStyle(.tertiary) - - // Replacement (right side) - VStack(alignment: .leading, spacing: 4) { - Text("Replace with:") - .font(.caption2) - .foregroundStyle(.tertiary) + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.tertiaryText) - Text(self.entry.replacement) - .font(.callout.weight(.medium)) - .foregroundStyle(self.theme.palette.accent) - } - .frame(maxWidth: .infinity, alignment: .leading) + Text(self.entry.replacement) + .font(self.theme.typography.bodySmallStrong) + .foregroundStyle(self.theme.palette.accent) + .frame(maxWidth: .infinity, alignment: .leading) - // Actions - HStack(spacing: 6) { + HStack(spacing: 2) { Button { self.onEdit() } label: { - Image(systemName: "pencil") - .font(.caption2) + Image(systemName: "slider.horizontal.3") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 32, height: 32) } - .buttonStyle(.bordered) - .controlSize(.mini) + .buttonStyle(SquareIconButtonStyle()) + .help("Configure replacement") Button(role: .destructive) { self.onDelete() } label: { Image(systemName: "trash") - .font(.caption2) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 32, height: 32) } - .buttonStyle(.bordered) - .controlSize(.mini) + .buttonStyle(SquareIconButtonStyle(foreground: .red, borderColor: .red)) + .help("Delete replacement") } } - .padding(10) - .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.5))) + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.52)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.28), lineWidth: 1) + ) + ) } } From c14b760eccd52c07f836c2cf0d7cdbe090212886 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 26 Jun 2026 17:00:09 -0700 Subject: [PATCH 3/4] preserve dictionary word weights --- .../Services/DictionaryTransferService.swift | 80 +++++++++++++++---- .../DictationE2ETests.swift | 16 ++-- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/Sources/Fluid/Services/DictionaryTransferService.swift b/Sources/Fluid/Services/DictionaryTransferService.swift index 0fa9b83c..a9f5a185 100644 --- a/Sources/Fluid/Services/DictionaryTransferService.swift +++ b/Sources/Fluid/Services/DictionaryTransferService.swift @@ -63,11 +63,18 @@ struct DictionaryTransferReplacement: Codable, Equatable { struct DictionaryTransferDocument: Codable, Equatable { let replacements: [DictionaryTransferReplacement] - let customWords: [String] + let customWords: [DictionaryTransferCustomWord] init(replacements: [DictionaryTransferReplacement], customWords: [String]) { + self.init( + replacements: replacements, + customWordEntries: customWords.map { DictionaryTransferCustomWord(text: $0, weight: nil) } + ) + } + + init(replacements: [DictionaryTransferReplacement], customWordEntries: [DictionaryTransferCustomWord]) { self.replacements = replacements - self.customWords = customWords + self.customWords = customWordEntries } init(from decoder: Decoder) throws { @@ -123,7 +130,7 @@ struct DictionaryTransferDocument: Codable, Equatable { private static func decodeCustomWords( from container: KeyedDecodingContainer - ) throws -> (found: Bool, values: [String]) { + ) throws -> (found: Bool, values: [DictionaryTransferCustomWord]) { let customWords = try Self.decodeCustomWordValues(from: container, key: .customWords) if customWords.found { return customWords @@ -150,27 +157,48 @@ struct DictionaryTransferDocument: Codable, Equatable { private static func decodeCustomWordValues( from container: KeyedDecodingContainer, key: CodingKeys - ) throws -> (found: Bool, values: [String]) { + ) throws -> (found: Bool, values: [DictionaryTransferCustomWord]) { guard container.contains(key) else { return (false, []) } - return try (true, container.decode([DictionaryTransferCustomWord].self, forKey: key).map(\.text)) + return try (true, container.decode([DictionaryTransferCustomWord].self, forKey: key)) } } -private struct DictionaryTransferCustomWord: Decodable { +struct DictionaryTransferCustomWord: Codable, Equatable { let text: String + let weight: Float? + + init(text: String, weight: Float?) { + self.text = text + self.weight = weight + } init(from decoder: Decoder) throws { if let text = try? decoder.singleValueContainer().decode(String.self) { self.text = text + self.weight = nil return } let container = try decoder.container(keyedBy: CodingKeys.self) self.text = try container.decode(String.self, forKey: .text) + self.weight = try container.decodeIfPresent(Float.self, forKey: .weight) + } + + func encode(to encoder: Encoder) throws { + if self.weight == nil { + var container = encoder.singleValueContainer() + try container.encode(self.text) + return + } + + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.text, forKey: .text) + try container.encodeIfPresent(self.weight, forKey: .weight) } private enum CodingKeys: String, CodingKey { case text + case weight } } @@ -212,10 +240,7 @@ final class DictionaryTransferService { func makeExportDocument() throws -> DictionaryTransferDocument { try DictionaryTransferDocument( replacements: SettingsStore.shared.customDictionaryEntries.compactMap(Self.exportReplacement(from:)), - customWords: Self.normalizedUniqueStrings( - ParakeetVocabularyStore.shared.loadUserBoostTerms().map(\.text), - lowercased: false - ) + customWordEntries: Self.exportCustomWords(from: ParakeetVocabularyStore.shared.loadUserBoostTerms()) ) } @@ -276,13 +301,13 @@ final class DictionaryTransferService { var customWords = mode == .replace ? [] : currentCustomWords for word in normalizedDocument.customWords { guard customWords.count < self.maxCustomWords else { break } - if customWords.contains(where: { $0.text.caseInsensitiveCompare(word) == .orderedSame }) { + if customWords.contains(where: { $0.text.caseInsensitiveCompare(word.text) == .orderedSame }) { continue } customWords.append( ParakeetVocabularyStore.VocabularyConfig.Term( - text: word, - weight: self.importedCustomWordWeight, + text: word.text, + weight: word.weight ?? self.importedCustomWordWeight, aliases: [] ) ) @@ -294,7 +319,7 @@ final class DictionaryTransferService { private static func normalizedDocument(_ document: DictionaryTransferDocument) -> DictionaryTransferDocument { DictionaryTransferDocument( replacements: document.replacements.compactMap(self.exportReplacement(from:)), - customWords: self.normalizedUniqueStrings(document.customWords, lowercased: false) + customWordEntries: self.exportCustomWords(from: document.customWords) ) } @@ -321,6 +346,33 @@ final class DictionaryTransferService { return SettingsStore.CustomDictionaryEntry(triggers: from, replacement: to) } + private static func exportCustomWords( + from terms: [ParakeetVocabularyStore.VocabularyConfig.Term] + ) -> [DictionaryTransferCustomWord] { + self.exportCustomWords( + from: terms.map { DictionaryTransferCustomWord(text: $0.text, weight: $0.weight) } + ) + } + + private static func exportCustomWords( + from words: [DictionaryTransferCustomWord] + ) -> [DictionaryTransferCustomWord] { + var seen: Set = [] + var result: [DictionaryTransferCustomWord] = [] + result.reserveCapacity(words.count) + + for word in words { + let text = word.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + let key = text.lowercased() + guard !seen.contains(key) else { continue } + seen.insert(key) + result.append(DictionaryTransferCustomWord(text: text, weight: word.weight)) + } + + return result + } + private static func upsert( _ entry: SettingsStore.CustomDictionaryEntry, into entries: inout [SettingsStore.CustomDictionaryEntry] diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 87f45ec0..fecf613f 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -113,6 +113,7 @@ final class DictationE2ETests: XCTestCase { XCTAssertEqual(state.replacements.first?.replacement, "FluidVoice") XCTAssertEqual(state.customWords.map(\.text), ["FluidVoice", "Barath"]) XCTAssertEqual(state.customWords.map(\.weight), [10.0, 10.0]) + XCTAssertEqual(state.customWords.map(\.aliases), [[], []]) } func testDictionaryTransferImport_mergeDedupesAndMovesDuplicateTriggers() throws { @@ -295,7 +296,8 @@ final class DictationE2ETests: XCTestCase { XCTAssertEqual(state.replacements.count, 0) XCTAssertEqual(state.customWords.map(\.text), ["FluidVoice", "GEMBA-E"]) - XCTAssertEqual(state.customWords.map(\.weight), [10.0, 10.0]) + XCTAssertEqual(state.customWords.map(\.weight), [13.0, 10.0]) + XCTAssertEqual(state.customWords.map(\.aliases), [[], []]) } func testDictionaryTransferImport_acceptsLocalAPICustomWordsResponse() throws { @@ -325,6 +327,8 @@ final class DictationE2ETests: XCTestCase { XCTAssertEqual(state.replacements.count, 0) XCTAssertEqual(state.customWords.map(\.text), ["FluidVoice", "Barath"]) + XCTAssertEqual(state.customWords.map(\.weight), [10.0, 10.0]) + XCTAssertEqual(state.customWords.map(\.aliases), [[], []]) } func testDictationEndToEnd_whisperTiny_transcribesFixture() async throws { @@ -804,7 +808,7 @@ final class DictationE2ETests: XCTestCase { } func testLooksLikeHTML_rejectsLeadingWhitespaceAndBOMVariants() { - let bom: [UInt8] = [0xEF, 0xBB, 0xBF] + let bom: [UInt8] = [0xef, 0xbb, 0xbf] // Leading ASCII whitespace before the markup token. XCTAssertTrue(HuggingFaceModelDownloader.looksLikeHTML(Data(" \n\t".utf8))) @@ -828,9 +832,9 @@ final class DictationE2ETests: XCTestCase { // MIL program text (`model.mil`). XCTAssertFalse(HuggingFaceModelDownloader.looksLikeHTML(Data("program(1.0)\n[buildInfo = ...]".utf8))) // Binary CoreML / Mach-O magic prefix. - XCTAssertFalse(HuggingFaceModelDownloader.looksLikeHTML(Data([0xCF, 0xFA, 0xED, 0xFE, 0x07, 0x00]))) + XCTAssertFalse(HuggingFaceModelDownloader.looksLikeHTML(Data([0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00]))) // Leading-NUL binary (e.g. coremldata.bin / weight.bin style payloads). - XCTAssertFalse(HuggingFaceModelDownloader.looksLikeHTML(Data([0x00, 0x00, 0x01, 0x3C, 0x68]))) + XCTAssertFalse(HuggingFaceModelDownloader.looksLikeHTML(Data([0x00, 0x00, 0x01, 0x3c, 0x68]))) // Empty payload. XCTAssertFalse(HuggingFaceModelDownloader.looksLikeHTML(Data())) // A stray `<` NOT followed by a markup-ish byte must not be over-rejected. @@ -915,7 +919,7 @@ final class DictationE2ETests: XCTestCase { // A loose required file (e.g. a tokenizer) with real binary content. let tokenizerURL = root.appendingPathComponent("tokenizer.model") - try Data([0x0A, 0x09, 0x05, 0x00]).write(to: tokenizerURL) + try Data([0x0a, 0x09, 0x05, 0x00]).write(to: tokenizerURL) let entries = [packageName, "tokenizer.model"] @@ -939,7 +943,7 @@ final class DictationE2ETests: XCTestCase { // Missing entries and an empty required directory are conservative: never flagged corrupt // on uncertainty (incompleteness is the existence check's concern, not this one's). - try Data([0x0A, 0x09, 0x05, 0x00]).write(to: tokenizerURL) + try Data([0x0a, 0x09, 0x05, 0x00]).write(to: tokenizerURL) let emptyPackage = root.appendingPathComponent("empty.mlpackage", isDirectory: true) try FileManager.default.createDirectory(at: emptyPackage, withIntermediateDirectories: true) XCTAssertFalse( From 42e61e98446fb83eab41fb7ed3fca08b54cb5f37 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Fri, 26 Jun 2026 17:12:09 -0700 Subject: [PATCH 4/4] fix dictionary weight test fixture --- Tests/FluidDictationIntegrationTests/DictationE2ETests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index fecf613f..d659e03b 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -277,7 +277,7 @@ final class DictationE2ETests: XCTestCase { { "text": "FluidVoice", "aliases": ["fluid voice"], - "weight": 10.0 + "weight": 13.0 }, { "text": "GEMBA-E"