From cf8d8214368ee200ae43103d808a3cdb82c08bf4 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 7 Nov 2025 11:31:41 +0545 Subject: [PATCH] Implement Dictionary Import export support for iOS companion app(v1) --- .../DictionaryImportExportService.swift | 177 ++++++++++++++++++ .../Dictionary/DictionarySettingsView.swift | 36 +++- .../Views/Dictionary/DictionaryView.swift | 9 +- 3 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 VoiceInk/Services/DictionaryImportExportService.swift diff --git a/VoiceInk/Services/DictionaryImportExportService.swift b/VoiceInk/Services/DictionaryImportExportService.swift new file mode 100644 index 0000000..ec0f2bb --- /dev/null +++ b/VoiceInk/Services/DictionaryImportExportService.swift @@ -0,0 +1,177 @@ +import Foundation +import AppKit +import UniformTypeIdentifiers + +struct DictionaryExportData: Codable { + let version: String + let dictionaryItems: [String] + let wordReplacements: [String: String] + let exportDate: Date +} + +class DictionaryImportExportService { + static let shared = DictionaryImportExportService() + private let dictionaryItemsKey = "CustomDictionaryItems" + private let wordReplacementsKey = "wordReplacements" + + private init() {} + + func exportDictionary() { + var dictionaryWords: [String] = [] + if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), + let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + dictionaryWords = items.map { $0.word } + } + + let wordReplacements = UserDefaults.standard.dictionary(forKey: wordReplacementsKey) as? [String: String] ?? [:] + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" + + let exportData = DictionaryExportData( + version: version, + dictionaryItems: dictionaryWords, + wordReplacements: wordReplacements, + exportDate: Date() + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .iso8601 + + do { + let jsonData = try encoder.encode(exportData) + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [UTType.json] + savePanel.nameFieldStringValue = "VoiceInk_Dictionary.json" + savePanel.title = "Export Dictionary Data" + savePanel.message = "Choose a location to save your dictionary items and word replacements." + + DispatchQueue.main.async { + if savePanel.runModal() == .OK { + if let url = savePanel.url { + do { + try jsonData.write(to: url) + self.showAlert(title: "Export Successful", message: "Dictionary data exported successfully to \(url.lastPathComponent).") + } catch { + self.showAlert(title: "Export Error", message: "Could not save dictionary data: \(error.localizedDescription)") + } + } + } else { + self.showAlert(title: "Export Canceled", message: "Export operation was canceled.") + } + } + } catch { + self.showAlert(title: "Export Error", message: "Could not encode dictionary data: \(error.localizedDescription)") + } + } + + func importDictionary() { + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [UTType.json] + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + openPanel.title = "Import Dictionary Data" + openPanel.message = "Choose a dictionary file to import. New items will be added, existing items will be kept." + + DispatchQueue.main.async { + if openPanel.runModal() == .OK { + guard let url = openPanel.url else { + self.showAlert(title: "Import Error", message: "Could not get the file URL.") + return + } + + do { + let jsonData = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let importedData = try decoder.decode(DictionaryExportData.self, from: jsonData) + + var existingItems: [DictionaryItem] = [] + if let data = UserDefaults.standard.data(forKey: self.dictionaryItemsKey), + let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + existingItems = items + } + + let existingWordsLower = Set(existingItems.map { $0.word.lowercased() }) + let originalExistingCount = existingItems.count + var newWordsAdded = 0 + + for importedWord in importedData.dictionaryItems { + if !existingWordsLower.contains(importedWord.lowercased()) { + existingItems.append(DictionaryItem(word: importedWord)) + newWordsAdded += 1 + } + } + + if let encoded = try? JSONEncoder().encode(existingItems) { + UserDefaults.standard.set(encoded, forKey: self.dictionaryItemsKey) + } + + var existingReplacements = UserDefaults.standard.dictionary(forKey: self.wordReplacementsKey) as? [String: String] ?? [:] + + var replacementToOriginals: [String: Set] = [:] + + for (originals, replacement) in existingReplacements { + let replacementLower = replacement.lowercased() + let words = originals.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + replacementToOriginals[replacementLower, default: []].formUnion(words) + } + + for (originals, replacement) in importedData.wordReplacements { + let replacementLower = replacement.lowercased() + let words = originals.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + replacementToOriginals[replacementLower, default: []].formUnion(words) + } + + var mergedReplacements: [String: String] = [:] + for (replacementLower, originals) in replacementToOriginals { + var uniqueOriginals: [String] = [] + var seenLower = Set() + + for original in originals { + let originalLower = original.lowercased() + if !seenLower.contains(originalLower) { + uniqueOriginals.append(original) + seenLower.insert(originalLower) + } + } + + let mergedKey = uniqueOriginals.sorted().joined(separator: ", ") + + let replacement = existingReplacements.first(where: { $0.value.lowercased() == replacementLower })?.value + ?? importedData.wordReplacements.first(where: { $0.value.lowercased() == replacementLower })?.value + ?? replacementLower + + mergedReplacements[mergedKey] = replacement + } + + UserDefaults.standard.set(mergedReplacements, forKey: self.wordReplacementsKey) + + var message = "Dictionary data imported successfully from \(url.lastPathComponent).\n\n" + message += "Dictionary Items: \(newWordsAdded) added, \(originalExistingCount) kept\n" + message += "Word Replacements: Merged into \(mergedReplacements.count) total rules" + + self.showAlert(title: "Import Successful", message: message) + + } catch { + self.showAlert(title: "Import Error", message: "Error importing dictionary data: \(error.localizedDescription). The file might be corrupted or not in the correct format.") + } + } else { + self.showAlert(title: "Import Canceled", message: "Import operation was canceled.") + } + } + } + + private func showAlert(title: String, message: String) { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + } +} diff --git a/VoiceInk/Views/Dictionary/DictionarySettingsView.swift b/VoiceInk/Views/Dictionary/DictionarySettingsView.swift index 9f357ae..7fa9b8f 100644 --- a/VoiceInk/Views/Dictionary/DictionarySettingsView.swift +++ b/VoiceInk/Views/Dictionary/DictionarySettingsView.swift @@ -47,7 +47,7 @@ struct DictionarySettingsView: View { .background(Circle() .fill(Color(.windowBackgroundColor).opacity(0.9)) .shadow(color: .black.opacity(0.1), radius: 10, y: 5)) - + VStack(spacing: 8) { Text("Dictionary Settings") .font(.system(size: 28, weight: .bold)) @@ -74,10 +74,36 @@ struct DictionarySettingsView: View { private var sectionSelector: some View { VStack(alignment: .leading, spacing: 20) { - Text("Select Section") - .font(.title2) - .fontWeight(.semibold) - + HStack { + Text("Select Section") + .font(.title2) + .fontWeight(.semibold) + + Spacer() + + HStack(spacing: 12) { + Button(action: { + DictionaryImportExportService.shared.importDictionary() + }) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 18)) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .help("Import dictionary items and word replacements") + + Button(action: { + DictionaryImportExportService.shared.exportDictionary() + }) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 18)) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .help("Export dictionary items and word replacements") + } + } + HStack(spacing: 20) { ForEach(DictionarySection.allCases, id: \.self) { section in SectionCard( diff --git a/VoiceInk/Views/Dictionary/DictionaryView.swift b/VoiceInk/Views/Dictionary/DictionaryView.swift index d7d2640..125c4ef 100644 --- a/VoiceInk/Views/Dictionary/DictionaryView.swift +++ b/VoiceInk/Views/Dictionary/DictionaryView.swift @@ -1,11 +1,11 @@ import SwiftUI struct DictionaryItem: Identifiable, Hashable, Codable { - let id: UUID var word: String - init(id: UUID = UUID(), word: String) { - self.id = id + var id: String { word } + + init(word: String) { self.word = word } @@ -15,15 +15,14 @@ struct DictionaryItem: Identifiable, Hashable, Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) 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(id, forKey: .id) try container.encode(word, forKey: .word) } }