From fe842de807ce23a2592af98feae03a54cd4fd0f2 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sat, 27 Dec 2025 15:27:00 +0545 Subject: [PATCH 01/10] Rename Correct Spellings to Vocabulary and update related terminology --- .../Services/CustomVocabularyService.swift | 2 +- .../DictionaryImportExportService.swift | 18 +++--- VoiceInk/Services/ImportExportService.swift | 12 ++-- .../Dictionary/DictionarySettingsView.swift | 8 +-- ...tionaryView.swift => VocabularyView.swift} | 58 +++++++++---------- 5 files changed, 49 insertions(+), 49 deletions(-) rename VoiceInk/Views/Dictionary/{DictionaryView.swift => VocabularyView.swift} (81%) diff --git a/VoiceInk/Services/CustomVocabularyService.swift b/VoiceInk/Services/CustomVocabularyService.swift index 8181727..fc6706d 100644 --- a/VoiceInk/Services/CustomVocabularyService.swift +++ b/VoiceInk/Services/CustomVocabularyService.swift @@ -24,7 +24,7 @@ class CustomVocabularyService { } do { - let items = try JSONDecoder().decode([DictionaryItem].self, from: data) + let items = try JSONDecoder().decode([VocabularyWord].self, from: data) let words = items.map { $0.word } return words.isEmpty ? nil : words } catch { diff --git a/VoiceInk/Services/DictionaryImportExportService.swift b/VoiceInk/Services/DictionaryImportExportService.swift index 2831268..aaa9ee6 100644 --- a/VoiceInk/Services/DictionaryImportExportService.swift +++ b/VoiceInk/Services/DictionaryImportExportService.swift @@ -4,7 +4,7 @@ import UniformTypeIdentifiers struct DictionaryExportData: Codable { let version: String - let dictionaryItems: [String] + let vocabularyWords: [String] let wordReplacements: [String: String] let exportDate: Date } @@ -19,7 +19,7 @@ class DictionaryImportExportService { func exportDictionary() { var dictionaryWords: [String] = [] if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), - let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + let items = try? JSONDecoder().decode([VocabularyWord].self, from: data) { dictionaryWords = items.map { $0.word } } @@ -29,7 +29,7 @@ class DictionaryImportExportService { let exportData = DictionaryExportData( version: version, - dictionaryItems: dictionaryWords, + vocabularyWords: dictionaryWords, wordReplacements: wordReplacements, exportDate: Date() ) @@ -45,7 +45,7 @@ class DictionaryImportExportService { savePanel.allowedContentTypes = [UTType.json] savePanel.nameFieldStringValue = "VoiceInk_Dictionary.json" savePanel.title = "Export Dictionary Data" - savePanel.message = "Choose a location to save your dictionary items and word replacements." + savePanel.message = "Choose a location to save your vocabulary and word replacements." DispatchQueue.main.async { if savePanel.runModal() == .OK { @@ -88,9 +88,9 @@ class DictionaryImportExportService { decoder.dateDecodingStrategy = .iso8601 let importedData = try decoder.decode(DictionaryExportData.self, from: jsonData) - var existingItems: [DictionaryItem] = [] + var existingItems: [VocabularyWord] = [] if let data = UserDefaults.standard.data(forKey: self.dictionaryItemsKey), - let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + let items = try? JSONDecoder().decode([VocabularyWord].self, from: data) { existingItems = items } @@ -98,9 +98,9 @@ class DictionaryImportExportService { let originalExistingCount = existingItems.count var newWordsAdded = 0 - for importedWord in importedData.dictionaryItems { + for importedWord in importedData.vocabularyWords { if !existingWordsLower.contains(importedWord.lowercased()) { - existingItems.append(DictionaryItem(word: importedWord)) + existingItems.append(VocabularyWord(word: importedWord)) newWordsAdded += 1 } } @@ -147,7 +147,7 @@ class DictionaryImportExportService { UserDefaults.standard.set(existingReplacements, forKey: self.wordReplacementsKey) var message = "Dictionary data imported successfully from \(url.lastPathComponent).\n\n" - message += "Dictionary Items: \(newWordsAdded) added, \(originalExistingCount) kept\n" + message += "Vocabulary Words: \(newWordsAdded) added, \(originalExistingCount) kept\n" message += "Word Replacements: \(addedCount) added, \(updatedCount) updated" self.showAlert(title: "Import Successful", message: message) diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 81c4335..847afb5 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -32,7 +32,7 @@ struct VoiceInkExportedSettings: Codable { let version: String let customPrompts: [CustomPrompt] let powerModeConfigs: [PowerModeConfig] - let dictionaryItems: [DictionaryItem]? + let vocabularyWords: [VocabularyWord]? let wordReplacements: [String: String]? let generalSettings: GeneralSettings? let customEmojis: [String]? @@ -78,9 +78,9 @@ class ImportExportService { // Export custom models let customModels = CustomModelManager.shared.customModels - var exportedDictionaryItems: [DictionaryItem]? = nil + var exportedDictionaryItems: [VocabularyWord]? = nil if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), - let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + let items = try? JSONDecoder().decode([VocabularyWord].self, from: data) { exportedDictionaryItems = items } @@ -114,7 +114,7 @@ class ImportExportService { version: currentSettingsVersion, customPrompts: exportablePrompts, powerModeConfigs: powerConfigs, - dictionaryItems: exportedDictionaryItems, + vocabularyWords: exportedDictionaryItems, wordReplacements: exportedWordReplacements, generalSettings: generalSettingsToExport, customEmojis: emojiManager.customEmojis, @@ -203,12 +203,12 @@ class ImportExportService { } } - if let itemsToImport = importedSettings.dictionaryItems { + if let itemsToImport = importedSettings.vocabularyWords { if let encoded = try? JSONEncoder().encode(itemsToImport) { UserDefaults.standard.set(encoded, forKey: "CustomVocabularyItems") } } else { - print("No custom vocabulary items (for spelling) found in the imported file. Existing items remain unchanged.") + print("No vocabulary words found in the imported file. Existing items remain unchanged.") } if let replacementsToImport = importedSettings.wordReplacements { diff --git a/VoiceInk/Views/Dictionary/DictionarySettingsView.swift b/VoiceInk/Views/Dictionary/DictionarySettingsView.swift index 7fa9b8f..d7fb14c 100644 --- a/VoiceInk/Views/Dictionary/DictionarySettingsView.swift +++ b/VoiceInk/Views/Dictionary/DictionarySettingsView.swift @@ -6,7 +6,7 @@ struct DictionarySettingsView: View { enum DictionarySection: String, CaseIterable { case replacements = "Word Replacements" - case spellings = "Correct Spellings" + case spellings = "Vocabulary" var description: String { switch self { @@ -90,7 +90,7 @@ struct DictionarySettingsView: View { .foregroundColor(.blue) } .buttonStyle(.plain) - .help("Import dictionary items and word replacements") + .help("Import vocabulary and word replacements") Button(action: { DictionaryImportExportService.shared.exportDictionary() @@ -100,7 +100,7 @@ struct DictionarySettingsView: View { .foregroundColor(.blue) } .buttonStyle(.plain) - .help("Export dictionary items and word replacements") + .help("Export vocabulary and word replacements") } } @@ -120,7 +120,7 @@ struct DictionarySettingsView: View { VStack(alignment: .leading, spacing: 20) { switch selectedSection { case .spellings: - DictionaryView(whisperPrompt: whisperPrompt) + VocabularyView(whisperPrompt: whisperPrompt) .background(CardBackground(isSelected: false)) case .replacements: WordReplacementView() diff --git a/VoiceInk/Views/Dictionary/DictionaryView.swift b/VoiceInk/Views/Dictionary/VocabularyView.swift similarity index 81% rename from VoiceInk/Views/Dictionary/DictionaryView.swift rename to VoiceInk/Views/Dictionary/VocabularyView.swift index 9bd67dc..9ebdb42 100644 --- a/VoiceInk/Views/Dictionary/DictionaryView.swift +++ b/VoiceInk/Views/Dictionary/VocabularyView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct DictionaryItem: Identifiable, Hashable, Codable { +struct VocabularyWord: Identifiable, Hashable, Codable { var word: String var id: String { word } @@ -27,13 +27,13 @@ struct DictionaryItem: Identifiable, Hashable, Codable { } } -enum DictionarySortMode: String { +enum VocabularySortMode: String { case wordAsc = "wordAsc" case wordDesc = "wordDesc" } -class DictionaryManager: ObservableObject { - @Published var items: [DictionaryItem] = [] +class VocabularyManager: ObservableObject { + @Published var items: [VocabularyWord] = [] private let saveKey = "CustomVocabularyItems" private let whisperPrompt: WhisperPrompt @@ -45,7 +45,7 @@ class DictionaryManager: ObservableObject { private func loadItems() { guard let data = UserDefaults.standard.data(forKey: saveKey) else { return } - if let savedItems = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + if let savedItems = try? JSONDecoder().decode([VocabularyWord].self, from: data) { items = savedItems } } @@ -62,7 +62,7 @@ class DictionaryManager: ObservableObject { return } - let newItem = DictionaryItem(word: normalizedWord) + let newItem = VocabularyWord(word: normalizedWord) items.insert(newItem, at: 0) saveItems() } @@ -77,36 +77,36 @@ class DictionaryManager: ObservableObject { } } -struct DictionaryView: View { - @StateObject private var dictionaryManager: DictionaryManager +struct VocabularyView: View { + @StateObject private var vocabularyManager: VocabularyManager @ObservedObject var whisperPrompt: WhisperPrompt @State private var newWord = "" @State private var showAlert = false @State private var alertMessage = "" - @State private var sortMode: DictionarySortMode = .wordAsc + @State private var sortMode: VocabularySortMode = .wordAsc init(whisperPrompt: WhisperPrompt) { self.whisperPrompt = whisperPrompt - _dictionaryManager = StateObject(wrappedValue: DictionaryManager(whisperPrompt: whisperPrompt)) + _vocabularyManager = StateObject(wrappedValue: VocabularyManager(whisperPrompt: whisperPrompt)) - if let savedSort = UserDefaults.standard.string(forKey: "dictionarySortMode"), - let mode = DictionarySortMode(rawValue: savedSort) { + if let savedSort = UserDefaults.standard.string(forKey: "vocabularySortMode"), + let mode = VocabularySortMode(rawValue: savedSort) { _sortMode = State(initialValue: mode) } } - private var sortedItems: [DictionaryItem] { + private var sortedItems: [VocabularyWord] { switch sortMode { case .wordAsc: - return dictionaryManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending } + return vocabularyManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending } case .wordDesc: - return dictionaryManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending } + return vocabularyManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending } } } private func toggleSort() { sortMode = (sortMode == .wordAsc) ? .wordDesc : .wordAsc - UserDefaults.standard.set(sortMode.rawValue, forKey: "dictionarySortMode") + UserDefaults.standard.set(sortMode.rawValue, forKey: "vocabularySortMode") } var body: some View { @@ -124,7 +124,7 @@ struct DictionaryView: View { } HStack(spacing: 8) { - TextField("Add word to dictionary", text: $newWord) + TextField("Add word to vocabulary", text: $newWord) .textFieldStyle(.roundedBorder) .font(.system(size: 13)) .onSubmit { addWords() } @@ -140,11 +140,11 @@ struct DictionaryView: View { .help("Add word") } - if !dictionaryManager.items.isEmpty { + if !vocabularyManager.items.isEmpty { VStack(alignment: .leading, spacing: 12) { Button(action: toggleSort) { HStack(spacing: 4) { - Text("Dictionary Items (\(dictionaryManager.items.count))") + Text("Vocabulary Words (\(vocabularyManager.items.count))") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) @@ -159,8 +159,8 @@ struct DictionaryView: View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 240, maximum: .infinity), spacing: 12)], alignment: .leading, spacing: 12) { ForEach(sortedItems) { item in - DictionaryItemView(item: item) { - dictionaryManager.removeWord(item.word) + VocabularyWordView(item: item) { + vocabularyManager.removeWord(item.word) } } } @@ -172,7 +172,7 @@ struct DictionaryView: View { } } .padding() - .alert("Dictionary", isPresented: $showAlert) { + .alert("Vocabulary", isPresented: $showAlert) { Button("OK", role: .cancel) {} } message: { Text(alertMessage) @@ -191,28 +191,28 @@ struct DictionaryView: View { guard !parts.isEmpty else { return } if parts.count == 1, let word = parts.first { - if dictionaryManager.items.contains(where: { $0.word.lowercased() == word.lowercased() }) { - alertMessage = "'\(word)' is already in the dictionary" + if vocabularyManager.items.contains(where: { $0.word.lowercased() == word.lowercased() }) { + alertMessage = "'\(word)' is already in the vocabulary" showAlert = true return } - dictionaryManager.addWord(word) + vocabularyManager.addWord(word) newWord = "" return } for word in parts { let lower = word.lowercased() - if !dictionaryManager.items.contains(where: { $0.word.lowercased() == lower }) { - dictionaryManager.addWord(word) + if !vocabularyManager.items.contains(where: { $0.word.lowercased() == lower }) { + vocabularyManager.addWord(word) } } newWord = "" } } -struct DictionaryItemView: View { - let item: DictionaryItem +struct VocabularyWordView: View { + let item: VocabularyWord let onDelete: () -> Void @State private var isHovered = false From a363745f3624dda3b8bdb4dbd8407e712b23179a Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sat, 27 Dec 2025 18:24:33 +0545 Subject: [PATCH 02/10] Prevent duplicate word replacements --- .../Dictionary/EditReplacementSheet.swift | 21 ++++- .../Dictionary/WordReplacementView.swift | 91 +++++++++++++++---- 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/VoiceInk/Views/Dictionary/EditReplacementSheet.swift b/VoiceInk/Views/Dictionary/EditReplacementSheet.swift index 7dfd91f..ecebe76 100644 --- a/VoiceInk/Views/Dictionary/EditReplacementSheet.swift +++ b/VoiceInk/Views/Dictionary/EditReplacementSheet.swift @@ -8,6 +8,8 @@ struct EditReplacementSheet: View { @State private var originalWord: String @State private var replacementWord: String + @State private var showAlert = false + @State private var alertMessage = "" // MARK: – Initialiser init(manager: WordReplacementManager, originalKey: String) { @@ -24,6 +26,11 @@ struct EditReplacementSheet: View { formContent } .frame(width: 460, height: 560) + .alert("Word Replacement", isPresented: $showAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage) + } } // MARK: – Subviews @@ -115,15 +122,23 @@ struct EditReplacementSheet: View { private func saveChanges() { let newOriginal = originalWord.trimmingCharacters(in: .whitespacesAndNewlines) let newReplacement = replacementWord - // Ensure at least one non-empty token let tokens = newOriginal .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard !tokens.isEmpty, !newReplacement.isEmpty else { return } - manager.updateReplacement(oldOriginal: originalKey, newOriginal: newOriginal, newReplacement: newReplacement) - dismiss() + let result = manager.updateReplacement(oldOriginal: originalKey, newOriginal: newOriginal, newReplacement: newReplacement) + if result.success { + dismiss() + } else { + if let conflictingWord = result.conflictingWord { + alertMessage = "'\(conflictingWord)' already exists in word replacements" + } else { + alertMessage = "This word replacement already exists" + } + showAlert = true + } } } diff --git a/VoiceInk/Views/Dictionary/WordReplacementView.swift b/VoiceInk/Views/Dictionary/WordReplacementView.swift index ef603b4..52fd99d 100644 --- a/VoiceInk/Views/Dictionary/WordReplacementView.swift +++ b/VoiceInk/Views/Dictionary/WordReplacementView.swift @@ -26,24 +26,68 @@ class WordReplacementManager: ObservableObject { init() { self.replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String] ?? [:] } - - func addReplacement(original: String, replacement: String) { - // Preserve comma-separated originals as a single entry + + func addReplacement(original: String, replacement: String) -> (success: Bool, conflictingWord: String?) { let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty else { return (false, nil) } + + let newTokensPairs = trimmed + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { (original: $0, lowercased: $0.lowercased()) } + + for existingKey in replacements.keys { + let existingTokens = existingKey + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + + for tokenPair in newTokensPairs { + if existingTokens.contains(tokenPair.lowercased) { + return (false, tokenPair.original) + } + } + } + replacements[trimmed] = replacement + return (true, nil) } func removeReplacement(original: String) { replacements.removeValue(forKey: original) } - func updateReplacement(oldOriginal: String, newOriginal: String, newReplacement: String) { - // Replace old key with the new comma-preserved key - replacements.removeValue(forKey: oldOriginal) + func updateReplacement(oldOriginal: String, newOriginal: String, newReplacement: String) -> (success: Bool, conflictingWord: String?) { let trimmed = newOriginal.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty else { return (false, nil) } + + let newTokensPairs = trimmed + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { (original: $0, lowercased: $0.lowercased()) } + + for existingKey in replacements.keys { + if existingKey == oldOriginal { + continue + } + + let existingTokens = existingKey + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + + for tokenPair in newTokensPairs { + if existingTokens.contains(tokenPair.lowercased) { + return (false, tokenPair.original) + } + } + } + + replacements.removeValue(forKey: oldOriginal) replacements[trimmed] = newReplacement + return (true, nil) } } @@ -227,7 +271,9 @@ struct AddReplacementSheet: View { @Environment(\.dismiss) private var dismiss @State private var originalWord = "" @State private var replacementWord = "" - + @State private var showAlert = false + @State private var alertMessage = "" + var body: some View { VStack(spacing: 0) { // Header @@ -385,21 +431,34 @@ struct AddReplacementSheet: View { } } .frame(width: 460, height: 520) + .alert("Word Replacement", isPresented: $showAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage) + } } - + private func addReplacement() { - let original = originalWord + let original = originalWord.trimmingCharacters(in: .whitespacesAndNewlines) let replacement = replacementWord - - // Validate that at least one non-empty token exists + let tokens = original .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard !tokens.isEmpty && !replacement.isEmpty else { return } - - manager.addReplacement(original: original, replacement: replacement) - dismiss() + + let result = manager.addReplacement(original: original, replacement: replacement) + if result.success { + dismiss() + } else { + if let conflictingWord = result.conflictingWord { + alertMessage = "'\(conflictingWord)' already exists in word replacements" + } else { + alertMessage = "This word replacement already exists" + } + showAlert = true + } } } From 01c12cdd2d000935b37f594c0556a8ab07e5fef4 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sat, 27 Dec 2025 20:19:33 +0545 Subject: [PATCH 03/10] Refactor word replacements with inline UI --- .../Views/Dictionary/VocabularyView.swift | 29 +- .../Dictionary/WordReplacementView.swift | 546 ++++++++---------- 2 files changed, 246 insertions(+), 329 deletions(-) diff --git a/VoiceInk/Views/Dictionary/VocabularyView.swift b/VoiceInk/Views/Dictionary/VocabularyView.swift index 9ebdb42..ecbd395 100644 --- a/VoiceInk/Views/Dictionary/VocabularyView.swift +++ b/VoiceInk/Views/Dictionary/VocabularyView.swift @@ -109,6 +109,10 @@ struct VocabularyView: View { UserDefaults.standard.set(sortMode.rawValue, forKey: "vocabularySortMode") } + private var shouldShowAddButton: Bool { + !newWord.isEmpty + } + var body: some View { VStack(alignment: .leading, spacing: 20) { GroupBox { @@ -129,16 +133,19 @@ struct VocabularyView: View { .font(.system(size: 13)) .onSubmit { addWords() } - Button(action: addWords) { - Image(systemName: "plus.circle.fill") - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.blue) - .font(.system(size: 16, weight: .semibold)) + if shouldShowAddButton { + Button(action: addWords) { + Image(systemName: "plus.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + .font(.system(size: 16, weight: .semibold)) + } + .buttonStyle(.borderless) + .disabled(newWord.isEmpty) + .help("Add word") } - .buttonStyle(.borderless) - .disabled(newWord.isEmpty) - .help("Add word") } + .animation(.easeInOut(duration: 0.2), value: shouldShowAddButton) if !vocabularyManager.items.isEmpty { VStack(alignment: .leading, spacing: 12) { @@ -214,7 +221,7 @@ struct VocabularyView: View { struct VocabularyWordView: View { let item: VocabularyWord let onDelete: () -> Void - @State private var isHovered = false + @State private var isDeleteHovered = false var body: some View { HStack(spacing: 6) { @@ -228,14 +235,14 @@ struct VocabularyWordView: View { Button(action: onDelete) { Image(systemName: "xmark.circle.fill") .symbolRenderingMode(.hierarchical) - .foregroundStyle(isHovered ? .red : .secondary) + .foregroundStyle(isDeleteHovered ? .red : .secondary) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.borderless) .help("Remove word") .onHover { hover in withAnimation(.easeInOut(duration: 0.2)) { - isHovered = hover + isDeleteHovered = hover } } } diff --git a/VoiceInk/Views/Dictionary/WordReplacementView.swift b/VoiceInk/Views/Dictionary/WordReplacementView.swift index 52fd99d..5cf3f9c 100644 --- a/VoiceInk/Views/Dictionary/WordReplacementView.swift +++ b/VoiceInk/Views/Dictionary/WordReplacementView.swift @@ -93,12 +93,13 @@ class WordReplacementManager: ObservableObject { struct WordReplacementView: View { @StateObject private var manager = WordReplacementManager() - @State private var showAddReplacementModal = false @State private var showAlert = false @State private var editingOriginal: String? = nil - @State private var alertMessage = "" @State private var sortMode: SortMode = .originalAsc + @State private var originalWord = "" + @State private var replacementWord = "" + @State private var showInfoPopover = false init() { if let savedSort = UserDefaults.standard.string(forKey: "wordReplacementSortMode"), @@ -131,6 +132,10 @@ struct WordReplacementView: View { } UserDefaults.standard.set(sortMode.rawValue, forKey: "wordReplacementSortMode") } + + private var shouldShowAddButton: Bool { + !originalWord.isEmpty || !replacementWord.isEmpty + } var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -141,296 +146,118 @@ struct WordReplacementView: View { .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: "info.circle.fill") - .foregroundColor(.blue) + Button(action: { showInfoPopover.toggle() }) { + Image(systemName: "info.circle.fill") + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .popover(isPresented: $showInfoPopover) { + WordReplacementInfoPopover() + } } } - - VStack(spacing: 0) { - HStack(spacing: 16) { - Button(action: { toggleSort(for: .original) }) { - HStack(spacing: 4) { - Text("Original") - .font(.headline) - - if sortMode == .originalAsc || sortMode == .originalDesc { - Image(systemName: sortMode == .originalAsc ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + + HStack(spacing: 8) { + TextField("Original text (use commas for multiple)", text: $originalWord) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13)) + + Image(systemName: "arrow.right") + .foregroundColor(.secondary) + .font(.system(size: 10)) + .frame(width: 10) + + TextField("Replacement text", text: $replacementWord) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13)) + .onSubmit { addReplacement() } + + if shouldShowAddButton { + Button(action: addReplacement) { + Image(systemName: "plus.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + .font(.system(size: 16, weight: .semibold)) } - .buttonStyle(.plain) - - Image(systemName: "arrow.right") - .foregroundColor(.secondary) - .font(.system(size: 12)) - .frame(width: 20) - - Button(action: { toggleSort(for: .replacement) }) { - HStack(spacing: 4) { - Text("Replacement") - .font(.headline) - - if sortMode == .replacementAsc || sortMode == .replacementDesc { - Image(systemName: sortMode == .replacementAsc ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - HStack(spacing: 8) { - Button(action: { showAddReplacementModal = true }) { - Image(systemName: "plus") - } - .buttonStyle(.borderless) - } - .frame(width: 60) + .buttonStyle(.borderless) + .disabled(originalWord.isEmpty || replacementWord.isEmpty) + .help("Add word replacement") } - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color(.controlBackgroundColor)) - - Divider() - - // Content - if manager.replacements.isEmpty { - EmptyStateView(showAddModal: $showAddReplacementModal) - } else { + } + .animation(.easeInOut(duration: 0.2), value: shouldShowAddButton) + + if !manager.replacements.isEmpty { + VStack(spacing: 0) { + HStack(spacing: 8) { + Button(action: { toggleSort(for: .original) }) { + HStack(spacing: 4) { + Text("Original") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + if sortMode == .originalAsc || sortMode == .originalDesc { + Image(systemName: sortMode == .originalAsc ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.accentColor) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .help("Sort by original") + + Image(systemName: "arrow.right") + .foregroundColor(.secondary) + .font(.system(size: 10)) + .frame(width: 10) + + Button(action: { toggleSort(for: .replacement) }) { + HStack(spacing: 4) { + Text("Replacement") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + if sortMode == .replacementAsc || sortMode == .replacementDesc { + Image(systemName: sortMode == .replacementAsc ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.accentColor) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .help("Sort by replacement") + } + .padding(.horizontal, 4) + .padding(.vertical, 8) + + Divider() + ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(sortedReplacements.enumerated()), id: \.offset) { index, pair in + ForEach(sortedReplacements, id: \.key) { pair in ReplacementRow( original: pair.key, replacement: pair.value, onDelete: { manager.removeReplacement(original: pair.key) }, onEdit: { editingOriginal = pair.key } ) - - if index != sortedReplacements.count - 1 { + + if pair.key != sortedReplacements.last?.key { Divider() - .padding(.leading, 32) } } } - .background(Color(.controlBackgroundColor)) } + .frame(maxHeight: 300) } + .padding(.top, 4) } } .padding() - .sheet(isPresented: $showAddReplacementModal) { - AddReplacementSheet(manager: manager) - } - // Edit existing replacement .sheet(item: $editingOriginal) { original in EditReplacementSheet(manager: manager, originalKey: original) } - - } -} - -struct EmptyStateView: View { - @Binding var showAddModal: Bool - - var body: some View { - VStack(spacing: 12) { - Image(systemName: "text.word.spacing") - .font(.system(size: 32)) - .foregroundColor(.secondary) - - Text("No Replacements") - .font(.headline) - - Text("Add word replacements to automatically replace text.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 250) - - Button("Add Replacement") { - showAddModal = true - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .padding(.top, 8) - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -struct AddReplacementSheet: View { - @ObservedObject var manager: WordReplacementManager - @Environment(\.dismiss) private var dismiss - @State private var originalWord = "" - @State private var replacementWord = "" - @State private var showAlert = false - @State private var alertMessage = "" - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button("Cancel", role: .cancel) { - dismiss() - } - .buttonStyle(.borderless) - .keyboardShortcut(.escape, modifiers: []) - - Spacer() - - Text("Add Word Replacement") - .font(.headline) - - Spacer() - - Button("Add") { - addReplacement() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(originalWord.isEmpty || replacementWord.isEmpty) - .keyboardShortcut(.return, modifiers: []) - } - .padding(.horizontal) - .padding(.vertical, 12) - .background(CardBackground(isSelected: false)) - - Divider() - - ScrollView { - VStack(spacing: 20) { - // Description - Text("Define a word or phrase to be automatically replaced.") - .font(.subheadline) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.top, 8) - - // Form Content - VStack(spacing: 16) { - // Original Text Section - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Original Text") - .font(.headline) - .foregroundColor(.primary) - - Text("Required") - .font(.caption) - .foregroundColor(.secondary) - } - - TextField("Enter word or phrase to replace (use commas for multiple)", text: $originalWord) - .textFieldStyle(.roundedBorder) - .font(.body) - Text("Separate multiple originals with commas, e.g. Voicing, Voice ink, Voiceing") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal) - - // Replacement Text Section - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Replacement Text") - .font(.headline) - .foregroundColor(.primary) - - Text("Required") - .font(.caption) - .foregroundColor(.secondary) - } - - TextEditor(text: $replacementWord) - .font(.body) - .frame(height: 100) - .padding(8) - .background(Color(.textBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(.separatorColor), lineWidth: 1) - ) - } - .padding(.horizontal) - } - - // Example Section - VStack(alignment: .leading, spacing: 8) { - Text("Examples") - .font(.subheadline) - .foregroundColor(.secondary) - - // Single original -> replacement - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Original:") - .font(.caption) - .foregroundColor(.secondary) - Text("my website link") - .font(.callout) - } - - Image(systemName: "arrow.right") - .font(.caption) - .foregroundColor(.secondary) - - VStack(alignment: .leading, spacing: 4) { - Text("Replacement:") - .font(.caption) - .foregroundColor(.secondary) - Text("https://tryvoiceink.com") - .font(.callout) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color(.textBackgroundColor)) - .cornerRadius(8) - - // Comma-separated originals -> single replacement - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Original:") - .font(.caption) - .foregroundColor(.secondary) - Text("Voicing, Voice ink, Voiceing") - .font(.callout) - } - - Image(systemName: "arrow.right") - .font(.caption) - .foregroundColor(.secondary) - - VStack(alignment: .leading, spacing: 4) { - Text("Replacement:") - .font(.caption) - .foregroundColor(.secondary) - Text("VoiceInk") - .font(.callout) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color(.textBackgroundColor)) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.top, 8) - } - .padding(.vertical) - } - } - .frame(width: 460, height: 520) .alert("Word Replacement", isPresented: $showAlert) { Button("OK", role: .cancel) {} } message: { @@ -440,7 +267,7 @@ struct AddReplacementSheet: View { private func addReplacement() { let original = originalWord.trimmingCharacters(in: .whitespacesAndNewlines) - let replacement = replacementWord + let replacement = replacementWord.trimmingCharacters(in: .whitespacesAndNewlines) let tokens = original .split(separator: ",") @@ -450,7 +277,8 @@ struct AddReplacementSheet: View { let result = manager.addReplacement(original: original, replacement: replacement) if result.success { - dismiss() + originalWord = "" + replacementWord = "" } else { if let conflictingWord = result.conflictingWord { alertMessage = "'\(conflictingWord)' already exists in word replacements" @@ -462,68 +290,150 @@ struct AddReplacementSheet: View { } } +struct WordReplacementInfoPopover: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("How to use Word Replacements") + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + Text("Separate multiple originals with commas:") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Voicing, Voice ink, Voiceing") + .font(.callout) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.textBackgroundColor)) + .cornerRadius(6) + } + + Divider() + + Text("Examples") + .font(.subheadline) + .foregroundColor(.secondary) + + VStack(spacing: 12) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("Original:") + .font(.caption) + .foregroundColor(.secondary) + Text("my website link") + .font(.callout) + } + + Image(systemName: "arrow.right") + .font(.caption) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Replacement:") + .font(.caption) + .foregroundColor(.secondary) + Text("https://tryvoiceink.com") + .font(.callout) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.textBackgroundColor)) + .cornerRadius(6) + + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("Original:") + .font(.caption) + .foregroundColor(.secondary) + Text("Voicing, Voice ink") + .font(.callout) + } + + Image(systemName: "arrow.right") + .font(.caption) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Replacement:") + .font(.caption) + .foregroundColor(.secondary) + Text("VoiceInk") + .font(.callout) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.textBackgroundColor)) + .cornerRadius(6) + } + } + .padding() + .frame(width: 380) + } +} + struct ReplacementRow: View { let original: String let replacement: String let onDelete: () -> Void let onEdit: () -> Void - + @State private var isEditHovered = false + @State private var isDeleteHovered = false + var body: some View { - HStack(spacing: 16) { - // Original Text Container - HStack { - Text(original) - .font(.body) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(.textBackgroundColor)) - .cornerRadius(6) - } - .frame(maxWidth: .infinity) - - // Arrow + HStack(spacing: 8) { + Text(original) + .font(.system(size: 13)) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "arrow.right") .foregroundColor(.secondary) - .font(.system(size: 12)) - - // Replacement Text Container - HStack { + .font(.system(size: 10)) + .frame(width: 10) + + ZStack(alignment: .trailing) { Text(replacement) - .font(.body) + .font(.system(size: 13)) .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(.textBackgroundColor)) - .cornerRadius(6) + .padding(.trailing, 50) + + HStack(spacing: 6) { + Button(action: onEdit) { + Image(systemName: "pencil.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundColor(isEditHovered ? .accentColor : .secondary) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.borderless) + .help("Edit replacement") + .onHover { hover in + withAnimation(.easeInOut(duration: 0.2)) { + isEditHovered = hover + } + } + + Button(action: onDelete) { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(isDeleteHovered ? .red : .secondary) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.borderless) + .help("Remove replacement") + .onHover { hover in + withAnimation(.easeInOut(duration: 0.2)) { + isDeleteHovered = hover + } + } + } } .frame(maxWidth: .infinity) - - // Edit Button - Button(action: onEdit) { - Image(systemName: "pencil.circle.fill") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.accentColor) - .font(.system(size: 16)) - } - .buttonStyle(.borderless) - .help("Edit replacement") - - // Delete Button - Button(action: onDelete) { - Image(systemName: "xmark.circle.fill") - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.red) - .font(.system(size: 16)) - } - .buttonStyle(.borderless) - .help("Remove replacement") } - .padding(.horizontal) .padding(.vertical, 8) - .contentShape(Rectangle()) - .background(Color(.controlBackgroundColor)) + .padding(.horizontal, 4) } } \ No newline at end of file From 2a9bf12e0ec680a4c5d89c50ebb250420322ad03 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sat, 27 Dec 2025 21:20:26 +0545 Subject: [PATCH 04/10] Remove unused isEnabled property from VocabularyWord --- VoiceInk/Views/Dictionary/VocabularyView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/VoiceInk/Views/Dictionary/VocabularyView.swift b/VoiceInk/Views/Dictionary/VocabularyView.swift index ecbd395..d76cb10 100644 --- a/VoiceInk/Views/Dictionary/VocabularyView.swift +++ b/VoiceInk/Views/Dictionary/VocabularyView.swift @@ -10,7 +10,7 @@ struct VocabularyWord: Identifiable, Hashable, Codable { } private enum CodingKeys: String, CodingKey { - case id, word, dateAdded, isEnabled + case id, word, dateAdded } init(from decoder: Decoder) throws { @@ -18,7 +18,6 @@ struct VocabularyWord: Identifiable, Hashable, Codable { word = try container.decode(String.self, forKey: .word) _ = try? container.decodeIfPresent(UUID.self, forKey: .id) _ = try? container.decodeIfPresent(Date.self, forKey: .dateAdded) - _ = try? container.decodeIfPresent(Bool.self, forKey: .isEnabled) } func encode(to encoder: Encoder) throws { From 60125c316b9661985b56cf00d5547981fd897708 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:09:43 +0545 Subject: [PATCH 05/10] Migrate dictionary data from UserDefaults to SwiftData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates vocabulary words and word replacements from UserDefaults to SwiftData for better data management and persistence. Changes: - Create VocabularyWord and WordReplacement SwiftData models - Add dual ModelConfiguration setup (default.store for transcripts, dictionary.store for dictionary data) - Implement DictionaryMigrationService for one-time UserDefaults→SwiftData migration - Rename "Correct Spellings" to "Vocabulary" for clearer terminology - Update all dictionary views to use @Query instead of manager classes - Update all services to fetch from SwiftData using FetchDescriptor - Enhance word replacement duplicate detection (now checks during add AND edit) - Update import/export services to work with SwiftData - Preserve all existing functionality with improved data integrity Technical details: - Separate store files: default.store (transcripts) + dictionary.store (vocabulary + replacements) - Migration flag: "HasMigratedDictionaryToSwiftData_v2" - All CRUD operations properly implemented with duplicate detection --- VoiceInk/Models/VocabularyWord.swift | 13 ++ VoiceInk/Models/WordReplacement.swift | 19 +++ .../AIEnhancement/AIEnhancementService.swift | 2 +- .../AudioFileTranscriptionManager.swift | 2 +- .../AudioFileTranscriptionService.swift | 2 +- .../Services/CustomVocabularyService.swift | 27 +--- .../DictionaryImportExportService.swift | 67 ++++---- .../Services/DictionaryMigrationService.swift | 99 ++++++++++++ VoiceInk/Services/ImportExportService.swift | 46 ++++-- .../Services/WordReplacementService.swift | 29 ++-- .../Dictionary/DictionarySettingsView.swift | 6 +- .../Dictionary/EditReplacementSheet.swift | 68 ++++---- .../Views/Dictionary/VocabularyView.swift | 118 ++++---------- .../Dictionary/WordReplacementView.swift | 153 ++++++------------ VoiceInk/VoiceInk.swift | 124 +++++++------- VoiceInk/Whisper/WhisperState.swift | 2 +- 16 files changed, 426 insertions(+), 351 deletions(-) create mode 100644 VoiceInk/Models/VocabularyWord.swift create mode 100644 VoiceInk/Models/WordReplacement.swift create mode 100644 VoiceInk/Services/DictionaryMigrationService.swift diff --git a/VoiceInk/Models/VocabularyWord.swift b/VoiceInk/Models/VocabularyWord.swift new file mode 100644 index 0000000..2813e83 --- /dev/null +++ b/VoiceInk/Models/VocabularyWord.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData + +@Model +final class VocabularyWord { + @Attribute(.unique) var word: String + var dateAdded: Date + + init(word: String, dateAdded: Date = Date()) { + self.word = word + self.dateAdded = dateAdded + } +} diff --git a/VoiceInk/Models/WordReplacement.swift b/VoiceInk/Models/WordReplacement.swift new file mode 100644 index 0000000..7efac38 --- /dev/null +++ b/VoiceInk/Models/WordReplacement.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftData + +@Model +final class WordReplacement { + var id: UUID + var originalText: String + var replacementText: String + var dateAdded: Date + var isEnabled: Bool + + init(originalText: String, replacementText: String, dateAdded: Date = Date(), isEnabled: Bool = true) { + self.id = UUID() + self.originalText = originalText + self.replacementText = replacementText + self.dateAdded = dateAdded + self.isEnabled = isEnabled + } +} diff --git a/VoiceInk/Services/AIEnhancement/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancement/AIEnhancementService.swift index 48df532..676dcb6 100644 --- a/VoiceInk/Services/AIEnhancement/AIEnhancementService.swift +++ b/VoiceInk/Services/AIEnhancement/AIEnhancementService.swift @@ -167,7 +167,7 @@ class AIEnhancementService: ObservableObject { "" } - let customVocabulary = customVocabularyService.getCustomVocabulary() + let customVocabulary = customVocabularyService.getCustomVocabulary(from: modelContext) let allContextSections = selectedTextContext + clipboardContext + screenCaptureContext diff --git a/VoiceInk/Services/AudioFileTranscriptionManager.swift b/VoiceInk/Services/AudioFileTranscriptionManager.swift index 54d8df4..57418ae 100644 --- a/VoiceInk/Services/AudioFileTranscriptionManager.swift +++ b/VoiceInk/Services/AudioFileTranscriptionManager.swift @@ -96,7 +96,7 @@ class AudioTranscriptionManager: ObservableObject { text = WhisperTextFormatter.format(text) } - text = WordReplacementService.shared.applyReplacements(to: text) + text = WordReplacementService.shared.applyReplacements(to: text, using: modelContext) // Handle enhancement if enabled if let enhancementService = whisperState.enhancementService, diff --git a/VoiceInk/Services/AudioFileTranscriptionService.swift b/VoiceInk/Services/AudioFileTranscriptionService.swift index 959853b..223f8bd 100644 --- a/VoiceInk/Services/AudioFileTranscriptionService.swift +++ b/VoiceInk/Services/AudioFileTranscriptionService.swift @@ -55,7 +55,7 @@ class AudioTranscriptionService: ObservableObject { text = WhisperTextFormatter.format(text) } - text = WordReplacementService.shared.applyReplacements(to: text) + text = WordReplacementService.shared.applyReplacements(to: text, using: modelContext) logger.notice("✅ Word replacements applied") let audioAsset = AVURLAsset(url: url) diff --git a/VoiceInk/Services/CustomVocabularyService.swift b/VoiceInk/Services/CustomVocabularyService.swift index fc6706d..1e9cdce 100644 --- a/VoiceInk/Services/CustomVocabularyService.swift +++ b/VoiceInk/Services/CustomVocabularyService.swift @@ -1,16 +1,14 @@ import Foundation import SwiftUI +import SwiftData class CustomVocabularyService { static let shared = CustomVocabularyService() - private init() { - // Migrate old key to new key if needed - migrateOldDataIfNeeded() - } + private init() {} - func getCustomVocabulary() -> String { - guard let customWords = getCustomVocabularyWords(), !customWords.isEmpty else { + func getCustomVocabulary(from context: ModelContext) -> String { + guard let customWords = getCustomVocabularyWords(from: context), !customWords.isEmpty else { return "" } @@ -18,26 +16,15 @@ class CustomVocabularyService { return "Important Vocabulary: \(wordsText)" } - private func getCustomVocabularyWords() -> [String]? { - guard let data = UserDefaults.standard.data(forKey: "CustomVocabularyItems") else { - return nil - } + private func getCustomVocabularyWords(from context: ModelContext) -> [String]? { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\VocabularyWord.word)]) do { - let items = try JSONDecoder().decode([VocabularyWord].self, from: data) + let items = try context.fetch(descriptor) let words = items.map { $0.word } return words.isEmpty ? nil : words } catch { return nil } } - - private func migrateOldDataIfNeeded() { - // Migrate from old "CustomDictionaryItems" key to new "CustomVocabularyItems" key - if UserDefaults.standard.data(forKey: "CustomVocabularyItems") == nil, - let oldData = UserDefaults.standard.data(forKey: "CustomDictionaryItems") { - UserDefaults.standard.set(oldData, forKey: "CustomVocabularyItems") - UserDefaults.standard.removeObject(forKey: "CustomDictionaryItems") - } - } } diff --git a/VoiceInk/Services/DictionaryImportExportService.swift b/VoiceInk/Services/DictionaryImportExportService.swift index aaa9ee6..ae1407e 100644 --- a/VoiceInk/Services/DictionaryImportExportService.swift +++ b/VoiceInk/Services/DictionaryImportExportService.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import UniformTypeIdentifiers +import SwiftData struct DictionaryExportData: Codable { let version: String @@ -11,19 +12,23 @@ struct DictionaryExportData: Codable { class DictionaryImportExportService { static let shared = DictionaryImportExportService() - private let dictionaryItemsKey = "CustomVocabularyItems" - private let wordReplacementsKey = "wordReplacements" private init() {} - func exportDictionary() { + func exportDictionary(from context: ModelContext) { + // Fetch vocabulary words from SwiftData var dictionaryWords: [String] = [] - if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), - let items = try? JSONDecoder().decode([VocabularyWord].self, from: data) { + let vocabularyDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\VocabularyWord.word)]) + if let items = try? context.fetch(vocabularyDescriptor) { dictionaryWords = items.map { $0.word } } - let wordReplacements = UserDefaults.standard.dictionary(forKey: wordReplacementsKey) as? [String: String] ?? [:] + // Fetch word replacements from SwiftData + var wordReplacements: [String: String] = [:] + let replacementsDescriptor = FetchDescriptor() + if let replacements = try? context.fetch(replacementsDescriptor) { + wordReplacements = Dictionary(uniqueKeysWithValues: replacements.map { ($0.originalText, $0.replacementText) }) + } let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" @@ -66,7 +71,7 @@ class DictionaryImportExportService { } } - func importDictionary() { + func importDictionary(into context: ModelContext) { let openPanel = NSOpenPanel() openPanel.allowedContentTypes = [UTType.json] openPanel.canChooseFiles = true @@ -88,63 +93,65 @@ class DictionaryImportExportService { decoder.dateDecodingStrategy = .iso8601 let importedData = try decoder.decode(DictionaryExportData.self, from: jsonData) - var existingItems: [VocabularyWord] = [] - if let data = UserDefaults.standard.data(forKey: self.dictionaryItemsKey), - let items = try? JSONDecoder().decode([VocabularyWord].self, from: data) { - existingItems = items - } - + // Fetch existing vocabulary words from SwiftData + let vocabularyDescriptor = FetchDescriptor() + let existingItems = (try? context.fetch(vocabularyDescriptor)) ?? [] let existingWordsLower = Set(existingItems.map { $0.word.lowercased() }) let originalExistingCount = existingItems.count var newWordsAdded = 0 + // Import vocabulary words for importedWord in importedData.vocabularyWords { if !existingWordsLower.contains(importedWord.lowercased()) { - existingItems.append(VocabularyWord(word: importedWord)) + let newWord = VocabularyWord(word: importedWord) + context.insert(newWord) newWordsAdded += 1 } } - if let encoded = try? JSONEncoder().encode(existingItems) { - UserDefaults.standard.set(encoded, forKey: self.dictionaryItemsKey) - } - - var existingReplacements = UserDefaults.standard.dictionary(forKey: self.wordReplacementsKey) as? [String: String] ?? [:] + // Fetch existing word replacements from SwiftData + let replacementsDescriptor = FetchDescriptor() + let existingReplacements = (try? context.fetch(replacementsDescriptor)) ?? [] var addedCount = 0 var updatedCount = 0 + // Import word replacements for (importedKey, importedReplacement) in importedData.wordReplacements { let normalizedImportedKey = self.normalizeReplacementKey(importedKey) let importedWords = self.extractWords(from: normalizedImportedKey) - var modifiedExisting: [String: String] = [:] - for (existingKey, existingReplacement) in existingReplacements { - var existingWords = self.extractWords(from: existingKey) + // Check for conflicts and update existing replacements + var hasConflict = false + for existingReplacement in existingReplacements { + var existingWords = self.extractWords(from: existingReplacement.originalText) var modified = false for importedWord in importedWords { if let index = existingWords.firstIndex(where: { $0.lowercased() == importedWord.lowercased() }) { existingWords.remove(at: index) modified = true + hasConflict = true } } - if !existingWords.isEmpty { - let newKey = existingWords.joined(separator: ", ") - modifiedExisting[newKey] = existingReplacement - } - if modified { + if existingWords.isEmpty { + context.delete(existingReplacement) + } else { + existingReplacement.originalText = existingWords.joined(separator: ", ") + } updatedCount += 1 } } - existingReplacements = modifiedExisting - existingReplacements[normalizedImportedKey] = importedReplacement + // Add new replacement + let newReplacement = WordReplacement(originalText: normalizedImportedKey, replacementText: importedReplacement) + context.insert(newReplacement) addedCount += 1 } - UserDefaults.standard.set(existingReplacements, forKey: self.wordReplacementsKey) + // Save all changes + try context.save() var message = "Dictionary data imported successfully from \(url.lastPathComponent).\n\n" message += "Vocabulary Words: \(newWordsAdded) added, \(originalExistingCount) kept\n" diff --git a/VoiceInk/Services/DictionaryMigrationService.swift b/VoiceInk/Services/DictionaryMigrationService.swift new file mode 100644 index 0000000..031ad23 --- /dev/null +++ b/VoiceInk/Services/DictionaryMigrationService.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData +import OSLog + +class DictionaryMigrationService { + static let shared = DictionaryMigrationService() + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "DictionaryMigration") + + private let migrationCompletedKey = "HasMigratedDictionaryToSwiftData_v2" + private let vocabularyKey = "CustomVocabularyItems" + private let wordReplacementsKey = "wordReplacements" + + private init() {} + + /// Migrates dictionary data from UserDefaults to SwiftData + /// This is a one-time operation that preserves all existing user data + func migrateIfNeeded(context: ModelContext) { + // Check if migration has already been completed + if UserDefaults.standard.bool(forKey: migrationCompletedKey) { + logger.info("Dictionary migration already completed, skipping") + return + } + + logger.info("Starting dictionary migration from UserDefaults to SwiftData") + + var vocabularyMigrated = 0 + var replacementsMigrated = 0 + + // Migrate vocabulary words + if let data = UserDefaults.standard.data(forKey: vocabularyKey) { + do { + // Decode old vocabulary structure + let decoder = JSONDecoder() + let oldVocabulary = try decoder.decode([OldVocabularyWord].self, from: data) + + logger.info("Found \(oldVocabulary.count) vocabulary words to migrate") + + for oldWord in oldVocabulary { + let newWord = VocabularyWord(word: oldWord.word) + context.insert(newWord) + vocabularyMigrated += 1 + } + + logger.info("Successfully migrated \(vocabularyMigrated) vocabulary words") + } catch { + logger.error("Failed to migrate vocabulary words: \(error.localizedDescription)") + } + } else { + logger.info("No vocabulary words found to migrate") + } + + // Migrate word replacements + if let replacements = UserDefaults.standard.dictionary(forKey: wordReplacementsKey) as? [String: String] { + logger.info("Found \(replacements.count) word replacements to migrate") + + for (originalText, replacementText) in replacements { + let wordReplacement = WordReplacement( + originalText: originalText, + replacementText: replacementText + ) + context.insert(wordReplacement) + replacementsMigrated += 1 + } + + logger.info("Successfully migrated \(replacementsMigrated) word replacements") + } else { + logger.info("No word replacements found to migrate") + } + + // Save the migrated data + do { + try context.save() + logger.info("Successfully saved migrated data to SwiftData") + + // Mark migration as completed + UserDefaults.standard.set(true, forKey: migrationCompletedKey) + logger.info("Migration completed successfully") + } catch { + logger.error("Failed to save migrated data: \(error.localizedDescription)") + } + } +} + +// Legacy structure for decoding old vocabulary data +private struct OldVocabularyWord: Decodable { + let word: String + + private enum CodingKeys: String, CodingKey { + case id, word, dateAdded + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + word = try container.decode(String.self, forKey: .word) + // Ignore other fields that may exist in old format + _ = try? container.decodeIfPresent(UUID.self, forKey: .id) + _ = try? container.decodeIfPresent(Date.self, forKey: .dateAdded) + } +} diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 847afb5..9bfdad4 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -3,6 +3,7 @@ import AppKit import UniformTypeIdentifiers import KeyboardShortcuts import LaunchAtLogin +import SwiftData struct GeneralSettings: Codable { let toggleMiniRecorderShortcut: KeyboardShortcuts.Shortcut? @@ -28,11 +29,16 @@ struct GeneralSettings: Codable { let clipboardRestoreDelay: Double? } +// Simple codable struct for vocabulary words (for export/import only) +struct VocabularyWordData: Codable { + let word: String +} + struct VoiceInkExportedSettings: Codable { let version: String let customPrompts: [CustomPrompt] let powerModeConfigs: [PowerModeConfig] - let vocabularyWords: [VocabularyWord]? + let vocabularyWords: [VocabularyWordData]? let wordReplacements: [String: String]? let generalSettings: GeneralSettings? let customEmojis: [String]? @@ -78,13 +84,19 @@ class ImportExportService { // Export custom models let customModels = CustomModelManager.shared.customModels - var exportedDictionaryItems: [VocabularyWord]? = nil - if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), - let items = try? JSONDecoder().decode([VocabularyWord].self, from: data) { - exportedDictionaryItems = items + // Fetch vocabulary words from SwiftData + var exportedDictionaryItems: [VocabularyWordData]? = nil + let vocabularyDescriptor = FetchDescriptor() + if let items = try? whisperState.modelContext.fetch(vocabularyDescriptor), !items.isEmpty { + exportedDictionaryItems = items.map { VocabularyWordData(word: $0.word) } } - let exportedWordReplacements = UserDefaults.standard.dictionary(forKey: wordReplacementsKey) as? [String: String] + // Fetch word replacements from SwiftData + var exportedWordReplacements: [String: String]? = nil + let replacementsDescriptor = FetchDescriptor() + if let replacements = try? whisperState.modelContext.fetch(replacementsDescriptor), !replacements.isEmpty { + exportedWordReplacements = Dictionary(uniqueKeysWithValues: replacements.map { ($0.originalText, $0.replacementText) }) + } let generalSettingsToExport = GeneralSettings( toggleMiniRecorderShortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder), @@ -203,16 +215,32 @@ class ImportExportService { } } + // Import vocabulary words to SwiftData if let itemsToImport = importedSettings.vocabularyWords { - if let encoded = try? JSONEncoder().encode(itemsToImport) { - UserDefaults.standard.set(encoded, forKey: "CustomVocabularyItems") + let vocabularyDescriptor = FetchDescriptor() + let existingWords = (try? whisperState.modelContext.fetch(vocabularyDescriptor)) ?? [] + let existingWordsSet = Set(existingWords.map { $0.word.lowercased() }) + + for item in itemsToImport { + if !existingWordsSet.contains(item.word.lowercased()) { + let newWord = VocabularyWord(word: item.word) + whisperState.modelContext.insert(newWord) + } } + try? whisperState.modelContext.save() + print("Successfully imported vocabulary words to SwiftData.") } else { print("No vocabulary words found in the imported file. Existing items remain unchanged.") } + // Import word replacements to SwiftData if let replacementsToImport = importedSettings.wordReplacements { - UserDefaults.standard.set(replacementsToImport, forKey: self.wordReplacementsKey) + for (original, replacement) in replacementsToImport { + let newReplacement = WordReplacement(originalText: original, replacementText: replacement) + whisperState.modelContext.insert(newReplacement) + } + try? whisperState.modelContext.save() + print("Successfully imported word replacements to SwiftData.") } else { print("No word replacements found in the imported file. Existing replacements remain unchanged.") } diff --git a/VoiceInk/Services/WordReplacementService.swift b/VoiceInk/Services/WordReplacementService.swift index 09a72d4..787e063 100644 --- a/VoiceInk/Services/WordReplacementService.swift +++ b/VoiceInk/Services/WordReplacementService.swift @@ -1,20 +1,27 @@ import Foundation +import SwiftData class WordReplacementService { static let shared = WordReplacementService() - + private init() {} - - func applyReplacements(to text: String) -> String { - guard let replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String], - !replacements.isEmpty else { + + func applyReplacements(to text: String, using context: ModelContext) -> String { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.isEnabled } + ) + + guard let replacements = try? context.fetch(descriptor), !replacements.isEmpty else { return text // No replacements to apply } - + var modifiedText = text - + // Apply replacements (case-insensitive) - for (originalGroup, replacement) in replacements { + for replacement in replacements { + let originalGroup = replacement.originalText + let replacementText = replacement.replacementText + // Split comma-separated originals at apply time only let variants = originalGroup .split(separator: ",") @@ -33,16 +40,16 @@ class WordReplacementService { in: modifiedText, options: [], range: range, - withTemplate: replacement + withTemplate: replacementText ) } } else { // Fallback substring replace for non-spaced scripts - modifiedText = modifiedText.replacingOccurrences(of: original, with: replacement, options: .caseInsensitive) + modifiedText = modifiedText.replacingOccurrences(of: original, with: replacementText, options: .caseInsensitive) } } } - + return modifiedText } diff --git a/VoiceInk/Views/Dictionary/DictionarySettingsView.swift b/VoiceInk/Views/Dictionary/DictionarySettingsView.swift index d7fb14c..f02668a 100644 --- a/VoiceInk/Views/Dictionary/DictionarySettingsView.swift +++ b/VoiceInk/Views/Dictionary/DictionarySettingsView.swift @@ -1,6 +1,8 @@ import SwiftUI +import SwiftData struct DictionarySettingsView: View { + @Environment(\.modelContext) private var modelContext @State private var selectedSection: DictionarySection = .replacements let whisperPrompt: WhisperPrompt @@ -83,7 +85,7 @@ struct DictionarySettingsView: View { HStack(spacing: 12) { Button(action: { - DictionaryImportExportService.shared.importDictionary() + DictionaryImportExportService.shared.importDictionary(into: modelContext) }) { Image(systemName: "square.and.arrow.down") .font(.system(size: 18)) @@ -93,7 +95,7 @@ struct DictionarySettingsView: View { .help("Import vocabulary and word replacements") Button(action: { - DictionaryImportExportService.shared.exportDictionary() + DictionaryImportExportService.shared.exportDictionary(from: modelContext) }) { Image(systemName: "square.and.arrow.up") .font(.system(size: 18)) diff --git a/VoiceInk/Views/Dictionary/EditReplacementSheet.swift b/VoiceInk/Views/Dictionary/EditReplacementSheet.swift index ecebe76..723b8c5 100644 --- a/VoiceInk/Views/Dictionary/EditReplacementSheet.swift +++ b/VoiceInk/Views/Dictionary/EditReplacementSheet.swift @@ -1,8 +1,10 @@ import SwiftUI +import SwiftData + // Edit existing word replacement entry struct EditReplacementSheet: View { - @ObservedObject var manager: WordReplacementManager - let originalKey: String + let replacement: WordReplacement + let modelContext: ModelContext @Environment(\.dismiss) private var dismiss @@ -12,11 +14,11 @@ struct EditReplacementSheet: View { @State private var alertMessage = "" // MARK: – Initialiser - init(manager: WordReplacementManager, originalKey: String) { - self.manager = manager - self.originalKey = originalKey - _originalWord = State(initialValue: originalKey) - _replacementWord = State(initialValue: manager.replacements[originalKey] ?? "") + init(replacement: WordReplacement, modelContext: ModelContext) { + self.replacement = replacement + self.modelContext = modelContext + _originalWord = State(initialValue: replacement.originalText) + _replacementWord = State(initialValue: replacement.replacementText) } var body: some View { @@ -128,25 +130,37 @@ struct EditReplacementSheet: View { .filter { !$0.isEmpty } guard !tokens.isEmpty, !newReplacement.isEmpty else { return } - let result = manager.updateReplacement(oldOriginal: originalKey, newOriginal: newOriginal, newReplacement: newReplacement) - if result.success { - dismiss() - } else { - if let conflictingWord = result.conflictingWord { - alertMessage = "'\(conflictingWord)' already exists in word replacements" - } else { - alertMessage = "This word replacement already exists" - } - showAlert = true - } - } -} + // Check for duplicates (excluding current replacement) + let newTokensPairs = tokens.map { (original: $0, lowercased: $0.lowercased()) } -// MARK: – Preview -#if DEBUG -struct EditReplacementSheet_Previews: PreviewProvider { - static var previews: some View { - EditReplacementSheet(manager: WordReplacementManager(), originalKey: "hello") + let descriptor = FetchDescriptor() + if let allReplacements = try? modelContext.fetch(descriptor) { + for existingReplacement in allReplacements { + // Skip checking against itself + if existingReplacement.id == replacement.id { + continue + } + + let existingTokens = existingReplacement.originalText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + + for tokenPair in newTokensPairs { + if existingTokens.contains(tokenPair.lowercased) { + alertMessage = "'\(tokenPair.original)' already exists in word replacements" + showAlert = true + return + } + } + } + } + + // Update the replacement + replacement.originalText = newOriginal + replacement.replacementText = newReplacement + try? modelContext.save() + + dismiss() } -} -#endif \ No newline at end of file +} \ No newline at end of file diff --git a/VoiceInk/Views/Dictionary/VocabularyView.swift b/VoiceInk/Views/Dictionary/VocabularyView.swift index d76cb10..5137f23 100644 --- a/VoiceInk/Views/Dictionary/VocabularyView.swift +++ b/VoiceInk/Views/Dictionary/VocabularyView.swift @@ -1,83 +1,14 @@ import SwiftUI - -struct VocabularyWord: Identifiable, Hashable, Codable { - var word: String - - var id: String { word } - - init(word: String) { - self.word = word - } - - private enum CodingKeys: String, CodingKey { - case id, word, dateAdded - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - word = try container.decode(String.self, forKey: .word) - _ = try? container.decodeIfPresent(UUID.self, forKey: .id) - _ = try? container.decodeIfPresent(Date.self, forKey: .dateAdded) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(word, forKey: .word) - } -} +import SwiftData enum VocabularySortMode: String { case wordAsc = "wordAsc" case wordDesc = "wordDesc" } -class VocabularyManager: ObservableObject { - @Published var items: [VocabularyWord] = [] - private let saveKey = "CustomVocabularyItems" - private let whisperPrompt: WhisperPrompt - - init(whisperPrompt: WhisperPrompt) { - self.whisperPrompt = whisperPrompt - loadItems() - } - - private func loadItems() { - guard let data = UserDefaults.standard.data(forKey: saveKey) else { return } - - if let savedItems = try? JSONDecoder().decode([VocabularyWord].self, from: data) { - items = savedItems - } - } - - private func saveItems() { - if let encoded = try? JSONEncoder().encode(items) { - UserDefaults.standard.set(encoded, forKey: saveKey) - } - } - - func addWord(_ word: String) { - let normalizedWord = word.trimmingCharacters(in: .whitespacesAndNewlines) - guard !items.contains(where: { $0.word.lowercased() == normalizedWord.lowercased() }) else { - return - } - - let newItem = VocabularyWord(word: normalizedWord) - items.insert(newItem, at: 0) - saveItems() - } - - func removeWord(_ word: String) { - items.removeAll(where: { $0.word == word }) - saveItems() - } - - var allWords: [String] { - items.map { $0.word } - } -} - struct VocabularyView: View { - @StateObject private var vocabularyManager: VocabularyManager + @Query private var vocabularyWords: [VocabularyWord] + @Environment(\.modelContext) private var modelContext @ObservedObject var whisperPrompt: WhisperPrompt @State private var newWord = "" @State private var showAlert = false @@ -86,7 +17,6 @@ struct VocabularyView: View { init(whisperPrompt: WhisperPrompt) { self.whisperPrompt = whisperPrompt - _vocabularyManager = StateObject(wrappedValue: VocabularyManager(whisperPrompt: whisperPrompt)) if let savedSort = UserDefaults.standard.string(forKey: "vocabularySortMode"), let mode = VocabularySortMode(rawValue: savedSort) { @@ -97,9 +27,9 @@ struct VocabularyView: View { private var sortedItems: [VocabularyWord] { switch sortMode { case .wordAsc: - return vocabularyManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending } + return vocabularyWords.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending } case .wordDesc: - return vocabularyManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending } + return vocabularyWords.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending } } } @@ -146,11 +76,11 @@ struct VocabularyView: View { } .animation(.easeInOut(duration: 0.2), value: shouldShowAddButton) - if !vocabularyManager.items.isEmpty { + if !vocabularyWords.isEmpty { VStack(alignment: .leading, spacing: 12) { Button(action: toggleSort) { HStack(spacing: 4) { - Text("Vocabulary Words (\(vocabularyManager.items.count))") + Text("Vocabulary Words (\(vocabularyWords.count))") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) @@ -166,7 +96,7 @@ struct VocabularyView: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 240, maximum: .infinity), spacing: 12)], alignment: .leading, spacing: 12) { ForEach(sortedItems) { item in VocabularyWordView(item: item) { - vocabularyManager.removeWord(item.word) + removeWord(item) } } } @@ -188,33 +118,49 @@ struct VocabularyView: View { private func addWords() { let input = newWord.trimmingCharacters(in: .whitespacesAndNewlines) guard !input.isEmpty else { return } - + let parts = input .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - + guard !parts.isEmpty else { return } - + if parts.count == 1, let word = parts.first { - if vocabularyManager.items.contains(where: { $0.word.lowercased() == word.lowercased() }) { + if vocabularyWords.contains(where: { $0.word.lowercased() == word.lowercased() }) { alertMessage = "'\(word)' is already in the vocabulary" showAlert = true return } - vocabularyManager.addWord(word) + addWord(word) newWord = "" return } - + for word in parts { let lower = word.lowercased() - if !vocabularyManager.items.contains(where: { $0.word.lowercased() == lower }) { - vocabularyManager.addWord(word) + if !vocabularyWords.contains(where: { $0.word.lowercased() == lower }) { + addWord(word) } } newWord = "" } + + private func addWord(_ word: String) { + let normalizedWord = word.trimmingCharacters(in: .whitespacesAndNewlines) + guard !vocabularyWords.contains(where: { $0.word.lowercased() == normalizedWord.lowercased() }) else { + return + } + + let newWord = VocabularyWord(word: normalizedWord) + modelContext.insert(newWord) + try? modelContext.save() + } + + private func removeWord(_ word: VocabularyWord) { + modelContext.delete(word) + try? modelContext.save() + } } struct VocabularyWordView: View { diff --git a/VoiceInk/Views/Dictionary/WordReplacementView.swift b/VoiceInk/Views/Dictionary/WordReplacementView.swift index 5cf3f9c..fc0ccf2 100644 --- a/VoiceInk/Views/Dictionary/WordReplacementView.swift +++ b/VoiceInk/Views/Dictionary/WordReplacementView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData extension String: Identifiable { public var id: String { self } @@ -16,110 +17,34 @@ enum SortColumn { case replacement } -class WordReplacementManager: ObservableObject { - @Published var replacements: [String: String] { - didSet { - UserDefaults.standard.set(replacements, forKey: "wordReplacements") - } - } - - init() { - self.replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String] ?? [:] - } - - func addReplacement(original: String, replacement: String) -> (success: Bool, conflictingWord: String?) { - let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return (false, nil) } - - let newTokensPairs = trimmed - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { (original: $0, lowercased: $0.lowercased()) } - - for existingKey in replacements.keys { - let existingTokens = existingKey - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - - for tokenPair in newTokensPairs { - if existingTokens.contains(tokenPair.lowercased) { - return (false, tokenPair.original) - } - } - } - - replacements[trimmed] = replacement - return (true, nil) - } - - func removeReplacement(original: String) { - replacements.removeValue(forKey: original) - } - - func updateReplacement(oldOriginal: String, newOriginal: String, newReplacement: String) -> (success: Bool, conflictingWord: String?) { - let trimmed = newOriginal.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return (false, nil) } - - let newTokensPairs = trimmed - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { (original: $0, lowercased: $0.lowercased()) } - - for existingKey in replacements.keys { - if existingKey == oldOriginal { - continue - } - - let existingTokens = existingKey - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - - for tokenPair in newTokensPairs { - if existingTokens.contains(tokenPair.lowercased) { - return (false, tokenPair.original) - } - } - } - - replacements.removeValue(forKey: oldOriginal) - replacements[trimmed] = newReplacement - return (true, nil) - } -} - struct WordReplacementView: View { - @StateObject private var manager = WordReplacementManager() + @Query private var wordReplacements: [WordReplacement] + @Environment(\.modelContext) private var modelContext @State private var showAlert = false - @State private var editingOriginal: String? = nil + @State private var editingReplacement: WordReplacement? = nil @State private var alertMessage = "" @State private var sortMode: SortMode = .originalAsc @State private var originalWord = "" @State private var replacementWord = "" @State private var showInfoPopover = false - + init() { if let savedSort = UserDefaults.standard.string(forKey: "wordReplacementSortMode"), let mode = SortMode(rawValue: savedSort) { _sortMode = State(initialValue: mode) } } - - private var sortedReplacements: [(key: String, value: String)] { - let pairs = Array(manager.replacements) - + + private var sortedReplacements: [WordReplacement] { switch sortMode { case .originalAsc: - return pairs.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + return wordReplacements.sorted { $0.originalText.localizedCaseInsensitiveCompare($1.originalText) == .orderedAscending } case .originalDesc: - return pairs.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedDescending } + return wordReplacements.sorted { $0.originalText.localizedCaseInsensitiveCompare($1.originalText) == .orderedDescending } case .replacementAsc: - return pairs.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedAscending } + return wordReplacements.sorted { $0.replacementText.localizedCaseInsensitiveCompare($1.replacementText) == .orderedAscending } case .replacementDesc: - return pairs.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedDescending } + return wordReplacements.sorted { $0.replacementText.localizedCaseInsensitiveCompare($1.replacementText) == .orderedDescending } } } @@ -186,7 +111,7 @@ struct WordReplacementView: View { } .animation(.easeInOut(duration: 0.2), value: shouldShowAddButton) - if !manager.replacements.isEmpty { + if !wordReplacements.isEmpty { VStack(spacing: 0) { HStack(spacing: 8) { Button(action: { toggleSort(for: .original) }) { @@ -235,15 +160,15 @@ struct WordReplacementView: View { ScrollView { LazyVStack(spacing: 0) { - ForEach(sortedReplacements, id: \.key) { pair in + ForEach(sortedReplacements) { replacement in ReplacementRow( - original: pair.key, - replacement: pair.value, - onDelete: { manager.removeReplacement(original: pair.key) }, - onEdit: { editingOriginal = pair.key } + original: replacement.originalText, + replacement: replacement.replacementText, + onDelete: { removeReplacement(replacement) }, + onEdit: { editingReplacement = replacement } ) - if pair.key != sortedReplacements.last?.key { + if replacement.id != sortedReplacements.last?.id { Divider() } } @@ -255,8 +180,8 @@ struct WordReplacementView: View { } } .padding() - .sheet(item: $editingOriginal) { original in - EditReplacementSheet(manager: manager, originalKey: original) + .sheet(item: $editingReplacement) { replacement in + EditReplacementSheet(replacement: replacement, modelContext: modelContext) } .alert("Word Replacement", isPresented: $showAlert) { Button("OK", role: .cancel) {} @@ -275,18 +200,36 @@ struct WordReplacementView: View { .filter { !$0.isEmpty } guard !tokens.isEmpty && !replacement.isEmpty else { return } - let result = manager.addReplacement(original: original, replacement: replacement) - if result.success { - originalWord = "" - replacementWord = "" - } else { - if let conflictingWord = result.conflictingWord { - alertMessage = "'\(conflictingWord)' already exists in word replacements" - } else { - alertMessage = "This word replacement already exists" + // Check for duplicates + let newTokensPairs = tokens.map { (original: $0, lowercased: $0.lowercased()) } + + for existingReplacement in wordReplacements { + let existingTokens = existingReplacement.originalText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + + for tokenPair in newTokensPairs { + if existingTokens.contains(tokenPair.lowercased) { + alertMessage = "'\(tokenPair.original)' already exists in word replacements" + showAlert = true + return + } } - showAlert = true } + + // Add new replacement + let newReplacement = WordReplacement(originalText: original, replacementText: replacement) + modelContext.insert(newReplacement) + try? modelContext.save() + + originalWord = "" + replacementWord = "" + } + + private func removeReplacement(_ replacement: WordReplacement) { + modelContext.delete(replacement) + try? modelContext.save() } } diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 6eaa3ff..77d8c19 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -42,13 +42,17 @@ struct VoiceInkApp: App { } let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Initialization") - let schema = Schema([Transcription.self]) + let schema = Schema([ + Transcription.self, + VocabularyWord.self, + WordReplacement.self + ]) var initializationFailed = false // Attempt 1: Try persistent storage if let persistentContainer = Self.createPersistentContainer(schema: schema, logger: logger) { container = persistentContainer - + #if DEBUG // Print SwiftData storage location in debug builds only if let url = persistentContainer.mainContext.container.configurations.first?.url { @@ -59,9 +63,9 @@ struct VoiceInkApp: App { // Attempt 2: Try in-memory storage else if let memoryContainer = Self.createInMemoryContainer(schema: schema, logger: logger) { container = memoryContainer - + logger.warning("Using in-memory storage as fallback. Data will not persist between sessions.") - + // Show alert to user about storage issue DispatchQueue.main.async { let alert = NSAlert() @@ -72,19 +76,16 @@ struct VoiceInkApp: App { alert.runModal() } } - // Attempt 3: Try ultra-minimal default container - else if let minimalContainer = Self.createMinimalContainer(schema: schema, logger: logger) { - container = minimalContainer - logger.warning("Using minimal emergency container") - } - // All attempts failed: Create disabled container and mark for termination + // All attempts failed else { - logger.critical("All ModelContainer initialization attempts failed") + logger.critical("ModelContainer initialization failed") initializationFailed = true - - // Create a dummy container to satisfy Swift's initialization requirements - // App will show error and terminate in onAppear - container = Self.createDummyContainer(schema: schema) + + // Create minimal in-memory container to satisfy initialization + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + container = (try? ModelContainer(for: schema, configurations: [config])) ?? { + preconditionFailure("Unable to create ModelContainer. SwiftData is unavailable.") + }() } containerInitializationFailed = initializationFailed @@ -134,15 +135,37 @@ struct VoiceInkApp: App { // Create app-specific Application Support directory URL let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("com.prakashjoshipax.VoiceInk", isDirectory: true) - + // Create the directory if it doesn't exist try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true) - - // Configure SwiftData to use the conventional location - let storeURL = appSupportURL.appendingPathComponent("default.store") - let modelConfiguration = ModelConfiguration(schema: schema, url: storeURL) - - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + + // Define storage locations + let defaultStoreURL = appSupportURL.appendingPathComponent("default.store") + let dictionaryStoreURL = appSupportURL.appendingPathComponent("dictionary.store") + + // Transcript configuration + let transcriptSchema = Schema([Transcription.self]) + let transcriptConfig = ModelConfiguration( + "default", + schema: transcriptSchema, + url: defaultStoreURL, + cloudKitDatabase: .none + ) + + // Dictionary configuration + let dictionarySchema = Schema([VocabularyWord.self, WordReplacement.self]) + let dictionaryConfig = ModelConfiguration( + "dictionary", + schema: dictionarySchema, + url: dictionaryStoreURL, + cloudKitDatabase: .none + ) + + // Initialize container + return try ModelContainer( + for: schema, + configurations: transcriptConfig, dictionaryConfig + ) } catch { logger.error("Failed to create persistent ModelContainer: \(error.localizedDescription)") return nil @@ -151,45 +174,29 @@ struct VoiceInkApp: App { private static func createInMemoryContainer(schema: Schema, logger: Logger) -> ModelContainer? { do { - let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) - return try ModelContainer(for: schema, configurations: [configuration]) + // Transcript configuration + let transcriptSchema = Schema([Transcription.self]) + let transcriptConfig = ModelConfiguration( + "default", + schema: transcriptSchema, + isStoredInMemoryOnly: true + ) + + // Dictionary configuration + let dictionarySchema = Schema([VocabularyWord.self, WordReplacement.self]) + let dictionaryConfig = ModelConfiguration( + "dictionary", + schema: dictionarySchema, + isStoredInMemoryOnly: true + ) + + return try ModelContainer(for: schema, configurations: transcriptConfig, dictionaryConfig) } catch { logger.error("Failed to create in-memory ModelContainer: \(error.localizedDescription)") return nil } } - private static func createMinimalContainer(schema: Schema, logger: Logger) -> ModelContainer? { - do { - // Try default initializer without custom configuration - return try ModelContainer(for: schema) - } catch { - logger.error("Failed to create minimal ModelContainer: \(error.localizedDescription)") - return nil - } - } - - private static func createDummyContainer(schema: Schema) -> ModelContainer { - // Create an absolute minimal container for initialization - // This uses in-memory storage and will never actually be used - // as the app will show an error and terminate in onAppear - let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) - - // Note: In-memory containers should always succeed unless SwiftData itself is unavailable - // (which would indicate a serious system-level issue). We use preconditionFailure here - // rather than fatalError because: - // 1. This code is only reached after 3 prior initialization attempts have failed - // 2. An in-memory container failing indicates SwiftData is completely unavailable - // 3. Swift requires non-optional container property to be initialized - // 4. The app will immediately terminate in onAppear when containerInitializationFailed is checked - do { - return try ModelContainer(for: schema, configurations: [config]) - } catch { - // This indicates a system-level SwiftData failure - app cannot function - preconditionFailure("Unable to create even a dummy ModelContainer. SwiftData is unavailable: \(error)") - } - } - var body: some Scene { WindowGroup { if hasCompletedOnboarding { @@ -210,11 +217,14 @@ struct VoiceInkApp: App { alert.alertStyle = .critical alert.addButton(withTitle: "Quit") alert.runModal() - + NSApplication.shared.terminate(nil) return } - + + // Migrate dictionary data from UserDefaults to SwiftData (one-time operation) + DictionaryMigrationService.shared.migrateIfNeeded(context: container.mainContext) + updaterViewModel.silentlyCheckForUpdates() if enableAnnouncements { AnnouncementsService.shared.start() diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index d8aa71f..6d11e38 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -316,7 +316,7 @@ class WhisperState: NSObject, ObservableObject { logger.notice("📝 Formatted transcript: \(text, privacy: .public)") } - text = WordReplacementService.shared.applyReplacements(to: text) + text = WordReplacementService.shared.applyReplacements(to: text, using: modelContext) logger.notice("📝 WordReplacement: \(text, privacy: .public)") let audioAsset = AVURLAsset(url: url) From 4e551926e7b3efd2d9ca2ee716350bcd96602487 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:16:59 +0545 Subject: [PATCH 06/10] Fix Soniox vocabulary integration to read from SwiftData --- .../CloudTranscriptionService.swift | 10 ++++++++-- .../SonioxTranscriptionService.swift | 20 ++++++++++++------- .../TranscriptionServiceRegistry.swift | 2 +- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/VoiceInk/Services/CloudTranscription/CloudTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/CloudTranscriptionService.swift index 75af540..4ac7131 100644 --- a/VoiceInk/Services/CloudTranscription/CloudTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/CloudTranscriptionService.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftData import os enum CloudTranscriptionError: Error, LocalizedError { @@ -34,14 +35,19 @@ enum CloudTranscriptionError: Error, LocalizedError { } class CloudTranscriptionService: TranscriptionService { - + private let modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + private lazy var groqService = GroqTranscriptionService() private lazy var elevenLabsService = ElevenLabsTranscriptionService() private lazy var deepgramService = DeepgramTranscriptionService() private lazy var mistralService = MistralTranscriptionService() private lazy var geminiService = GeminiTranscriptionService() private lazy var openAICompatibleService = OpenAICompatibleTranscriptionService() - private lazy var sonioxService = SonioxTranscriptionService() + private lazy var sonioxService = SonioxTranscriptionService(modelContext: modelContext) func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { var text: String diff --git a/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift index 49dd357..95cece6 100644 --- a/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift @@ -1,7 +1,13 @@ import Foundation +import SwiftData class SonioxTranscriptionService { private let apiBase = "https://api.soniox.com/v1" + private let modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { let config = try getAPIConfig(for: model) @@ -170,16 +176,16 @@ class SonioxTranscriptionService { } private func getCustomDictionaryTerms() -> [String] { - guard let data = UserDefaults.standard.data(forKey: "CustomVocabularyItems") else { + // Fetch vocabulary words from SwiftData + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.word)]) + guard let vocabularyWords = try? modelContext.fetch(descriptor) else { return [] } - // Decode without depending on UI layer types; extract "word" strings - guard let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - return [] - } - let words = json.compactMap { $0["word"] as? String } - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + let words = vocabularyWords + .map { $0.word.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + // De-duplicate while preserving order var seen = Set() var unique: [String] = [] diff --git a/VoiceInk/Services/TranscriptionServiceRegistry.swift b/VoiceInk/Services/TranscriptionServiceRegistry.swift index a7703ea..5171a82 100644 --- a/VoiceInk/Services/TranscriptionServiceRegistry.swift +++ b/VoiceInk/Services/TranscriptionServiceRegistry.swift @@ -12,7 +12,7 @@ class TranscriptionServiceRegistry { modelsDirectory: modelsDirectory, whisperState: whisperState ) - private(set) lazy var cloudTranscriptionService = CloudTranscriptionService() + private(set) lazy var cloudTranscriptionService = CloudTranscriptionService(modelContext: whisperState.modelContext) private(set) lazy var nativeAppleTranscriptionService = NativeAppleTranscriptionService() private(set) lazy var parakeetTranscriptionService = ParakeetTranscriptionService() From a6310431679dd207ad85466169da615f47b80aff Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:19:36 +0545 Subject: [PATCH 07/10] Add error handling for dictionary save operations --- .../Dictionary/EditReplacementSheet.swift | 9 +++++++-- .../Views/Dictionary/VocabularyView.swift | 16 ++++++++++++++-- .../Dictionary/WordReplacementView.swift | 19 +++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/VoiceInk/Views/Dictionary/EditReplacementSheet.swift b/VoiceInk/Views/Dictionary/EditReplacementSheet.swift index 723b8c5..482c076 100644 --- a/VoiceInk/Views/Dictionary/EditReplacementSheet.swift +++ b/VoiceInk/Views/Dictionary/EditReplacementSheet.swift @@ -159,8 +159,13 @@ struct EditReplacementSheet: View { // Update the replacement replacement.originalText = newOriginal replacement.replacementText = newReplacement - try? modelContext.save() - dismiss() + do { + try modelContext.save() + dismiss() + } catch { + alertMessage = "Failed to save changes: \(error.localizedDescription)" + showAlert = true + } } } \ No newline at end of file diff --git a/VoiceInk/Views/Dictionary/VocabularyView.swift b/VoiceInk/Views/Dictionary/VocabularyView.swift index 5137f23..4ae3f9e 100644 --- a/VoiceInk/Views/Dictionary/VocabularyView.swift +++ b/VoiceInk/Views/Dictionary/VocabularyView.swift @@ -154,12 +154,24 @@ struct VocabularyView: View { let newWord = VocabularyWord(word: normalizedWord) modelContext.insert(newWord) - try? modelContext.save() + + do { + try modelContext.save() + } catch { + alertMessage = "Failed to add word: \(error.localizedDescription)" + showAlert = true + } } private func removeWord(_ word: VocabularyWord) { modelContext.delete(word) - try? modelContext.save() + + do { + try modelContext.save() + } catch { + alertMessage = "Failed to remove word: \(error.localizedDescription)" + showAlert = true + } } } diff --git a/VoiceInk/Views/Dictionary/WordReplacementView.swift b/VoiceInk/Views/Dictionary/WordReplacementView.swift index fc0ccf2..aa8aae6 100644 --- a/VoiceInk/Views/Dictionary/WordReplacementView.swift +++ b/VoiceInk/Views/Dictionary/WordReplacementView.swift @@ -221,15 +221,26 @@ struct WordReplacementView: View { // Add new replacement let newReplacement = WordReplacement(originalText: original, replacementText: replacement) modelContext.insert(newReplacement) - try? modelContext.save() - originalWord = "" - replacementWord = "" + do { + try modelContext.save() + originalWord = "" + replacementWord = "" + } catch { + alertMessage = "Failed to add replacement: \(error.localizedDescription)" + showAlert = true + } } private func removeReplacement(_ replacement: WordReplacement) { modelContext.delete(replacement) - try? modelContext.save() + + do { + try modelContext.save() + } catch { + alertMessage = "Failed to remove replacement: \(error.localizedDescription)" + showAlert = true + } } } From 7beb63e3c686848e94c962bce6d6a3eeef56a626 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:26:50 +0545 Subject: [PATCH 08/10] Prevent crashes and duplicates in import operations --- .../DictionaryImportExportService.swift | 5 ++-- VoiceInk/Services/ImportExportService.swift | 29 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/VoiceInk/Services/DictionaryImportExportService.swift b/VoiceInk/Services/DictionaryImportExportService.swift index ae1407e..20f4415 100644 --- a/VoiceInk/Services/DictionaryImportExportService.swift +++ b/VoiceInk/Services/DictionaryImportExportService.swift @@ -27,7 +27,8 @@ class DictionaryImportExportService { var wordReplacements: [String: String] = [:] let replacementsDescriptor = FetchDescriptor() if let replacements = try? context.fetch(replacementsDescriptor) { - wordReplacements = Dictionary(uniqueKeysWithValues: replacements.map { ($0.originalText, $0.replacementText) }) + // Use uniquingKeysWith to handle potential duplicates gracefully (keep first occurrence) + wordReplacements = Dictionary(replacements.map { ($0.originalText, $0.replacementText) }, uniquingKeysWith: { first, _ in first }) } let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" @@ -121,7 +122,6 @@ class DictionaryImportExportService { let importedWords = self.extractWords(from: normalizedImportedKey) // Check for conflicts and update existing replacements - var hasConflict = false for existingReplacement in existingReplacements { var existingWords = self.extractWords(from: existingReplacement.originalText) var modified = false @@ -130,7 +130,6 @@ class DictionaryImportExportService { if let index = existingWords.firstIndex(where: { $0.lowercased() == importedWord.lowercased() }) { existingWords.remove(at: index) modified = true - hasConflict = true } } diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 9bfdad4..386c2bb 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -235,9 +235,34 @@ class ImportExportService { // Import word replacements to SwiftData if let replacementsToImport = importedSettings.wordReplacements { + let replacementsDescriptor = FetchDescriptor() + let existingReplacements = (try? whisperState.modelContext.fetch(replacementsDescriptor)) ?? [] + + // Build a set of existing replacement keys for duplicate checking + var existingKeysSet = Set() + for existing in existingReplacements { + let tokens = existing.originalText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + existingKeysSet.formUnion(tokens) + } + for (original, replacement) in replacementsToImport { - let newReplacement = WordReplacement(originalText: original, replacementText: replacement) - whisperState.modelContext.insert(newReplacement) + let importTokens = original + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + + // Check if any token already exists + let hasConflict = importTokens.contains { existingKeysSet.contains($0) } + + if !hasConflict { + let newReplacement = WordReplacement(originalText: original, replacementText: replacement) + whisperState.modelContext.insert(newReplacement) + // Add these tokens to the set to prevent duplicates within the import + existingKeysSet.formUnion(importTokens) + } } try? whisperState.modelContext.save() print("Successfully imported word replacements to SwiftData.") From bf3c035e58a36a021d7ab6be2736e09753a397da Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:32:14 +0545 Subject: [PATCH 09/10] Add rollback for failed dictionary operations --- VoiceInk/Views/Dictionary/VocabularyView.swift | 4 ++++ VoiceInk/Views/Dictionary/WordReplacementView.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/VoiceInk/Views/Dictionary/VocabularyView.swift b/VoiceInk/Views/Dictionary/VocabularyView.swift index 4ae3f9e..3d322fc 100644 --- a/VoiceInk/Views/Dictionary/VocabularyView.swift +++ b/VoiceInk/Views/Dictionary/VocabularyView.swift @@ -158,6 +158,8 @@ struct VocabularyView: View { do { try modelContext.save() } catch { + // Rollback the insert to maintain UI consistency + modelContext.delete(newWord) alertMessage = "Failed to add word: \(error.localizedDescription)" showAlert = true } @@ -169,6 +171,8 @@ struct VocabularyView: View { do { try modelContext.save() } catch { + // Rollback the delete to restore UI consistency + modelContext.rollback() alertMessage = "Failed to remove word: \(error.localizedDescription)" showAlert = true } diff --git a/VoiceInk/Views/Dictionary/WordReplacementView.swift b/VoiceInk/Views/Dictionary/WordReplacementView.swift index aa8aae6..7cc038d 100644 --- a/VoiceInk/Views/Dictionary/WordReplacementView.swift +++ b/VoiceInk/Views/Dictionary/WordReplacementView.swift @@ -227,6 +227,8 @@ struct WordReplacementView: View { originalWord = "" replacementWord = "" } catch { + // Rollback the insert to maintain UI consistency + modelContext.delete(newReplacement) alertMessage = "Failed to add replacement: \(error.localizedDescription)" showAlert = true } @@ -238,6 +240,8 @@ struct WordReplacementView: View { do { try modelContext.save() } catch { + // Rollback the delete to restore UI consistency + modelContext.rollback() alertMessage = "Failed to remove replacement: \(error.localizedDescription)" showAlert = true } From 93f8811d77861c95f1839842fd607b693c50c779 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:47:21 +0545 Subject: [PATCH 10/10] Add missing rollback in dictionary import error handling --- VoiceInk/Services/DictionaryImportExportService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VoiceInk/Services/DictionaryImportExportService.swift b/VoiceInk/Services/DictionaryImportExportService.swift index 20f4415..1920f1b 100644 --- a/VoiceInk/Services/DictionaryImportExportService.swift +++ b/VoiceInk/Services/DictionaryImportExportService.swift @@ -159,6 +159,8 @@ class DictionaryImportExportService { self.showAlert(title: "Import Successful", message: message) } catch { + // Rollback any unsaved changes to maintain consistency + context.rollback() self.showAlert(title: "Import Error", message: "Error importing dictionary data: \(error.localizedDescription). The file might be corrupted or not in the correct format.") } } else {