Files
OSGKeyboard/OSGKeyboardShared/Services/PersonalDictionaryEntryService.swift
T
2026-08-25 13:42:00 +08:00

121 lines
4.4 KiB
Swift

// PersonalDictionaryEntryService.swift
// OSGKeyboard · Shared
//
// Keeps every user-confirmed term on the same save, alias-generation,
// and cloud-sync path, regardless of which UI initiated the change.
import Foundation
@MainActor
public final class PersonalDictionaryEntryService {
public typealias AliasGeneration = @MainActor (String) async -> [String]
public typealias CloudPush = @MainActor (PersonalDictionary) async -> Void
public struct SavedEntry: Sendable {
public let entryID: UUID
public let term: String
public let source: PersonalDictionary.Entry.Source
public let dictionary: PersonalDictionary
public let shouldGenerateAliases: Bool
}
private let store: AppGroupStore
private let generateAliases: AliasGeneration
private let pushToCloud: CloudPush
public init(
store: AppGroupStore = AppGroupStore(),
aliasGeneration: AliasGeneration? = nil,
cloudPush: CloudPush? = nil
) {
self.store = store
if let aliasGeneration {
generateAliases = aliasGeneration
} else {
let generator = DictionaryAliasGenerator()
generateAliases = { term in
await generator.generateAliases(for: term)
}
}
pushToCloud = cloudPush ?? { dictionary in
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
}
}
/// Saves immediately so alias generation never blocks the user's action.
public func saveEntry(
term: String,
existingID: UUID? = nil,
source: PersonalDictionary.Entry.Source,
minimumUsageCount: Int? = nil
) -> SavedEntry? {
var shouldGenerateAliases = false
guard let mutation = store.updatePersonalDictionary({ dictionary -> PersonalDictionary.Entry? in
let previousTerm = existingID.flatMap { id in
dictionary.entries.first(where: { $0.id == id })?.term
}
let termChanged = previousTerm.map {
$0.caseInsensitiveCompare(term) != .orderedSame
} ?? true
guard let saved = dictionary.upsert(
term: term,
existingID: existingID,
source: source
),
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
return nil
}
if let minimumUsageCount {
dictionary.entries[index].usageCount = max(
dictionary.entries[index].usageCount,
minimumUsageCount
)
}
dictionary.version += 1
shouldGenerateAliases = existingID == nil || termChanged
return dictionary.entries[index]
}) else {
return nil
}
return SavedEntry(
entryID: mutation.result.id,
term: mutation.result.term,
source: mutation.result.source,
dictionary: mutation.dictionary,
shouldGenerateAliases: shouldGenerateAliases
)
}
/// Finishes the asynchronous work against the latest dictionary snapshot.
///
/// The identity and canonical term checks prevent a late LLM response from
/// restoring a deleted entry or attaching aliases after the term changed.
@discardableResult
public func finishSaving(_ saved: SavedEntry) async -> PersonalDictionary {
await pushToCloud(store.personalDictionary)
guard saved.shouldGenerateAliases else { return store.personalDictionary }
let aliases = await generateAliases(saved.term)
guard !aliases.isEmpty else { return store.personalDictionary }
guard let mutation = store.updatePersonalDictionary({ dictionary -> UUID? in
guard let current = dictionary.entries.first(where: { $0.id == saved.entryID }),
current.term == saved.term,
current.source == saved.source else {
return nil
}
dictionary.updateAliases(for: saved.entryID, aliases: aliases)
dictionary.version += 1
return saved.entryID
}) else {
return store.personalDictionary
}
await pushToCloud(mutation.dictionary)
// Cloud sync may merge remote entries into local storage while pushing.
return store.personalDictionary
}
}