From 60125c316b9661985b56cf00d5547981fd897708 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 12:09:43 +0545 Subject: [PATCH] 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)