Merge pull request #454 from Beingpax/dictionary-refactor

Improve Dictionary Feature
This commit is contained in:
Prakash Joshi Pax 2025-12-28 13:11:25 +05:45 committed by GitHub
commit 9d75109c72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 792 additions and 653 deletions

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -167,7 +167,7 @@ class AIEnhancementService: ObservableObject {
"" ""
} }
let customVocabulary = customVocabularyService.getCustomVocabulary() let customVocabulary = customVocabularyService.getCustomVocabulary(from: modelContext)
let allContextSections = selectedTextContext + clipboardContext + screenCaptureContext let allContextSections = selectedTextContext + clipboardContext + screenCaptureContext

View File

@ -96,7 +96,7 @@ class AudioTranscriptionManager: ObservableObject {
text = WhisperTextFormatter.format(text) text = WhisperTextFormatter.format(text)
} }
text = WordReplacementService.shared.applyReplacements(to: text) text = WordReplacementService.shared.applyReplacements(to: text, using: modelContext)
// Handle enhancement if enabled // Handle enhancement if enabled
if let enhancementService = whisperState.enhancementService, if let enhancementService = whisperState.enhancementService,

View File

@ -55,7 +55,7 @@ class AudioTranscriptionService: ObservableObject {
text = WhisperTextFormatter.format(text) text = WhisperTextFormatter.format(text)
} }
text = WordReplacementService.shared.applyReplacements(to: text) text = WordReplacementService.shared.applyReplacements(to: text, using: modelContext)
logger.notice("✅ Word replacements applied") logger.notice("✅ Word replacements applied")
let audioAsset = AVURLAsset(url: url) let audioAsset = AVURLAsset(url: url)

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import SwiftData
import os import os
enum CloudTranscriptionError: Error, LocalizedError { enum CloudTranscriptionError: Error, LocalizedError {
@ -34,14 +35,19 @@ enum CloudTranscriptionError: Error, LocalizedError {
} }
class CloudTranscriptionService: TranscriptionService { class CloudTranscriptionService: TranscriptionService {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
private lazy var groqService = GroqTranscriptionService() private lazy var groqService = GroqTranscriptionService()
private lazy var elevenLabsService = ElevenLabsTranscriptionService() private lazy var elevenLabsService = ElevenLabsTranscriptionService()
private lazy var deepgramService = DeepgramTranscriptionService() private lazy var deepgramService = DeepgramTranscriptionService()
private lazy var mistralService = MistralTranscriptionService() private lazy var mistralService = MistralTranscriptionService()
private lazy var geminiService = GeminiTranscriptionService() private lazy var geminiService = GeminiTranscriptionService()
private lazy var openAICompatibleService = OpenAICompatibleTranscriptionService() 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 { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
var text: String var text: String

View File

@ -1,7 +1,13 @@
import Foundation import Foundation
import SwiftData
class SonioxTranscriptionService { class SonioxTranscriptionService {
private let apiBase = "https://api.soniox.com/v1" 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 { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
let config = try getAPIConfig(for: model) let config = try getAPIConfig(for: model)
@ -170,16 +176,16 @@ class SonioxTranscriptionService {
} }
private func getCustomDictionaryTerms() -> [String] { private func getCustomDictionaryTerms() -> [String] {
guard let data = UserDefaults.standard.data(forKey: "CustomVocabularyItems") else { // Fetch vocabulary words from SwiftData
let descriptor = FetchDescriptor<VocabularyWord>(sortBy: [SortDescriptor(\.word)])
guard let vocabularyWords = try? modelContext.fetch(descriptor) else {
return [] return []
} }
// Decode without depending on UI layer types; extract "word" strings
guard let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { let words = vocabularyWords
return [] .map { $0.word.trimmingCharacters(in: .whitespacesAndNewlines) }
}
let words = json.compactMap { $0["word"] as? String }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
// De-duplicate while preserving order // De-duplicate while preserving order
var seen = Set<String>() var seen = Set<String>()
var unique: [String] = [] var unique: [String] = []

View File

@ -1,16 +1,14 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
class CustomVocabularyService { class CustomVocabularyService {
static let shared = CustomVocabularyService() static let shared = CustomVocabularyService()
private init() { private init() {}
// Migrate old key to new key if needed
migrateOldDataIfNeeded()
}
func getCustomVocabulary() -> String { func getCustomVocabulary(from context: ModelContext) -> String {
guard let customWords = getCustomVocabularyWords(), !customWords.isEmpty else { guard let customWords = getCustomVocabularyWords(from: context), !customWords.isEmpty else {
return "" return ""
} }
@ -18,26 +16,15 @@ class CustomVocabularyService {
return "Important Vocabulary: \(wordsText)" return "Important Vocabulary: \(wordsText)"
} }
private func getCustomVocabularyWords() -> [String]? { private func getCustomVocabularyWords(from context: ModelContext) -> [String]? {
guard let data = UserDefaults.standard.data(forKey: "CustomVocabularyItems") else { let descriptor = FetchDescriptor<VocabularyWord>(sortBy: [SortDescriptor(\VocabularyWord.word)])
return nil
}
do { do {
let items = try JSONDecoder().decode([DictionaryItem].self, from: data) let items = try context.fetch(descriptor)
let words = items.map { $0.word } let words = items.map { $0.word }
return words.isEmpty ? nil : words return words.isEmpty ? nil : words
} catch { } catch {
return nil 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")
}
}
} }

View File

@ -1,35 +1,41 @@
import Foundation import Foundation
import AppKit import AppKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import SwiftData
struct DictionaryExportData: Codable { struct DictionaryExportData: Codable {
let version: String let version: String
let dictionaryItems: [String] let vocabularyWords: [String]
let wordReplacements: [String: String] let wordReplacements: [String: String]
let exportDate: Date let exportDate: Date
} }
class DictionaryImportExportService { class DictionaryImportExportService {
static let shared = DictionaryImportExportService() static let shared = DictionaryImportExportService()
private let dictionaryItemsKey = "CustomVocabularyItems"
private let wordReplacementsKey = "wordReplacements"
private init() {} private init() {}
func exportDictionary() { func exportDictionary(from context: ModelContext) {
// Fetch vocabulary words from SwiftData
var dictionaryWords: [String] = [] var dictionaryWords: [String] = []
if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), let vocabularyDescriptor = FetchDescriptor<VocabularyWord>(sortBy: [SortDescriptor(\VocabularyWord.word)])
let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { if let items = try? context.fetch(vocabularyDescriptor) {
dictionaryWords = items.map { $0.word } 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<WordReplacement>()
if let replacements = try? context.fetch(replacementsDescriptor) {
// 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" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
let exportData = DictionaryExportData( let exportData = DictionaryExportData(
version: version, version: version,
dictionaryItems: dictionaryWords, vocabularyWords: dictionaryWords,
wordReplacements: wordReplacements, wordReplacements: wordReplacements,
exportDate: Date() exportDate: Date()
) )
@ -45,7 +51,7 @@ class DictionaryImportExportService {
savePanel.allowedContentTypes = [UTType.json] savePanel.allowedContentTypes = [UTType.json]
savePanel.nameFieldStringValue = "VoiceInk_Dictionary.json" savePanel.nameFieldStringValue = "VoiceInk_Dictionary.json"
savePanel.title = "Export Dictionary Data" 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 { DispatchQueue.main.async {
if savePanel.runModal() == .OK { if savePanel.runModal() == .OK {
@ -66,7 +72,7 @@ class DictionaryImportExportService {
} }
} }
func importDictionary() { func importDictionary(into context: ModelContext) {
let openPanel = NSOpenPanel() let openPanel = NSOpenPanel()
openPanel.allowedContentTypes = [UTType.json] openPanel.allowedContentTypes = [UTType.json]
openPanel.canChooseFiles = true openPanel.canChooseFiles = true
@ -88,38 +94,36 @@ class DictionaryImportExportService {
decoder.dateDecodingStrategy = .iso8601 decoder.dateDecodingStrategy = .iso8601
let importedData = try decoder.decode(DictionaryExportData.self, from: jsonData) let importedData = try decoder.decode(DictionaryExportData.self, from: jsonData)
var existingItems: [DictionaryItem] = [] // Fetch existing vocabulary words from SwiftData
if let data = UserDefaults.standard.data(forKey: self.dictionaryItemsKey), let vocabularyDescriptor = FetchDescriptor<VocabularyWord>()
let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { let existingItems = (try? context.fetch(vocabularyDescriptor)) ?? []
existingItems = items
}
let existingWordsLower = Set(existingItems.map { $0.word.lowercased() }) let existingWordsLower = Set(existingItems.map { $0.word.lowercased() })
let originalExistingCount = existingItems.count let originalExistingCount = existingItems.count
var newWordsAdded = 0 var newWordsAdded = 0
for importedWord in importedData.dictionaryItems { // Import vocabulary words
for importedWord in importedData.vocabularyWords {
if !existingWordsLower.contains(importedWord.lowercased()) { if !existingWordsLower.contains(importedWord.lowercased()) {
existingItems.append(DictionaryItem(word: importedWord)) let newWord = VocabularyWord(word: importedWord)
context.insert(newWord)
newWordsAdded += 1 newWordsAdded += 1
} }
} }
if let encoded = try? JSONEncoder().encode(existingItems) { // Fetch existing word replacements from SwiftData
UserDefaults.standard.set(encoded, forKey: self.dictionaryItemsKey) let replacementsDescriptor = FetchDescriptor<WordReplacement>()
} let existingReplacements = (try? context.fetch(replacementsDescriptor)) ?? []
var existingReplacements = UserDefaults.standard.dictionary(forKey: self.wordReplacementsKey) as? [String: String] ?? [:]
var addedCount = 0 var addedCount = 0
var updatedCount = 0 var updatedCount = 0
// Import word replacements
for (importedKey, importedReplacement) in importedData.wordReplacements { for (importedKey, importedReplacement) in importedData.wordReplacements {
let normalizedImportedKey = self.normalizeReplacementKey(importedKey) let normalizedImportedKey = self.normalizeReplacementKey(importedKey)
let importedWords = self.extractWords(from: normalizedImportedKey) let importedWords = self.extractWords(from: normalizedImportedKey)
var modifiedExisting: [String: String] = [:] // Check for conflicts and update existing replacements
for (existingKey, existingReplacement) in existingReplacements { for existingReplacement in existingReplacements {
var existingWords = self.extractWords(from: existingKey) var existingWords = self.extractWords(from: existingReplacement.originalText)
var modified = false var modified = false
for importedWord in importedWords { for importedWord in importedWords {
@ -129,30 +133,34 @@ class DictionaryImportExportService {
} }
} }
if !existingWords.isEmpty {
let newKey = existingWords.joined(separator: ", ")
modifiedExisting[newKey] = existingReplacement
}
if modified { if modified {
if existingWords.isEmpty {
context.delete(existingReplacement)
} else {
existingReplacement.originalText = existingWords.joined(separator: ", ")
}
updatedCount += 1 updatedCount += 1
} }
} }
existingReplacements = modifiedExisting // Add new replacement
existingReplacements[normalizedImportedKey] = importedReplacement let newReplacement = WordReplacement(originalText: normalizedImportedKey, replacementText: importedReplacement)
context.insert(newReplacement)
addedCount += 1 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" 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" message += "Word Replacements: \(addedCount) added, \(updatedCount) updated"
self.showAlert(title: "Import Successful", message: message) self.showAlert(title: "Import Successful", message: message)
} catch { } 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.") self.showAlert(title: "Import Error", message: "Error importing dictionary data: \(error.localizedDescription). The file might be corrupted or not in the correct format.")
} }
} else { } else {

View File

@ -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)
}
}

View File

@ -3,6 +3,7 @@ import AppKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import KeyboardShortcuts import KeyboardShortcuts
import LaunchAtLogin import LaunchAtLogin
import SwiftData
struct GeneralSettings: Codable { struct GeneralSettings: Codable {
let toggleMiniRecorderShortcut: KeyboardShortcuts.Shortcut? let toggleMiniRecorderShortcut: KeyboardShortcuts.Shortcut?
@ -28,11 +29,16 @@ struct GeneralSettings: Codable {
let clipboardRestoreDelay: Double? let clipboardRestoreDelay: Double?
} }
// Simple codable struct for vocabulary words (for export/import only)
struct VocabularyWordData: Codable {
let word: String
}
struct VoiceInkExportedSettings: Codable { struct VoiceInkExportedSettings: Codable {
let version: String let version: String
let customPrompts: [CustomPrompt] let customPrompts: [CustomPrompt]
let powerModeConfigs: [PowerModeConfig] let powerModeConfigs: [PowerModeConfig]
let dictionaryItems: [DictionaryItem]? let vocabularyWords: [VocabularyWordData]?
let wordReplacements: [String: String]? let wordReplacements: [String: String]?
let generalSettings: GeneralSettings? let generalSettings: GeneralSettings?
let customEmojis: [String]? let customEmojis: [String]?
@ -78,13 +84,19 @@ class ImportExportService {
// Export custom models // Export custom models
let customModels = CustomModelManager.shared.customModels let customModels = CustomModelManager.shared.customModels
var exportedDictionaryItems: [DictionaryItem]? = nil // Fetch vocabulary words from SwiftData
if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), var exportedDictionaryItems: [VocabularyWordData]? = nil
let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { let vocabularyDescriptor = FetchDescriptor<VocabularyWord>()
exportedDictionaryItems = items 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<WordReplacement>()
if let replacements = try? whisperState.modelContext.fetch(replacementsDescriptor), !replacements.isEmpty {
exportedWordReplacements = Dictionary(uniqueKeysWithValues: replacements.map { ($0.originalText, $0.replacementText) })
}
let generalSettingsToExport = GeneralSettings( let generalSettingsToExport = GeneralSettings(
toggleMiniRecorderShortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder), toggleMiniRecorderShortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder),
@ -114,7 +126,7 @@ class ImportExportService {
version: currentSettingsVersion, version: currentSettingsVersion,
customPrompts: exportablePrompts, customPrompts: exportablePrompts,
powerModeConfigs: powerConfigs, powerModeConfigs: powerConfigs,
dictionaryItems: exportedDictionaryItems, vocabularyWords: exportedDictionaryItems,
wordReplacements: exportedWordReplacements, wordReplacements: exportedWordReplacements,
generalSettings: generalSettingsToExport, generalSettings: generalSettingsToExport,
customEmojis: emojiManager.customEmojis, customEmojis: emojiManager.customEmojis,
@ -203,16 +215,57 @@ class ImportExportService {
} }
} }
if let itemsToImport = importedSettings.dictionaryItems { // Import vocabulary words to SwiftData
if let encoded = try? JSONEncoder().encode(itemsToImport) { if let itemsToImport = importedSettings.vocabularyWords {
UserDefaults.standard.set(encoded, forKey: "CustomVocabularyItems") let vocabularyDescriptor = FetchDescriptor<VocabularyWord>()
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 { } 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.")
} }
// Import word replacements to SwiftData
if let replacementsToImport = importedSettings.wordReplacements { if let replacementsToImport = importedSettings.wordReplacements {
UserDefaults.standard.set(replacementsToImport, forKey: self.wordReplacementsKey) let replacementsDescriptor = FetchDescriptor<WordReplacement>()
let existingReplacements = (try? whisperState.modelContext.fetch(replacementsDescriptor)) ?? []
// Build a set of existing replacement keys for duplicate checking
var existingKeysSet = Set<String>()
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 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.")
} else { } else {
print("No word replacements found in the imported file. Existing replacements remain unchanged.") print("No word replacements found in the imported file. Existing replacements remain unchanged.")
} }

View File

@ -12,7 +12,7 @@ class TranscriptionServiceRegistry {
modelsDirectory: modelsDirectory, modelsDirectory: modelsDirectory,
whisperState: whisperState 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 nativeAppleTranscriptionService = NativeAppleTranscriptionService()
private(set) lazy var parakeetTranscriptionService = ParakeetTranscriptionService() private(set) lazy var parakeetTranscriptionService = ParakeetTranscriptionService()

View File

@ -1,20 +1,27 @@
import Foundation import Foundation
import SwiftData
class WordReplacementService { class WordReplacementService {
static let shared = WordReplacementService() static let shared = WordReplacementService()
private init() {} private init() {}
func applyReplacements(to text: String) -> String { func applyReplacements(to text: String, using context: ModelContext) -> String {
guard let replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String], let descriptor = FetchDescriptor<WordReplacement>(
!replacements.isEmpty else { predicate: #Predicate { $0.isEnabled }
)
guard let replacements = try? context.fetch(descriptor), !replacements.isEmpty else {
return text // No replacements to apply return text // No replacements to apply
} }
var modifiedText = text var modifiedText = text
// Apply replacements (case-insensitive) // 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 // Split comma-separated originals at apply time only
let variants = originalGroup let variants = originalGroup
.split(separator: ",") .split(separator: ",")
@ -33,16 +40,16 @@ class WordReplacementService {
in: modifiedText, in: modifiedText,
options: [], options: [],
range: range, range: range,
withTemplate: replacement withTemplate: replacementText
) )
} }
} else { } else {
// Fallback substring replace for non-spaced scripts // 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 return modifiedText
} }

View File

@ -1,12 +1,14 @@
import SwiftUI import SwiftUI
import SwiftData
struct DictionarySettingsView: View { struct DictionarySettingsView: View {
@Environment(\.modelContext) private var modelContext
@State private var selectedSection: DictionarySection = .replacements @State private var selectedSection: DictionarySection = .replacements
let whisperPrompt: WhisperPrompt let whisperPrompt: WhisperPrompt
enum DictionarySection: String, CaseIterable { enum DictionarySection: String, CaseIterable {
case replacements = "Word Replacements" case replacements = "Word Replacements"
case spellings = "Correct Spellings" case spellings = "Vocabulary"
var description: String { var description: String {
switch self { switch self {
@ -83,24 +85,24 @@ struct DictionarySettingsView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
DictionaryImportExportService.shared.importDictionary() DictionaryImportExportService.shared.importDictionary(into: modelContext)
}) { }) {
Image(systemName: "square.and.arrow.down") Image(systemName: "square.and.arrow.down")
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundColor(.blue) .foregroundColor(.blue)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Import dictionary items and word replacements") .help("Import vocabulary and word replacements")
Button(action: { Button(action: {
DictionaryImportExportService.shared.exportDictionary() DictionaryImportExportService.shared.exportDictionary(from: modelContext)
}) { }) {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundColor(.blue) .foregroundColor(.blue)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Export dictionary items and word replacements") .help("Export vocabulary and word replacements")
} }
} }
@ -120,7 +122,7 @@ struct DictionarySettingsView: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
switch selectedSection { switch selectedSection {
case .spellings: case .spellings:
DictionaryView(whisperPrompt: whisperPrompt) VocabularyView(whisperPrompt: whisperPrompt)
.background(CardBackground(isSelected: false)) .background(CardBackground(isSelected: false))
case .replacements: case .replacements:
WordReplacementView() WordReplacementView()

View File

@ -1,20 +1,24 @@
import SwiftUI import SwiftUI
import SwiftData
// Edit existing word replacement entry // Edit existing word replacement entry
struct EditReplacementSheet: View { struct EditReplacementSheet: View {
@ObservedObject var manager: WordReplacementManager let replacement: WordReplacement
let originalKey: String let modelContext: ModelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var originalWord: String @State private var originalWord: String
@State private var replacementWord: String @State private var replacementWord: String
@State private var showAlert = false
@State private var alertMessage = ""
// MARK: Initialiser // MARK: Initialiser
init(manager: WordReplacementManager, originalKey: String) { init(replacement: WordReplacement, modelContext: ModelContext) {
self.manager = manager self.replacement = replacement
self.originalKey = originalKey self.modelContext = modelContext
_originalWord = State(initialValue: originalKey) _originalWord = State(initialValue: replacement.originalText)
_replacementWord = State(initialValue: manager.replacements[originalKey] ?? "") _replacementWord = State(initialValue: replacement.replacementText)
} }
var body: some View { var body: some View {
@ -24,6 +28,11 @@ struct EditReplacementSheet: View {
formContent formContent
} }
.frame(width: 460, height: 560) .frame(width: 460, height: 560)
.alert("Word Replacement", isPresented: $showAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(alertMessage)
}
} }
// MARK: Subviews // MARK: Subviews
@ -115,23 +124,48 @@ struct EditReplacementSheet: View {
private func saveChanges() { private func saveChanges() {
let newOriginal = originalWord.trimmingCharacters(in: .whitespacesAndNewlines) let newOriginal = originalWord.trimmingCharacters(in: .whitespacesAndNewlines)
let newReplacement = replacementWord let newReplacement = replacementWord
// Ensure at least one non-empty token
let tokens = newOriginal let tokens = newOriginal
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
guard !tokens.isEmpty, !newReplacement.isEmpty else { return } guard !tokens.isEmpty, !newReplacement.isEmpty else { return }
manager.updateReplacement(oldOriginal: originalKey, newOriginal: newOriginal, newReplacement: newReplacement) // Check for duplicates (excluding current replacement)
dismiss() let newTokensPairs = tokens.map { (original: $0, lowercased: $0.lowercased()) }
}
}
// MARK: Preview let descriptor = FetchDescriptor<WordReplacement>()
#if DEBUG if let allReplacements = try? modelContext.fetch(descriptor) {
struct EditReplacementSheet_Previews: PreviewProvider { for existingReplacement in allReplacements {
static var previews: some View { // Skip checking against itself
EditReplacementSheet(manager: WordReplacementManager(), originalKey: "hello") 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
do {
try modelContext.save()
dismiss()
} catch {
alertMessage = "Failed to save changes: \(error.localizedDescription)"
showAlert = true
}
} }
} }
#endif

View File

@ -1,112 +1,45 @@
import SwiftUI import SwiftUI
import SwiftData
struct DictionaryItem: Identifiable, Hashable, Codable { enum VocabularySortMode: String {
var word: String
var id: String { word }
init(word: String) {
self.word = word
}
private enum CodingKeys: String, CodingKey {
case id, word, dateAdded, isEnabled
}
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)
_ = try? container.decodeIfPresent(Bool.self, forKey: .isEnabled)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(word, forKey: .word)
}
}
enum DictionarySortMode: String {
case wordAsc = "wordAsc" case wordAsc = "wordAsc"
case wordDesc = "wordDesc" case wordDesc = "wordDesc"
} }
class DictionaryManager: ObservableObject { struct VocabularyView: View {
@Published var items: [DictionaryItem] = [] @Query private var vocabularyWords: [VocabularyWord]
private let saveKey = "CustomVocabularyItems" @Environment(\.modelContext) private var modelContext
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([DictionaryItem].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 = DictionaryItem(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 DictionaryView: View {
@StateObject private var dictionaryManager: DictionaryManager
@ObservedObject var whisperPrompt: WhisperPrompt @ObservedObject var whisperPrompt: WhisperPrompt
@State private var newWord = "" @State private var newWord = ""
@State private var showAlert = false @State private var showAlert = false
@State private var alertMessage = "" @State private var alertMessage = ""
@State private var sortMode: DictionarySortMode = .wordAsc @State private var sortMode: VocabularySortMode = .wordAsc
init(whisperPrompt: WhisperPrompt) { init(whisperPrompt: WhisperPrompt) {
self.whisperPrompt = whisperPrompt self.whisperPrompt = whisperPrompt
_dictionaryManager = StateObject(wrappedValue: DictionaryManager(whisperPrompt: whisperPrompt))
if let savedSort = UserDefaults.standard.string(forKey: "dictionarySortMode"), if let savedSort = UserDefaults.standard.string(forKey: "vocabularySortMode"),
let mode = DictionarySortMode(rawValue: savedSort) { let mode = VocabularySortMode(rawValue: savedSort) {
_sortMode = State(initialValue: mode) _sortMode = State(initialValue: mode)
} }
} }
private var sortedItems: [DictionaryItem] { private var sortedItems: [VocabularyWord] {
switch sortMode { switch sortMode {
case .wordAsc: case .wordAsc:
return dictionaryManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending } return vocabularyWords.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending }
case .wordDesc: case .wordDesc:
return dictionaryManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending } return vocabularyWords.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending }
} }
} }
private func toggleSort() { private func toggleSort() {
sortMode = (sortMode == .wordAsc) ? .wordDesc : .wordAsc sortMode = (sortMode == .wordAsc) ? .wordDesc : .wordAsc
UserDefaults.standard.set(sortMode.rawValue, forKey: "dictionarySortMode") UserDefaults.standard.set(sortMode.rawValue, forKey: "vocabularySortMode")
}
private var shouldShowAddButton: Bool {
!newWord.isEmpty
} }
var body: some View { var body: some View {
@ -124,27 +57,30 @@ struct DictionaryView: View {
} }
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("Add word to dictionary", text: $newWord) TextField("Add word to vocabulary", text: $newWord)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.font(.system(size: 13)) .font(.system(size: 13))
.onSubmit { addWords() } .onSubmit { addWords() }
Button(action: addWords) { if shouldShowAddButton {
Image(systemName: "plus.circle.fill") Button(action: addWords) {
.symbolRenderingMode(.hierarchical) Image(systemName: "plus.circle.fill")
.foregroundStyle(.blue) .symbolRenderingMode(.hierarchical)
.font(.system(size: 16, weight: .semibold)) .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 !dictionaryManager.items.isEmpty { if !vocabularyWords.isEmpty {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Button(action: toggleSort) { Button(action: toggleSort) {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("Dictionary Items (\(dictionaryManager.items.count))") Text("Vocabulary Words (\(vocabularyWords.count))")
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -159,8 +95,8 @@ struct DictionaryView: View {
ScrollView { ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 240, maximum: .infinity), spacing: 12)], alignment: .leading, spacing: 12) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 240, maximum: .infinity), spacing: 12)], alignment: .leading, spacing: 12) {
ForEach(sortedItems) { item in ForEach(sortedItems) { item in
DictionaryItemView(item: item) { VocabularyWordView(item: item) {
dictionaryManager.removeWord(item.word) removeWord(item)
} }
} }
} }
@ -172,7 +108,7 @@ struct DictionaryView: View {
} }
} }
.padding() .padding()
.alert("Dictionary", isPresented: $showAlert) { .alert("Vocabulary", isPresented: $showAlert) {
Button("OK", role: .cancel) {} Button("OK", role: .cancel) {}
} message: { } message: {
Text(alertMessage) Text(alertMessage)
@ -182,39 +118,71 @@ struct DictionaryView: View {
private func addWords() { private func addWords() {
let input = newWord.trimmingCharacters(in: .whitespacesAndNewlines) let input = newWord.trimmingCharacters(in: .whitespacesAndNewlines)
guard !input.isEmpty else { return } guard !input.isEmpty else { return }
let parts = input let parts = input
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
guard !parts.isEmpty else { return } guard !parts.isEmpty else { return }
if parts.count == 1, let word = parts.first { if parts.count == 1, let word = parts.first {
if dictionaryManager.items.contains(where: { $0.word.lowercased() == word.lowercased() }) { if vocabularyWords.contains(where: { $0.word.lowercased() == word.lowercased() }) {
alertMessage = "'\(word)' is already in the dictionary" alertMessage = "'\(word)' is already in the vocabulary"
showAlert = true showAlert = true
return return
} }
dictionaryManager.addWord(word) addWord(word)
newWord = "" newWord = ""
return return
} }
for word in parts { for word in parts {
let lower = word.lowercased() let lower = word.lowercased()
if !dictionaryManager.items.contains(where: { $0.word.lowercased() == lower }) { if !vocabularyWords.contains(where: { $0.word.lowercased() == lower }) {
dictionaryManager.addWord(word) addWord(word)
} }
} }
newWord = "" 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)
do {
try modelContext.save()
} catch {
// Rollback the insert to maintain UI consistency
modelContext.delete(newWord)
alertMessage = "Failed to add word: \(error.localizedDescription)"
showAlert = true
}
}
private func removeWord(_ word: VocabularyWord) {
modelContext.delete(word)
do {
try modelContext.save()
} catch {
// Rollback the delete to restore UI consistency
modelContext.rollback()
alertMessage = "Failed to remove word: \(error.localizedDescription)"
showAlert = true
}
}
} }
struct DictionaryItemView: View { struct VocabularyWordView: View {
let item: DictionaryItem let item: VocabularyWord
let onDelete: () -> Void let onDelete: () -> Void
@State private var isHovered = false @State private var isDeleteHovered = false
var body: some View { var body: some View {
HStack(spacing: 6) { HStack(spacing: 6) {
@ -228,14 +196,14 @@ struct DictionaryItemView: View {
Button(action: onDelete) { Button(action: onDelete) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(isHovered ? .red : .secondary) .foregroundStyle(isDeleteHovered ? .red : .secondary)
.contentTransition(.symbolEffect(.replace)) .contentTransition(.symbolEffect(.replace))
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.help("Remove word") .help("Remove word")
.onHover { hover in .onHover { hover in
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hover isDeleteHovered = hover
} }
} }
} }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SwiftData
extension String: Identifiable { extension String: Identifiable {
public var id: String { self } public var id: String { self }
@ -16,65 +17,34 @@ enum SortColumn {
case replacement 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) {
// Preserve comma-separated originals as a single entry
let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
replacements[trimmed] = replacement
}
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)
let trimmed = newOriginal.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
replacements[trimmed] = newReplacement
}
}
struct WordReplacementView: View { struct WordReplacementView: View {
@StateObject private var manager = WordReplacementManager() @Query private var wordReplacements: [WordReplacement]
@State private var showAddReplacementModal = false @Environment(\.modelContext) private var modelContext
@State private var showAlert = false @State private var showAlert = false
@State private var editingOriginal: String? = nil @State private var editingReplacement: WordReplacement? = nil
@State private var alertMessage = "" @State private var alertMessage = ""
@State private var sortMode: SortMode = .originalAsc @State private var sortMode: SortMode = .originalAsc
@State private var originalWord = ""
@State private var replacementWord = ""
@State private var showInfoPopover = false
init() { init() {
if let savedSort = UserDefaults.standard.string(forKey: "wordReplacementSortMode"), if let savedSort = UserDefaults.standard.string(forKey: "wordReplacementSortMode"),
let mode = SortMode(rawValue: savedSort) { let mode = SortMode(rawValue: savedSort) {
_sortMode = State(initialValue: mode) _sortMode = State(initialValue: mode)
} }
} }
private var sortedReplacements: [(key: String, value: String)] { private var sortedReplacements: [WordReplacement] {
let pairs = Array(manager.replacements)
switch sortMode { switch sortMode {
case .originalAsc: case .originalAsc:
return pairs.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } return wordReplacements.sorted { $0.originalText.localizedCaseInsensitiveCompare($1.originalText) == .orderedAscending }
case .originalDesc: case .originalDesc:
return pairs.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedDescending } return wordReplacements.sorted { $0.originalText.localizedCaseInsensitiveCompare($1.originalText) == .orderedDescending }
case .replacementAsc: case .replacementAsc:
return pairs.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedAscending } return wordReplacements.sorted { $0.replacementText.localizedCaseInsensitiveCompare($1.replacementText) == .orderedAscending }
case .replacementDesc: case .replacementDesc:
return pairs.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedDescending } return wordReplacements.sorted { $0.replacementText.localizedCaseInsensitiveCompare($1.replacementText) == .orderedDescending }
} }
} }
@ -87,6 +57,10 @@ struct WordReplacementView: View {
} }
UserDefaults.standard.set(sortMode.rawValue, forKey: "wordReplacementSortMode") UserDefaults.standard.set(sortMode.rawValue, forKey: "wordReplacementSortMode")
} }
private var shouldShowAddButton: Bool {
!originalWord.isEmpty || !replacementWord.isEmpty
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
@ -97,309 +71,264 @@ struct WordReplacementView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} icon: { } icon: {
Image(systemName: "info.circle.fill") Button(action: { showInfoPopover.toggle() }) {
.foregroundColor(.blue) Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
}
.buttonStyle(.plain)
.popover(isPresented: $showInfoPopover) {
WordReplacementInfoPopover()
}
} }
} }
VStack(spacing: 0) { HStack(spacing: 8) {
HStack(spacing: 16) { TextField("Original text (use commas for multiple)", text: $originalWord)
Button(action: { toggleSort(for: .original) }) { .textFieldStyle(.roundedBorder)
HStack(spacing: 4) { .font(.system(size: 13))
Text("Original")
.font(.headline) Image(systemName: "arrow.right")
.foregroundColor(.secondary)
if sortMode == .originalAsc || sortMode == .originalDesc { .font(.system(size: 10))
Image(systemName: sortMode == .originalAsc ? "chevron.up" : "chevron.down") .frame(width: 10)
.font(.caption)
.foregroundColor(.accentColor) TextField("Replacement text", text: $replacementWord)
} .textFieldStyle(.roundedBorder)
} .font(.system(size: 13))
.frame(maxWidth: .infinity, alignment: .leading) .onSubmit { addReplacement() }
.contentShape(Rectangle())
if shouldShowAddButton {
Button(action: addReplacement) {
Image(systemName: "plus.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 16, weight: .semibold))
} }
.buttonStyle(.plain) .buttonStyle(.borderless)
.disabled(originalWord.isEmpty || replacementWord.isEmpty)
Image(systemName: "arrow.right") .help("Add word replacement")
.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)
} }
.padding(.horizontal) }
.padding(.vertical, 8) .animation(.easeInOut(duration: 0.2), value: shouldShowAddButton)
.background(Color(.controlBackgroundColor))
if !wordReplacements.isEmpty {
Divider() VStack(spacing: 0) {
HStack(spacing: 8) {
// Content Button(action: { toggleSort(for: .original) }) {
if manager.replacements.isEmpty { HStack(spacing: 4) {
EmptyStateView(showAddModal: $showAddReplacementModal) Text("Original")
} else { .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 { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(sortedReplacements.enumerated()), id: \.offset) { index, pair in ForEach(sortedReplacements) { replacement in
ReplacementRow( ReplacementRow(
original: pair.key, original: replacement.originalText,
replacement: pair.value, replacement: replacement.replacementText,
onDelete: { manager.removeReplacement(original: pair.key) }, onDelete: { removeReplacement(replacement) },
onEdit: { editingOriginal = pair.key } onEdit: { editingReplacement = replacement }
) )
if index != sortedReplacements.count - 1 { if replacement.id != sortedReplacements.last?.id {
Divider() Divider()
.padding(.leading, 32)
} }
} }
} }
.background(Color(.controlBackgroundColor))
} }
.frame(maxHeight: 300)
} }
.padding(.top, 4)
} }
} }
.padding() .padding()
.sheet(isPresented: $showAddReplacementModal) { .sheet(item: $editingReplacement) { replacement in
AddReplacementSheet(manager: manager) EditReplacementSheet(replacement: replacement, modelContext: modelContext)
} }
// Edit existing replacement .alert("Word Replacement", isPresented: $showAlert) {
.sheet(item: $editingOriginal) { original in Button("OK", role: .cancel) {}
EditReplacementSheet(manager: manager, originalKey: original) } message: {
Text(alertMessage)
} }
} }
}
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 = ""
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)
}
private func addReplacement() { private func addReplacement() {
let original = originalWord let original = originalWord.trimmingCharacters(in: .whitespacesAndNewlines)
let replacement = replacementWord let replacement = replacementWord.trimmingCharacters(in: .whitespacesAndNewlines)
// Validate that at least one non-empty token exists
let tokens = original let tokens = original
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
guard !tokens.isEmpty && !replacement.isEmpty else { return } guard !tokens.isEmpty && !replacement.isEmpty else { return }
manager.addReplacement(original: original, replacement: replacement) // Check for duplicates
dismiss() 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
}
}
}
// Add new replacement
let newReplacement = WordReplacement(originalText: original, replacementText: replacement)
modelContext.insert(newReplacement)
do {
try modelContext.save()
originalWord = ""
replacementWord = ""
} catch {
// Rollback the insert to maintain UI consistency
modelContext.delete(newReplacement)
alertMessage = "Failed to add replacement: \(error.localizedDescription)"
showAlert = true
}
}
private func removeReplacement(_ replacement: WordReplacement) {
modelContext.delete(replacement)
do {
try modelContext.save()
} catch {
// Rollback the delete to restore UI consistency
modelContext.rollback()
alertMessage = "Failed to remove replacement: \(error.localizedDescription)"
showAlert = true
}
}
}
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)
} }
} }
@ -408,63 +337,61 @@ struct ReplacementRow: View {
let replacement: String let replacement: String
let onDelete: () -> Void let onDelete: () -> Void
let onEdit: () -> Void let onEdit: () -> Void
@State private var isEditHovered = false
@State private var isDeleteHovered = false
var body: some View { var body: some View {
HStack(spacing: 16) { HStack(spacing: 8) {
// Original Text Container Text(original)
HStack { .font(.system(size: 13))
Text(original) .lineLimit(2)
.font(.body) .frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
.frame(maxWidth: .infinity)
// Arrow
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.font(.system(size: 12)) .font(.system(size: 10))
.frame(width: 10)
// Replacement Text Container
HStack { ZStack(alignment: .trailing) {
Text(replacement) Text(replacement)
.font(.body) .font(.system(size: 13))
.lineLimit(2) .lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.trailing, 50)
.padding(.vertical, 8)
.background(Color(.textBackgroundColor)) HStack(spacing: 6) {
.cornerRadius(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) .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) .padding(.vertical, 8)
.contentShape(Rectangle()) .padding(.horizontal, 4)
.background(Color(.controlBackgroundColor))
} }
} }

View File

@ -42,13 +42,17 @@ struct VoiceInkApp: App {
} }
let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Initialization") 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 var initializationFailed = false
// Attempt 1: Try persistent storage // Attempt 1: Try persistent storage
if let persistentContainer = Self.createPersistentContainer(schema: schema, logger: logger) { if let persistentContainer = Self.createPersistentContainer(schema: schema, logger: logger) {
container = persistentContainer container = persistentContainer
#if DEBUG #if DEBUG
// Print SwiftData storage location in debug builds only // Print SwiftData storage location in debug builds only
if let url = persistentContainer.mainContext.container.configurations.first?.url { if let url = persistentContainer.mainContext.container.configurations.first?.url {
@ -59,9 +63,9 @@ struct VoiceInkApp: App {
// Attempt 2: Try in-memory storage // Attempt 2: Try in-memory storage
else if let memoryContainer = Self.createInMemoryContainer(schema: schema, logger: logger) { else if let memoryContainer = Self.createInMemoryContainer(schema: schema, logger: logger) {
container = memoryContainer container = memoryContainer
logger.warning("Using in-memory storage as fallback. Data will not persist between sessions.") logger.warning("Using in-memory storage as fallback. Data will not persist between sessions.")
// Show alert to user about storage issue // Show alert to user about storage issue
DispatchQueue.main.async { DispatchQueue.main.async {
let alert = NSAlert() let alert = NSAlert()
@ -72,19 +76,16 @@ struct VoiceInkApp: App {
alert.runModal() alert.runModal()
} }
} }
// Attempt 3: Try ultra-minimal default container // All attempts failed
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
else { else {
logger.critical("All ModelContainer initialization attempts failed") logger.critical("ModelContainer initialization failed")
initializationFailed = true initializationFailed = true
// Create a dummy container to satisfy Swift's initialization requirements // Create minimal in-memory container to satisfy initialization
// App will show error and terminate in onAppear let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
container = Self.createDummyContainer(schema: schema) container = (try? ModelContainer(for: schema, configurations: [config])) ?? {
preconditionFailure("Unable to create ModelContainer. SwiftData is unavailable.")
}()
} }
containerInitializationFailed = initializationFailed containerInitializationFailed = initializationFailed
@ -134,15 +135,37 @@ struct VoiceInkApp: App {
// Create app-specific Application Support directory URL // Create app-specific Application Support directory URL
let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("com.prakashjoshipax.VoiceInk", isDirectory: true) .appendingPathComponent("com.prakashjoshipax.VoiceInk", isDirectory: true)
// Create the directory if it doesn't exist // Create the directory if it doesn't exist
try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
// Configure SwiftData to use the conventional location // Define storage locations
let storeURL = appSupportURL.appendingPathComponent("default.store") let defaultStoreURL = appSupportURL.appendingPathComponent("default.store")
let modelConfiguration = ModelConfiguration(schema: schema, url: storeURL) let dictionaryStoreURL = appSupportURL.appendingPathComponent("dictionary.store")
return try ModelContainer(for: schema, configurations: [modelConfiguration]) // 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 { } catch {
logger.error("Failed to create persistent ModelContainer: \(error.localizedDescription)") logger.error("Failed to create persistent ModelContainer: \(error.localizedDescription)")
return nil return nil
@ -151,45 +174,29 @@ struct VoiceInkApp: App {
private static func createInMemoryContainer(schema: Schema, logger: Logger) -> ModelContainer? { private static func createInMemoryContainer(schema: Schema, logger: Logger) -> ModelContainer? {
do { do {
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) // Transcript configuration
return try ModelContainer(for: schema, configurations: [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 { } catch {
logger.error("Failed to create in-memory ModelContainer: \(error.localizedDescription)") logger.error("Failed to create in-memory ModelContainer: \(error.localizedDescription)")
return nil 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 { var body: some Scene {
WindowGroup { WindowGroup {
if hasCompletedOnboarding { if hasCompletedOnboarding {
@ -210,11 +217,14 @@ struct VoiceInkApp: App {
alert.alertStyle = .critical alert.alertStyle = .critical
alert.addButton(withTitle: "Quit") alert.addButton(withTitle: "Quit")
alert.runModal() alert.runModal()
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)
return return
} }
// Migrate dictionary data from UserDefaults to SwiftData (one-time operation)
DictionaryMigrationService.shared.migrateIfNeeded(context: container.mainContext)
updaterViewModel.silentlyCheckForUpdates() updaterViewModel.silentlyCheckForUpdates()
if enableAnnouncements { if enableAnnouncements {
AnnouncementsService.shared.start() AnnouncementsService.shared.start()

View File

@ -316,7 +316,7 @@ class WhisperState: NSObject, ObservableObject {
logger.notice("📝 Formatted transcript: \(text, privacy: .public)") 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)") logger.notice("📝 WordReplacement: \(text, privacy: .public)")
let audioAsset = AVURLAsset(url: url) let audioAsset = AVURLAsset(url: url)