feat(keyboard): rank pinyin by habit and persist user dictionaries

Erase dialect syllables before abbrev so wom prefers 我们, flush librime
on leave, and add a settings action to forget learned Chinese and English order.
This commit is contained in:
Rocky
2026-08-14 21:49:03 +08:00
parent 2c3a3f80f3
commit b13a1e904b
15 changed files with 588 additions and 5 deletions
@@ -35,6 +35,10 @@ public final class EnglishLearningStore: @unchecked Sendable {
(defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int]) ?? [:]
}
public func clear() {
defaults.removeObject(forKey: Self.defaultsKey)
}
private func mutate(word: String, delta: Int) {
let key = word.lowercased()
guard !key.isEmpty else { return }
+2 -1
View File
@@ -58,7 +58,8 @@ public final class LibrimeEngine: RimeEngineBridging {
public func teardown() {
bridge?.clearComposition()
bridge?.stopSession()
// Session destroy alone does not flush LevelDB user dictionaries.
bridge?.finalizeRuntime()
bridge = nil
composition = .empty
isReady = false
@@ -72,7 +72,7 @@ public struct RimeResourcePaths: Sendable {
public actor RimeResourceInstaller {
public static let shared = RimeResourceInstaller()
/// Bump when SharedSupport layout / schema / import_tables contract changes.
public static let resourceVersion = "2.3.0"
public static let resourceVersion = "2.4.0"
public init() {}
@@ -231,6 +231,28 @@ public actor RimeResourceInstaller {
// deployments and is intentionally not needed here.
bridge.finalizeRuntime()
}
/// Deletes librime user dictionaries under `UserData`, keeping `build/`
/// so `isReady` stays true. Host-only: the keyboard must not race LevelDB.
public func clearUserDictionary() throws {
guard Self.canDeployInCurrentProcess else {
throw RimeResourceError.hostAppRequired
}
let paths = try RimeResourcePaths.resolve()
try Self.removeUserDictionaries(in: paths.userData)
}
/// Testable file-level wipe. Matches LevelDB folders like `osg_pinyin.userdb`.
nonisolated public static func removeUserDictionaries(
in userData: URL,
fileManager: FileManager = .default
) throws {
guard fileManager.fileExists(atPath: userData.path) else { return }
let names = try fileManager.contentsOfDirectory(atPath: userData.path)
for name in names where name.lowercased().contains("userdb") {
try fileManager.removeItem(at: userData.appendingPathComponent(name))
}
}
}
extension RimeResourceInstaller {
@@ -43,7 +43,9 @@ public enum RimeSchemaGenerator {
let alphabet = inputSchema == .fullPinyin
? "zyxwvutsrqponmlkjihgfedcba"
: "zyxwvutsrqponmlkjihgfedcba;"
let algebra = fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
// Dialect single-letter syllables must be erased before fuzzy/abbrev
// so `n/l` cannot revive as `l`, and `wom` cannot exact-match .
let algebra = dialectEraseRules + fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
return """
@@ -112,6 +114,16 @@ public enum RimeSchemaGenerator {
"""
}
/// Drop Wu/dialect exact spellings (` m`, ` n/ng`, ` hm`) before
/// first-letter abbrev, matching rime-pinyin-simp. Keep the dictionary
/// rows; remains reachable as `en`, as `mu`.
public static let dialectEraseRules: [String] = [
"erase/^hm$/",
"erase/^m$/",
"erase/^n$/",
"erase/^ng$/"
]
/// Rules run against full-pinyin dictionary codes before double-pinyin
/// transforms, so fuzzy pairs work consistently in all three schemas.
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
@@ -144,7 +156,9 @@ public enum RimeSchemaGenerator {
case .fullPinyin:
return [
"derive/^([jqxy])u$/$1v/",
"abbrev/^([a-z]).+$/$1/"
"abbrev/^([a-z]).+$/$1/",
// Two-letter initials; do not add this to double pinyin.
"abbrev/^([zcs]h).+$/$1/"
]
case .microsoftDoublePinyin, .sogouDoublePinyin:
@@ -0,0 +1,18 @@
// TypingHabitStore.swift
// OSGKeyboard · Shared
//
// Cross-language "forget" for implicit typing habits. Ranking stays
// language-specific (EnglishLearningStore vs librime userdb).
import Foundation
public enum TypingHabitStore {
/// Clears English boosts and Chinese Rime user dictionaries.
/// Does not touch PersonalDictionary / osg_personal.
public static func clearAll(
englishStore: EnglishLearningStore = EnglishLearningStore()
) async throws {
englishStore.clear()
try await RimeResourceInstaller.shared.clearUserDictionary()
}
}
@@ -26,7 +26,13 @@ public final class TypingSessionController: ObservableObject {
@Published public private(set) var lastErrorNeedsHostDeployment: Bool = false
/// When true, English suggestions / autocorrect stay off (secure fields).
@Published public var suggestionsEnabled: Bool = true
/// Chinese composition is also skipped so passwords never enter Rime userdb.
@Published public var suggestionsEnabled: Bool = true {
didSet {
guard oldValue, !suggestionsEnabled else { return }
abandonChineseComposition()
}
}
/// Chevron appears only for Chinese composition with at least two candidates.
public var canExpandCandidatePanel: Bool {
@@ -306,6 +312,12 @@ public final class TypingSessionController: ObservableObject {
return handleEnglishCharacter(ch)
}
if !suggestionsEnabled {
clearOneShotShiftIfNeeded()
abandonChineseComposition()
return .insert(String(ch))
}
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
// keycodes would otherwise be rejected with no output.
@@ -327,6 +339,10 @@ public final class TypingSessionController: ObservableObject {
return handleEnglishSpace()
}
clearPeriodShortcut()
if !suggestionsEnabled {
abandonChineseComposition()
return .insert(" ")
}
let text = engine.processSpace() ?? " "
composition = engine.composition
syncCandidatePanelVisibility()
@@ -338,6 +354,10 @@ public final class TypingSessionController: ObservableObject {
if language == .english {
return commitEnglishWord(suffix: "\n")
}
if !suggestionsEnabled {
abandonChineseComposition()
return .insert("\n")
}
let text = engine.processReturn() ?? "\n"
composition = engine.composition
syncCandidatePanelVisibility()
@@ -349,6 +369,9 @@ public final class TypingSessionController: ObservableObject {
if language == .english {
return selectEnglishCandidate(at: index)
}
if !suggestionsEnabled {
return .none
}
guard composition.candidates.indices.contains(index) else { return .none }
// Display order may put phrases before first-syllable chars; select by engine index.
let engineIndex = composition.candidates[index].engineIndex
@@ -360,6 +383,13 @@ public final class TypingSessionController: ObservableObject {
return text.isEmpty ? .none : .insert(text)
}
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
private func abandonChineseComposition() {
engineStorage?.clearComposition()
composition = .empty
isCandidatePanelExpanded = false
}
// MARK: - English
private func handleEnglishSpace() -> TypingOutput {