Merge pull request #454 from Beingpax/dictionary-refactor
Improve Dictionary Feature
This commit is contained in:
commit
9d75109c72
13
VoiceInk/Models/VocabularyWord.swift
Normal file
13
VoiceInk/Models/VocabularyWord.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
VoiceInk/Models/WordReplacement.swift
Normal file
19
VoiceInk/Models/WordReplacement.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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] = []
|
||||||
|
|||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
99
VoiceInk/Services/DictionaryMigrationService.swift
Normal file
99
VoiceInk/Services/DictionaryMigrationService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user