Skip to main content

msime_engine/user_dictionary/
personal.rs

1//! The personal dictionary API (user-dictionary.md §15.1-§15.3, `include/metasequoia/personal_dictionary.h`). Error texts are the reference's; copy them from J:1738-2318 and PD.
2//!
3//! Validation failures are `InvalidArgument` (the bridge threw `invalid_argument` for them); every failure of an edit, page or lookup is `Failed` carrying the reference's `result.error`, which the bridge threw as `runtime_error`.
4
5use rusqlite::types::ValueRef;
6use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Row, TransactionBehavior};
7
8use crate::assets;
9use crate::dictionary::english::ensure_english_schema;
10use crate::error::{EngineError, Result};
11use crate::lattice::personal::PersonalTransition;
12use crate::paths::RuntimePaths;
13use crate::pinyin::syllables::intact_pinyin_set;
14use crate::types::{PersonalDictionaryEntry, PersonalDictionaryKind};
15use crate::user_dictionary::journal::{ensure_user_database, open_database, pinyin_table};
16use crate::user_dictionary::ngram_store::{
17    delete_personal_ngram_word, flush_journal, forget_journal_rows,
18};
19use crate::user_dictionary::replay::{
20    apply_english, apply_pinyin, apply_simple, attach, open_without_main_dictionary,
21};
22
23pub const MAX_PAGE_LIMIT: usize = 1_000;
24pub const MAX_PAGE_OFFSET: usize = 1_000_000;
25pub const MAX_REQUEST_ID_LENGTH: usize = 128;
26
27pub const MAX_ENTRY_WEIGHT: i64 = 100_000_000;
28const MAX_KEY_BYTES: usize = 512;
29const MAX_VALUE_BYTES: usize = 4_096;
30const MAX_PINYIN_SYLLABLES: usize = 64;
31
32// ---- Validation (PD:6-70) ----
33pub const WEIGHT_OUT_OF_RANGE: &str = "Weight must be between 1 and 100000000";
34pub const UNBOUNDED_ENTRY: &str = "A valid, bounded UTF-8 word and input code are required";
35pub const CONTROL_CHARACTER: &str = "The word contains an unsupported control character";
36pub const INCOMPLETE_PINYIN: &str =
37    "Use complete pinyin syllables separated by apostrophes or spaces";
38pub const SYLLABLE_COUNT_MISMATCH: &str =
39    "Each character must have one pinyin syllable (maximum 64)";
40pub const INVALID_WUBI_CODE: &str = "Wubi codes contain one to four letters";
41pub const INVALID_QUICK_PHRASE_CODE: &str =
42    "Quick-phrase codes contain one to 32 letters or digits";
43pub const INVALID_ENGLISH_CODE: &str = "English codes contain letters, hyphens and apostrophes";
44
45// ---- Edits, pages and lookups (J:1792-2317); `bundled` shares them ----
46pub(super) const INVALID_REQUEST_ID: &str = "Invalid personal dictionary request ID";
47pub(super) const ENTRY_REQUIRED: &str = "An entry is required";
48pub(super) const STORAGE_NOT_PREPARED: &str = "Cannot prepare personal dictionary storage";
49pub(super) const DICTIONARY_NOT_OPENED: &str = "Cannot open dictionary";
50pub(super) const STORAGE_NOT_ATTACHED: &str = "Cannot attach personal dictionary storage";
51pub(super) const CONTEXT_NOT_WRITTEN: &str = "Cannot write personal context";
52pub(super) const EDIT_NOT_BEGUN: &str = "Cannot begin personal dictionary edit";
53pub(super) const RECEIPT_NOT_READ: &str = "Cannot read edit receipt";
54pub(super) const RECEIPT_REUSED: &str = "Request ID was already used for another edit";
55pub(super) const RETRY_NOT_FINISHED: &str = "Cannot finish edit retry";
56pub(super) const RECEIPT_READ_UNFINISHED: &str = "Cannot finish reading edit receipt";
57pub(super) const ENTRY_CHANGED: &str = "The entry changed; reload it before editing";
58pub(super) const PREVIOUS_NOT_REMOVED: &str = "Cannot remove previous personal entry";
59pub(super) const ENTRY_NOT_SAVED: &str = "Cannot save personal entry";
60pub(super) const CONTEXT_NOT_REMOVED: &str = "Cannot remove personal context";
61pub(super) const RECEIPT_NOT_SAVED: &str = "Cannot save edit receipt";
62pub(super) const EDIT_NOT_COMMITTED: &str = "Cannot commit personal dictionary edit";
63pub(super) const STORAGE_UNAVAILABLE: &str = "Cannot access personal dictionary storage";
64pub(super) const INVALID_PAGE: &str = "Invalid personal dictionary page";
65pub(super) const DICTIONARY_NOT_READ: &str = "Cannot read personal dictionary";
66pub(super) const PAGE_READ_UNFINISHED: &str = "Cannot finish reading personal dictionary";
67/// 没有 `msime-pinyin.db` 的代次(方案集合不读它)只收英文词:拼音、五笔和快捷短语都写在 `msime-pinyin.db` 里。
68pub(super) const NO_CHINESE_DICTIONARY: &str =
69    "This input method has no Chinese dictionary; only English words can be edited";
70/// 既没有 `msime-pinyin.db` 也没有 `msime-english.db` 的代次(方案集合两者都不要求,资源目录也没带英文库)什么词也存不下。
71pub(super) const NO_ENGLISH_DICTIONARY: &str =
72    "This input method has no English dictionary; no words can be edited";
73
74/// 代次里有没有 `msime-pinyin.db`。不分方案集合的入口([`edit_personal_dictionary`]、[`personal_dictionary_entries`]、`edit_bundled_dictionary_entry`)按代次实际准备出的内容决定:方案集合不读主库时代次里没有它(`prepare_runtime_paths_for`),这时只有英文词能编辑、列出。
75pub(super) fn generation_has_main_dictionary(paths: &RuntimePaths) -> bool {
76    paths.dictionary(assets::MAIN_DICTIONARY).is_file()
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub struct PersonalDictionaryPage {
81    pub entries: Vec<PersonalDictionaryEntry>,
82    pub has_more: bool,
83}
84
85/// Normalise and check an entry: pinyin `"NI HAO"` becomes `"ni'hao"`; English keys may differ from their display (`dont` / `don't`).
86pub fn validate_personal_dictionary_entry(
87    entry: &PersonalDictionaryEntry,
88) -> Result<PersonalDictionaryEntry> {
89    validate_entry(entry, MAX_ENTRY_WEIGHT)
90}
91
92/// The same checks for a row being edited or removed. It is one the list returned and is matched exactly against the journal, and English frequency learning lifts a user's own word past `MAX_ENTRY_WEIGHT` without a ceiling (J:1027-1031), so only the floor applies to its weight. The reference refused such a row (personal_dictionary.cpp:11-12), which left it impossible to edit or remove.
93pub(crate) fn validate_existing_entry(
94    entry: &PersonalDictionaryEntry,
95) -> Result<PersonalDictionaryEntry> {
96    validate_entry(entry, i64::MAX)
97}
98
99fn validate_entry(
100    entry: &PersonalDictionaryEntry,
101    max_weight: i64,
102) -> Result<PersonalDictionaryEntry> {
103    let invalid = |message: &str| Err(EngineError::invalid(message));
104    if !(1..=max_weight).contains(&entry.weight) {
105        return invalid(WEIGHT_OUT_OF_RANGE);
106    }
107    if entry.key.is_empty()
108        || entry.key.len() > MAX_KEY_BYTES
109        || entry.value.is_empty()
110        || entry.value.len() > MAX_VALUE_BYTES
111    {
112        return invalid(UNBOUNDED_ENTRY);
113    }
114    let quick = entry.kind == PersonalDictionaryKind::QuickPhrase;
115    // A quick phrase may span lines and columns; no other word may carry a control character.
116    if entry
117        .value
118        .bytes()
119        .any(|byte| (byte < 32 && !(quick && (byte == b'\n' || byte == b'\t'))) || byte == 127)
120    {
121        return invalid(CONTROL_CHARACTER);
122    }
123    let mut key = entry.key.to_ascii_lowercase();
124    let letters = |byte: u8| byte.is_ascii_lowercase();
125    match entry.kind {
126        PersonalDictionaryKind::Pinyin => {
127            // Require explicit syllables; guessing a split here could store a different pronunciation.
128            key = key.replace(' ', "'");
129            let mut count = 0;
130            for syllable in key.split('\'') {
131                if !intact_pinyin_set().contains(syllable) {
132                    return invalid(INCOMPLETE_PINYIN);
133                }
134                count += 1;
135            }
136            if count > MAX_PINYIN_SYLLABLES || count != entry.value.chars().count() {
137                return invalid(SYLLABLE_COUNT_MISMATCH);
138            }
139        }
140        PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
141            if key.len() > 4 || !key.bytes().all(letters) {
142                return invalid(INVALID_WUBI_CODE);
143            }
144        }
145        PersonalDictionaryKind::QuickPhrase => {
146            if key.len() > 32
147                || !key
148                    .bytes()
149                    .all(|byte| letters(byte) || byte.is_ascii_digit())
150            {
151                return invalid(INVALID_QUICK_PHRASE_CODE);
152            }
153        }
154        PersonalDictionaryKind::English => {
155            // The code is what the user types and the word what it types out, and the two need not be the same text: `dont` typing out `don't` is the case that matters (overlay english_display).
156            if key.len() > 64
157                || !key
158                    .bytes()
159                    .all(|byte| letters(byte) || byte == b'-' || byte == b'\'')
160            {
161                return invalid(INVALID_ENGLISH_CODE);
162            }
163        }
164    }
165    Ok(PersonalDictionaryEntry {
166        kind: entry.kind,
167        key,
168        value: entry.value.clone(),
169        weight: entry.weight,
170    })
171}
172
173/// Add (`previous = None`), remove (`replacement = None`) or replace, in one attached transaction with its receipt. `previous` must still be user-inserted with the same weight. A request id (1..=128 of `[A-Za-z0-9_-]`) makes a retry of the same edit a no-op and a reuse for different content a failure.
174///
175/// An empty request id edits without a receipt, as the reference allowed.
176///
177/// 自己取独占的维护租约(`DictionaryAccess::try_maintenance`):还有会话或别的维护持着租约时以 `Busy`(`DICTIONARY_MAINTENANCE_BUSY`)失败,什么也不写。不合法的改动在取租约之前就报出来。代次里没有 `msime-pinyin.db`(方案集合不读它)时只能编辑英文词,规则同 `edit_personal_dictionary_with`。
178pub fn edit_personal_dictionary(
179    paths: &RuntimePaths,
180    previous: Option<&PersonalDictionaryEntry>,
181    replacement: Option<&PersonalDictionaryEntry>,
182    request_id: &str,
183) -> Result<()> {
184    let main_dictionary = generation_has_main_dictionary(paths);
185    normalize_edit(main_dictionary, previous, replacement, request_id)?;
186    let _access = maintenance(paths)?;
187    edit_personal_dictionary_with(paths, main_dictionary, previous, replacement, request_id)
188}
189
190/// 引擎层编辑接口取维护租约;取不到之外的失败(目录打不开、锁文件不对)按本模块的说法报存储不可用。
191pub(super) fn maintenance(paths: &RuntimePaths) -> Result<crate::access::DictionaryAccess> {
192    crate::access::maintenance(paths).map_err(|error| match error {
193        EngineError::Busy(_) => error,
194        _ => failed(STORAGE_UNAVAILABLE),
195    })
196}
197
198/// [`edit_personal_dictionary`],`main_dictionary` 说明代次里有没有 `msime-pinyin.db`(`SchemeSet::reads_main_dictionary`)。没有时只能编辑英文词,其余种类的增删改都以 `NO_CHINESE_DICTIONARY` 失败,什么也不写;代次里连 `msime-english.db` 也没有时,英文词以 `NO_ENGLISH_DICTIONARY` 失败。不取维护租约,调用方(host 门面背后的 host-api)已经持有。
199pub fn edit_personal_dictionary_with(
200    paths: &RuntimePaths,
201    main_dictionary: bool,
202    previous: Option<&PersonalDictionaryEntry>,
203    replacement: Option<&PersonalDictionaryEntry>,
204    request_id: &str,
205) -> Result<()> {
206    let (old, new) = normalize_edit(main_dictionary, previous, replacement, request_id)?;
207    paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
208    let journal = paths.user(assets::USER_JOURNAL);
209    let mut connection = open_edit_connection(paths, main_dictionary)?;
210    // 先刷新内存中的上下文,避免删除词条时遗漏尚未落盘的记录。
211    flush_journal(&journal).map_err(|_| failed(CONTEXT_NOT_WRITTEN))?;
212    let transaction = connection
213        .transaction_with_behavior(TransactionBehavior::Immediate)
214        .map_err(|_| failed(EDIT_NOT_BEGUN))?;
215    let applied =
216        apply_normalized_edit(&transaction, old.as_ref(), new.as_ref(), request_id, false)?;
217    transaction
218        .commit()
219        .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
220    if let Some(removed) = applied.forgotten {
221        forget_journal_rows(&journal, &removed);
222    }
223    Ok(())
224}
225
226/// 一批最多 128 项,和跨进程个人词库队列的活动请求上限一致。
227pub const MAX_EDIT_BATCH: usize = 128;
228/// 集合直达 Engine 的专用追加导入上限;普通编辑仍使用 128 项。
229pub const MAX_COLLECTION_IMPORT_BATCH: usize = 4096;
230
231/// 一次编辑的借用参数;请求回执格式与单项入口相同。
232#[derive(Clone, Copy)]
233pub struct PersonalDictionaryEdit<'a> {
234    pub previous: Option<&'a PersonalDictionaryEntry>,
235    pub replacement: Option<&'a PersonalDictionaryEntry>,
236    pub request_id: &'a str,
237    /// 集合新增不能覆盖用户原有词;先核对本请求的回执,再判断既有行。
238    pub preserve_existing: bool,
239}
240
241/// 批量编辑对一项的处理结果,既有用户词归集合管理层记录保护。
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243pub enum PersonalDictionaryApplied {
244    Written,
245    AlreadyPresent,
246}
247
248struct AppliedEdit {
249    outcome: PersonalDictionaryApplied,
250    forgotten: Option<Vec<PersonalTransition>>,
251}
252
253/// 一次准备连接并提交有界批次;保存点隔离条目失败,外层失败则整批不生效。
254pub fn edit_personal_dictionary_batch_with(
255    paths: &RuntimePaths,
256    main_dictionary: bool,
257    edits: &[PersonalDictionaryEdit<'_>],
258) -> Result<Vec<Result<PersonalDictionaryApplied>>> {
259    edit_personal_dictionary_batch_with_limit(paths, main_dictionary, edits, MAX_EDIT_BATCH)
260}
261
262/// 集合导入使用的专用批量入口,不改变普通编辑的 128 项上限。
263pub fn edit_personal_dictionary_collection_batch_with(
264    paths: &RuntimePaths,
265    main_dictionary: bool,
266    edits: &[PersonalDictionaryEdit<'_>],
267) -> Result<Vec<Result<PersonalDictionaryApplied>>> {
268    edit_personal_dictionary_batch_with_limit(
269        paths,
270        main_dictionary,
271        edits,
272        MAX_COLLECTION_IMPORT_BATCH,
273    )
274}
275
276fn edit_personal_dictionary_batch_with_limit(
277    paths: &RuntimePaths,
278    main_dictionary: bool,
279    edits: &[PersonalDictionaryEdit<'_>],
280    max_batch: usize,
281) -> Result<Vec<Result<PersonalDictionaryApplied>>> {
282    if edits.len() > max_batch {
283        return Err(failed("Personal dictionary edit batch is too large"));
284    }
285    if edits.is_empty() {
286        return Ok(Vec::new());
287    }
288    paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
289    let journal = paths.user(assets::USER_JOURNAL);
290    let mut connection = open_edit_connection(paths, main_dictionary)?;
291    flush_journal(&journal).map_err(|_| failed(CONTEXT_NOT_WRITTEN))?;
292    let mut transaction = connection
293        .transaction_with_behavior(TransactionBehavior::Immediate)
294        .map_err(|_| failed(EDIT_NOT_BEGUN))?;
295    let mut outcomes = Vec::with_capacity(edits.len());
296    let mut forgotten = Vec::new();
297    for edit in edits {
298        let (old, new) = match normalize_edit(
299            main_dictionary,
300            edit.previous,
301            edit.replacement,
302            edit.request_id,
303        ) {
304            Ok(entries) => entries,
305            Err(error) => {
306                outcomes.push(Err(error));
307                continue;
308            }
309        };
310        let mut savepoint = transaction
311            .savepoint()
312            .map_err(|_| failed(EDIT_NOT_BEGUN))?;
313        let result = apply_normalized_edit(
314            &savepoint,
315            old.as_ref(),
316            new.as_ref(),
317            edit.request_id,
318            edit.preserve_existing,
319        );
320        // RAISE(ROLLBACK) 或存储故障可能已经撤销外层事务,不能再开启保存点并误报前面的项成功。
321        if savepoint.is_autocommit() {
322            return Err(failed(EDIT_NOT_COMMITTED));
323        }
324        match result {
325            Ok(applied) => {
326                savepoint.commit().map_err(|_| failed(EDIT_NOT_COMMITTED))?;
327                if let Some(removed) = applied.forgotten {
328                    forgotten.extend(removed);
329                }
330                outcomes.push(Ok(applied.outcome));
331            }
332            Err(error) => {
333                savepoint
334                    .rollback()
335                    .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
336                savepoint.commit().map_err(|_| failed(EDIT_NOT_COMMITTED))?;
337                outcomes.push(Err(error));
338            }
339        }
340    }
341    transaction
342        .commit()
343        .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
344    if !forgotten.is_empty() {
345        forget_journal_rows(&journal, &forgotten);
346    }
347    Ok(outcomes)
348}
349
350fn normalize_edit(
351    main_dictionary: bool,
352    previous: Option<&PersonalDictionaryEntry>,
353    replacement: Option<&PersonalDictionaryEntry>,
354    request_id: &str,
355) -> Result<(
356    Option<PersonalDictionaryEntry>,
357    Option<PersonalDictionaryEntry>,
358)> {
359    if !valid_request_id(request_id) {
360        return Err(failed(INVALID_REQUEST_ID));
361    }
362    if previous.is_none() && replacement.is_none() {
363        return Err(failed(ENTRY_REQUIRED));
364    }
365    let old = previous
366        .map(validate_existing_entry)
367        .transpose()
368        .map_err(as_failure)?;
369    let new = replacement
370        .map(validate_personal_dictionary_entry)
371        .transpose()
372        .map_err(as_failure)?;
373    if !main_dictionary
374        && [old.as_ref(), new.as_ref()]
375            .into_iter()
376            .flatten()
377            .any(|entry| entry.kind != PersonalDictionaryKind::English)
378    {
379        return Err(failed(NO_CHINESE_DICTIONARY));
380    }
381
382    Ok((old, new))
383}
384
385fn apply_normalized_edit(
386    transaction: &Connection,
387    old: Option<&PersonalDictionaryEntry>,
388    new: Option<&PersonalDictionaryEntry>,
389    request_id: &str,
390    preserve_existing: bool,
391) -> Result<AppliedEdit> {
392    let payload = if request_id.is_empty() {
393        String::new()
394    } else {
395        let mut payload = String::new();
396        append_payload(&mut payload, old);
397        append_payload(&mut payload, new);
398        match check_receipt(transaction, request_id, &payload)? {
399            Receipt::Fresh => {}
400            Receipt::Retry => {
401                return Ok(AppliedEdit {
402                    outcome: PersonalDictionaryApplied::Written,
403                    forgotten: None,
404                });
405            }
406            Receipt::AlreadyPresent => {
407                return Ok(AppliedEdit {
408                    outcome: PersonalDictionaryApplied::AlreadyPresent,
409                    forgotten: None,
410                });
411            }
412        }
413        payload
414    };
415    if preserve_existing && old.is_none() {
416        if let Some(new) = new {
417            let present = transaction.query_row(
418                "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3 AND operation='upsert' AND user_inserted=1",
419                params![new.kind.journal_name(), new.key, new.value], |_| Ok(()),
420            ).optional().map_err(|_| failed(DICTIONARY_NOT_READ))?;
421            if present.is_some() || bundled_entry_present(transaction, new)? {
422                if !request_id.is_empty() {
423                    let receipt_payload = format!("{ALREADY_PRESENT_RECEIPT_PREFIX}{payload}");
424                    save_receipt(transaction, request_id, &receipt_payload)?;
425                }
426                return Ok(AppliedEdit {
427                    outcome: PersonalDictionaryApplied::AlreadyPresent,
428                    forgotten: None,
429                });
430            }
431        }
432    }
433    if let Some(old) = old {
434        let current = transaction
435            .query_row(
436                "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3 AND weight=?4 AND operation='upsert' AND user_inserted=1",
437                params![old.kind.journal_name(), old.key, old.value, old.weight],
438                |_| Ok(()),
439            )
440            .optional();
441        if !matches!(current, Ok(Some(()))) {
442            return Err(failed(ENTRY_CHANGED));
443        }
444        if !matches!(apply_personal_edit(transaction, old, true), Ok(true)) {
445            return Err(failed(PREVIOUS_NOT_REMOVED));
446        }
447    }
448    if let Some(new) = new {
449        if !matches!(apply_personal_edit(transaction, new, false), Ok(true)) {
450            return Err(failed(ENTRY_NOT_SAVED));
451        }
452    }
453    // 词条离开词库才删除输入上下文;只调整权重时保留。
454    let forgotten = match old {
455        Some(old)
456            if old.kind == PersonalDictionaryKind::Pinyin
457                && !new.is_some_and(|new| {
458                    new.kind == PersonalDictionaryKind::Pinyin && new.value == old.value
459                }) =>
460        {
461            Some(
462                delete_personal_ngram_word(transaction, "personal_journal", &old.value)
463                    .map_err(|_| failed(CONTEXT_NOT_REMOVED))?,
464            )
465        }
466        _ => None,
467    };
468    if !request_id.is_empty() {
469        save_receipt(transaction, request_id, &payload)?;
470    }
471    Ok(AppliedEdit {
472        outcome: PersonalDictionaryApplied::Written,
473        forgotten,
474    })
475}
476
477/// 集合新增不能覆盖当前代次里已经存在的发布词条;集合与用户词库共用同一工作库连接,所以这里直接检查实际表。
478fn bundled_entry_present(connection: &Connection, entry: &PersonalDictionaryEntry) -> Result<bool> {
479    let (schema, table, key_column, value_column) = match entry.kind {
480        PersonalDictionaryKind::Pinyin => {
481            let Some(table) = pinyin_table(&entry.key) else {
482                return Ok(false);
483            };
484            ("main", table, "key", "value")
485        }
486        PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
487            let Some(table) = entry.kind.wubi_table() else {
488                return Ok(false);
489            };
490            ("main", table.to_owned(), "key", "value")
491        }
492        PersonalDictionaryKind::QuickPhrase => ("main", "quick_parases".to_owned(), "key", "value"),
493        PersonalDictionaryKind::English => (
494            "replay_english",
495            "english_words".to_owned(),
496            "word",
497            "display",
498        ),
499    };
500    let table_exists = connection
501        .query_row(
502            &format!("SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name=?1"),
503            [&table],
504            |_| Ok(()),
505        )
506        .optional()
507        .map_err(|_| failed(DICTIONARY_NOT_READ))?
508        .is_some();
509    if !table_exists {
510        return Ok(false);
511    }
512    connection
513        .query_row(
514            &format!(
515                "SELECT 1 FROM {schema}.\"{table}\" WHERE \"{key_column}\"=?1 AND \"{value_column}\"=?2 LIMIT 1"
516            ),
517            params![entry.key, entry.value],
518            |_| Ok(()),
519        )
520        .optional()
521        .map(|row| row.is_some())
522        .map_err(|_| failed(DICTIONARY_NOT_READ))
523}
524
525/// User-inserted entries (auto-created words included) in kind/key/value order; with `include_learned_pinyin`, also learned or edited pinyin weights of shipped multi-character words.
526///
527/// Rows of a dictionary this engine does not know are left out (J:1974-1982). Text that is not UTF-8 is shown with replacement characters so one damaged row cannot hide the rest of the list; editing such a row fails its stale check and changes nothing.
528pub fn personal_dictionary_entries(
529    paths: &RuntimePaths,
530    offset: usize,
531    limit: usize,
532    include_learned_pinyin: bool,
533) -> Result<PersonalDictionaryPage> {
534    personal_dictionary_entries_with(
535        paths,
536        generation_has_main_dictionary(paths),
537        offset,
538        limit,
539        include_learned_pinyin,
540    )
541}
542
543/// [`personal_dictionary_entries`],`main_dictionary` 为假(代次里没有 `msime-pinyin.db`)时只列英文词:别的种类的行(例如从别的版本恢复来的快照)在这里既用不上也改不了,所以不列出。
544pub fn personal_dictionary_entries_with(
545    paths: &RuntimePaths,
546    main_dictionary: bool,
547    offset: usize,
548    limit: usize,
549    include_learned_pinyin: bool,
550) -> Result<PersonalDictionaryPage> {
551    read_personal_dictionary_page(
552        paths,
553        main_dictionary,
554        offset,
555        limit,
556        include_learned_pinyin,
557        true,
558        None,
559    )
560}
561
562/// 指定词库先筛选再分页;拼音带学习权重且排除单字,其他词库只导出用户词条。
563pub fn personal_dictionary_export_entries_for_kind(
564    paths: &RuntimePaths,
565    main_dictionary: bool,
566    kind: PersonalDictionaryKind,
567    offset: usize,
568    limit: usize,
569) -> Result<PersonalDictionaryPage> {
570    read_personal_dictionary_page(
571        paths,
572        main_dictionary,
573        offset,
574        limit,
575        kind == PersonalDictionaryKind::Pinyin,
576        true,
577        Some(kind),
578    )
579}
580
581/// 只查询指定词库的用户词条;列表保留拼音单字,不带学习权重。
582pub fn personal_dictionary_entries_for_kind(
583    paths: &RuntimePaths,
584    main_dictionary: bool,
585    kind: PersonalDictionaryKind,
586    offset: usize,
587    limit: usize,
588) -> Result<PersonalDictionaryPage> {
589    read_personal_dictionary_page(
590        paths,
591        main_dictionary,
592        offset,
593        limit,
594        false,
595        false,
596        Some(kind),
597    )
598}
599
600fn read_personal_dictionary_page(
601    paths: &RuntimePaths,
602    main_dictionary: bool,
603    offset: usize,
604    limit: usize,
605    include_learned_pinyin: bool,
606    exclude_single_pinyin: bool,
607    kind: Option<PersonalDictionaryKind>,
608) -> Result<PersonalDictionaryPage> {
609    if limit == 0 || limit > MAX_PAGE_LIMIT || offset > MAX_PAGE_OFFSET {
610        return Err(failed(INVALID_PAGE));
611    }
612    paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
613    let journal = paths.user(assets::USER_JOURNAL);
614    // 列表读取不创建日志(J:1939)。
615    if !journal.exists() {
616        return Ok(PersonalDictionaryPage::default());
617    }
618    let connection = open_database(&journal, OpenFlags::SQLITE_OPEN_READ_ONLY)
619        .map_err(|_| failed(DICTIONARY_NOT_READ))?;
620    let sql = if kind.is_some() {
621        "SELECT dictionary,key,value,weight FROM user_dictionary_operations WHERE operation='upsert' AND (user_inserted=1 OR (?3 AND dictionary='pinyin')) AND NOT (?3 AND ?5 AND dictionary='pinyin' AND length(value)<=1) AND (?4 OR dictionary='english') AND dictionary=?6 ORDER BY dictionary,key,value LIMIT ?1 OFFSET ?2"
622    } else {
623        "SELECT dictionary,key,value,weight FROM user_dictionary_operations WHERE operation='upsert' AND (user_inserted=1 OR (?3 AND dictionary='pinyin')) AND NOT (?3 AND ?5 AND dictionary='pinyin' AND length(value)<=1) AND (?4 OR dictionary='english') ORDER BY dictionary,key,value LIMIT ?1 OFFSET ?2"
624    };
625    let mut statement = connection
626        .prepare(sql)
627        .map_err(|_| failed(DICTIONARY_NOT_READ))?;
628    let mut page = PersonalDictionaryPage::default();
629    let read = (|| -> rusqlite::Result<()> {
630        let mut rows = if let Some(kind) = kind {
631            statement.query(params![
632                page_bind(limit + 1),
633                page_bind(offset),
634                i64::from(include_learned_pinyin),
635                i64::from(main_dictionary),
636                i64::from(exclude_single_pinyin),
637                kind.journal_name()
638            ])?
639        } else {
640            statement.query(params![
641                page_bind(limit + 1),
642                page_bind(offset),
643                i64::from(include_learned_pinyin),
644                i64::from(main_dictionary),
645                i64::from(exclude_single_pinyin)
646            ])?
647        };
648        while let Some(row) = rows.next()? {
649            if page.entries.len() == limit {
650                page.has_more = true;
651                break;
652            }
653            let Some(kind) = PersonalDictionaryKind::from_journal_name(&column_text(row, 0)?)
654            else {
655                continue;
656            };
657            page.entries.push(PersonalDictionaryEntry {
658                kind,
659                key: column_text(row, 1)?,
660                value: column_text(row, 2)?,
661                weight: column_i64(row, 3)?,
662            });
663        }
664        Ok(())
665    })();
666    if read.is_err() {
667        return Err(failed(PAGE_READ_UNFINISHED));
668    }
669    Ok(page)
670}
671
672/// The main dictionary opened for writing with the journal attached as `personal_journal` and the English dictionary as `replay_english`, both prepared first (J:1822-1836). `main_dictionary` 为假(代次里没有 `msime-pinyin.db`)时以一个内存库代替它,只有 attach 的日志和英文词库可写;这时代次里也没有 `msime-english.db` 就以 `NO_ENGLISH_DICTIONARY` 失败,不在代次里建一个空的英文库。
673///
674/// 日志是 WAL 时(iOS 以外)编辑事务只在每个文件内原子:崩溃可能留下日志已记下、词库工作副本回滚,下一次回放补齐,见 `journal::prefer_write_ahead_log`。
675pub(super) fn open_edit_connection(
676    paths: &RuntimePaths,
677    main_dictionary: bool,
678) -> Result<Connection> {
679    let journal = paths.user(assets::USER_JOURNAL);
680    let english = paths.dictionary(assets::ENGLISH_DICTIONARY);
681    if !main_dictionary && !english.is_file() {
682        return Err(failed(NO_ENGLISH_DICTIONARY));
683    }
684    if ensure_user_database(&journal).is_err() || ensure_english_schema(&english).is_err() {
685        return Err(failed(STORAGE_NOT_PREPARED));
686    }
687    let connection = if main_dictionary {
688        open_database(
689            &paths.dictionary(assets::MAIN_DICTIONARY),
690            OpenFlags::SQLITE_OPEN_READ_WRITE,
691        )
692        .map_err(|_| failed(DICTIONARY_NOT_OPENED))?
693    } else {
694        open_without_main_dictionary().map_err(|_| failed(DICTIONARY_NOT_OPENED))?
695    };
696    for (schema, path) in [("personal_journal", &journal), ("replay_english", &english)] {
697        attach(&connection, path, schema).map_err(|_| failed(STORAGE_NOT_ATTACHED))?;
698    }
699    Ok(connection)
700}
701
702#[derive(Debug, PartialEq, Eq)]
703pub(super) enum Receipt {
704    /// 还没有编辑使用这个请求 id。
705    Fresh,
706    /// 相同编辑已经用这个 id 提交;提交并报告成功。
707    Retry,
708    /// 相同的保留已有词条编辑已经用这个 id 提交;提交并报告已存在。
709    AlreadyPresent,
710}
711
712const ALREADY_PRESENT_RECEIPT_PREFIX: &str = "already_present;";
713
714/// Idempotency by request id: the stored payload must match exactly (J:1858-1880).
715pub(super) fn check_receipt(
716    transaction: &Connection,
717    request_id: &str,
718    payload: &str,
719) -> Result<Receipt> {
720    let mut statement = transaction
721        .prepare(
722            "SELECT payload FROM personal_journal.personal_dictionary_receipts WHERE request_id=?1",
723        )
724        .map_err(|_| failed(RECEIPT_NOT_READ))?;
725    let stored = statement
726        .query_row([request_id], |row| row.get::<_, Option<String>>(0))
727        .optional()
728        .map_err(|_| failed(RECEIPT_READ_UNFINISHED))?;
729    match stored {
730        None => Ok(Receipt::Fresh),
731        Some(stored) if stored.as_deref() == Some(payload) => Ok(Receipt::Retry),
732        Some(stored)
733            if stored
734                .as_deref()
735                .and_then(|stored| stored.strip_prefix(ALREADY_PRESENT_RECEIPT_PREFIX))
736                == Some(payload) =>
737        {
738            Ok(Receipt::AlreadyPresent)
739        }
740        Some(_) => Err(failed(RECEIPT_REUSED)),
741    }
742}
743
744pub(super) fn save_receipt(
745    transaction: &Connection,
746    request_id: &str,
747    payload: &str,
748) -> Result<()> {
749    transaction
750        .execute(
751            "INSERT INTO personal_journal.personal_dictionary_receipts(request_id,payload) VALUES(?1,?2)",
752            params![request_id, payload],
753        )
754        .map_err(|_| failed(RECEIPT_NOT_SAVED))?;
755    Ok(())
756}
757
758/// `none;` or `<kind ordinal>;<weight>;<len>:<key><len>:<value>` with byte lengths (J:1847-1857). Stored receipts compare against this text, so it must not change.
759fn append_payload(payload: &mut String, entry: Option<&PersonalDictionaryEntry>) {
760    let Some(entry) = entry else {
761        payload.push_str("none;");
762        return;
763    };
764    payload.push_str(&format!("{};{};", entry.kind as u8, entry.weight));
765    for text in [&entry.key, &entry.value] {
766        payload.push_str(&format!("{}:{}", text.len(), text));
767    }
768}
769
770/// Apply to the working table, then journal it as the user's own row: a removal is a tombstone with `user_inserted = 1`, so replay also removes a same-named shipped row (J:1757-1790).
771fn apply_personal_edit(
772    connection: &Connection,
773    entry: &PersonalDictionaryEntry,
774    remove: bool,
775) -> rusqlite::Result<bool> {
776    let (key, value, weight) = (entry.key.as_str(), entry.value.as_str(), entry.weight);
777    let (key_bytes, value_bytes) = (key.as_bytes(), value.as_bytes());
778    let applied = match entry.kind {
779        PersonalDictionaryKind::Pinyin => {
780            apply_pinyin(connection, key_bytes, value_bytes, remove, weight)?
781        }
782        PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => apply_simple(
783            connection,
784            entry.kind.wubi_table().unwrap_or_default(),
785            key_bytes,
786            value_bytes,
787            remove,
788            weight,
789        )?,
790        PersonalDictionaryKind::QuickPhrase => apply_simple(
791            connection,
792            "quick_parases",
793            key_bytes,
794            value_bytes,
795            remove,
796            weight,
797        )?,
798        PersonalDictionaryKind::English => apply_english(
799            connection,
800            key_bytes,
801            value_bytes,
802            remove,
803            weight,
804            value_bytes,
805        )?,
806    };
807    if !applied {
808        return Ok(false);
809    }
810    let display = if entry.kind == PersonalDictionaryKind::English {
811        value
812    } else {
813        ""
814    };
815    connection.execute(
816        "INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,?4,?5,?6,1) ON CONFLICT(dictionary,key,value) DO UPDATE SET operation=excluded.operation, weight=excluded.weight,display=excluded.display,user_inserted=1,updated_at=unixepoch()",
817        params![
818            entry.kind.journal_name(),
819            key,
820            value,
821            if remove { "delete" } else { "upsert" },
822            if remove { 0 } else { weight },
823            display
824        ],
825    )?;
826    Ok(true)
827}
828
829/// Empty, or at most 128 of `[A-Za-z0-9_-]` (J:2020-2026).
830pub(super) fn valid_request_id(request_id: &str) -> bool {
831    request_id.len() <= MAX_REQUEST_ID_LENGTH
832        && request_id
833            .bytes()
834            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
835}
836
837pub(super) fn failed(message: &str) -> EngineError {
838    EngineError::failed(message)
839}
840
841/// An edit reports a validation failure as its own failure, as `result.error` did.
842fn as_failure(error: EngineError) -> EngineError {
843    match error {
844        EngineError::InvalidArgument(message) => EngineError::Failed(message),
845        other => other,
846    }
847}
848
849/// `LIMIT` and `OFFSET` binds; both are bounded by the page checks above.
850pub(super) fn page_bind(value: usize) -> i64 {
851    value as i64
852}
853
854/// `sqlite3_column_text` as the reference's pages read it: NULL is empty, and bytes that are not UTF-8 are replaced for display.
855pub(super) fn column_text(row: &Row<'_>, index: usize) -> rusqlite::Result<String> {
856    Ok(match row.get_ref(index)? {
857        ValueRef::Null => String::new(),
858        ValueRef::Text(bytes) | ValueRef::Blob(bytes) => {
859            String::from_utf8_lossy(bytes).into_owned()
860        }
861        ValueRef::Integer(number) => number.to_string(),
862        ValueRef::Real(number) => number.to_string(),
863    })
864}
865
866/// `sqlite3_column_int64`: NULL reads as 0.
867pub(super) fn column_i64(row: &Row<'_>, index: usize) -> rusqlite::Result<i64> {
868    Ok(row.get::<_, Option<i64>>(index)?.unwrap_or(0))
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874    use crate::user_dictionary::generation::prepare_runtime_paths;
875    use crate::user_dictionary::replay::tests::{sql, weight};
876    use std::path::Path;
877
878    fn entry(
879        kind: PersonalDictionaryKind,
880        key: &str,
881        value: &str,
882        weight: i64,
883    ) -> PersonalDictionaryEntry {
884        PersonalDictionaryEntry {
885            kind,
886            key: key.into(),
887            value: value.into(),
888            weight,
889        }
890    }
891
892    /// test_personal_dictionary.cpp:53-60.
893    fn fixture(root: &Path) -> RuntimePaths {
894        let resources = root.join("resources");
895        std::fs::create_dir_all(&resources).unwrap();
896        sql(
897            &resources.join(assets::MAIN_DICTIONARY),
898            "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
899             CREATE TABLE tbl_7_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
900             CREATE TABLE tbl_others_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
901             CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
902             CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);",
903        );
904        ensure_english_schema(&resources.join(assets::ENGLISH_DICTIONARY)).unwrap();
905        prepare_runtime_paths(&resources, &root.join("user"), &root.join("cache"), "first").unwrap()
906    }
907
908    /// What a session would find: the row in the working table the kind lives in.
909    fn has(paths: &RuntimePaths, entry: &PersonalDictionaryEntry) -> bool {
910        let (database, query) = match entry.kind {
911            PersonalDictionaryKind::English => (
912                assets::ENGLISH_DICTIONARY,
913                "SELECT count(*) FROM english_words WHERE word=?1 AND display=?2".to_owned(),
914            ),
915            PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => (
916                assets::MAIN_DICTIONARY,
917                format!(
918                    "SELECT count(*) FROM {} WHERE key=?1 AND value=?2",
919                    entry.kind.wubi_table().unwrap()
920                ),
921            ),
922            PersonalDictionaryKind::QuickPhrase => (
923                assets::MAIN_DICTIONARY,
924                "SELECT count(*) FROM quick_parases WHERE key=?1 AND value=?2".to_owned(),
925            ),
926            PersonalDictionaryKind::Pinyin => (
927                assets::MAIN_DICTIONARY,
928                format!(
929                    "SELECT count(*) FROM \"{}\" WHERE key=?1 AND value=?2",
930                    crate::user_dictionary::journal::pinyin_table(&entry.key).unwrap()
931                ),
932            ),
933        };
934        let count: i64 = Connection::open(paths.dictionary(database))
935            .unwrap()
936            .query_row(&query, params![entry.key, entry.value], |row| row.get(0))
937            .unwrap();
938        count > 0
939    }
940
941    #[test]
942    fn preserved_import_receipt_survives_removal_before_retry() {
943        let root = tempfile::tempdir().unwrap();
944        let paths = fixture(root.path());
945        let original = entry(PersonalDictionaryKind::Pinyin, "ni'hao", "合成", 50_000);
946        edit_personal_dictionary(&paths, None, Some(&original), "fixture-owner").unwrap();
947        let imported = PersonalDictionaryEntry {
948            weight: 10_000,
949            ..original.clone()
950        };
951        let batch = [PersonalDictionaryEdit {
952            previous: None,
953            replacement: Some(&imported),
954            request_id: "collections-preserved-retry",
955            preserve_existing: true,
956        }];
957        let first = edit_personal_dictionary_batch_with(&paths, true, &batch).unwrap();
958        assert_eq!(
959            first[0].as_ref().unwrap(),
960            &PersonalDictionaryApplied::AlreadyPresent
961        );
962        edit_personal_dictionary(&paths, Some(&original), None, "fixture-owner-removes").unwrap();
963        assert!(!has(&paths, &original));
964        let retried = edit_personal_dictionary_batch_with(&paths, true, &batch).unwrap();
965        assert_eq!(
966            retried[0].as_ref().unwrap(),
967            &PersonalDictionaryApplied::AlreadyPresent
968        );
969        assert!(!has(&paths, &imported));
970    }
971
972    #[test]
973    fn preserved_import_receipt_rejects_a_changed_payload() {
974        let root = tempfile::tempdir().unwrap();
975        let paths = fixture(root.path());
976        let original = entry(PersonalDictionaryKind::Pinyin, "ni'hao", "合成", 50_000);
977        edit_personal_dictionary(&paths, None, Some(&original), "fixture-owner").unwrap();
978        let imported = PersonalDictionaryEntry {
979            weight: 10_000,
980            ..original
981        };
982        let attempt = |replacement| {
983            edit_personal_dictionary_batch_with(
984                &paths,
985                true,
986                &[PersonalDictionaryEdit {
987                    previous: None,
988                    replacement: Some(replacement),
989                    request_id: "collections-preserved-payload",
990                    preserve_existing: true,
991                }],
992            )
993            .unwrap()
994            .remove(0)
995        };
996        assert_eq!(
997            attempt(&imported).unwrap(),
998            PersonalDictionaryApplied::AlreadyPresent
999        );
1000        let changed = PersonalDictionaryEntry {
1001            weight: 10_001,
1002            ..imported.clone()
1003        };
1004        assert_eq!(attempt(&changed).unwrap_err().to_string(), RECEIPT_REUSED);
1005    }
1006
1007    #[test]
1008    fn preserved_import_receipt_failure_is_not_reported_as_success() {
1009        let root = tempfile::tempdir().unwrap();
1010        let paths = fixture(root.path());
1011        let original = entry(PersonalDictionaryKind::Pinyin, "ni'hao", "合成", 50_000);
1012        edit_personal_dictionary(&paths, None, Some(&original), "fixture-owner").unwrap();
1013        let journal = paths.user(assets::USER_JOURNAL);
1014        sql(&journal, "CREATE TRIGGER reject_preserved_receipt BEFORE INSERT ON personal_dictionary_receipts WHEN NEW.request_id='collections-preserved-failure' BEGIN SELECT RAISE(ABORT,'synthetic failure'); END;");
1015        let imported = PersonalDictionaryEntry {
1016            weight: 10_000,
1017            ..original.clone()
1018        };
1019        let result = edit_personal_dictionary_batch_with(
1020            &paths,
1021            true,
1022            &[PersonalDictionaryEdit {
1023                previous: None,
1024                replacement: Some(&imported),
1025                request_id: "collections-preserved-failure",
1026                preserve_existing: true,
1027            }],
1028        )
1029        .unwrap()
1030        .remove(0);
1031        assert_eq!(result.unwrap_err().to_string(), RECEIPT_NOT_SAVED);
1032        assert!(has(&paths, &original));
1033        let receipts: i64 = Connection::open(journal).unwrap().query_row(
1034            "SELECT count(*) FROM personal_dictionary_receipts WHERE request_id='collections-preserved-failure'",
1035            [], |row| row.get(0)
1036        ).unwrap();
1037        assert_eq!(receipts, 0);
1038    }
1039
1040    // engine-bridge tests.rs `validates_and_normalizes_personal_dictionary_entries` and test_personal_dictionary.cpp:63-76 with the overlay's English rule.
1041    #[test]
1042    fn validation_normalises_and_rejects_with_the_reference_messages() {
1043        use PersonalDictionaryKind::*;
1044        let normalized =
1045            validate_personal_dictionary_entry(&entry(Pinyin, "NI HAO", "拟好", 12345)).unwrap();
1046        assert_eq!(normalized, entry(Pinyin, "ni'hao", "拟好", 12345));
1047        assert_eq!(
1048            validate_personal_dictionary_entry(&entry(English, "dont", "don't", 100_000)).unwrap(),
1049            entry(English, "dont", "don't", 100_000)
1050        );
1051        assert_eq!(
1052            validate_personal_dictionary_entry(&entry(English, "hello", "different", 100_000))
1053                .unwrap(),
1054            entry(English, "hello", "different", 100_000)
1055        );
1056        assert_eq!(
1057            validate_personal_dictionary_entry(&entry(
1058                QuickPhrase,
1059                "TEST1",
1060                "fixture\nsecond\tline",
1061                1
1062            ))
1063            .unwrap()
1064            .key,
1065            "test1"
1066        );
1067        for (invalid, message) in [
1068            (entry(Pinyin, "nihao", "你好", 1), INCOMPLETE_PINYIN),
1069            (entry(Pinyin, "ni''hao", "你好", 1), INCOMPLETE_PINYIN),
1070            (entry(Pinyin, "ni'hao", "你", 1), SYLLABLE_COUNT_MISMATCH),
1071            (
1072                entry(Pinyin, &vec!["ni"; 65].join("'"), &"你".repeat(65), 1),
1073                SYLLABLE_COUNT_MISMATCH,
1074            ),
1075            (entry(Wubi, "abcde", "词", 1), INVALID_WUBI_CODE),
1076            (entry(Wubi, "ab1", "词", 1), INVALID_WUBI_CODE),
1077            (entry(Wubi98, "abcde", "词", 1), INVALID_WUBI_CODE),
1078            (
1079                entry(QuickPhrase, "bad;code", "text", 1),
1080                INVALID_QUICK_PHRASE_CODE,
1081            ),
1082            (
1083                entry(QuickPhrase, &"a".repeat(33), "text", 1),
1084                INVALID_QUICK_PHRASE_CODE,
1085            ),
1086            (
1087                entry(English, "wrong_code", "Word", 1),
1088                INVALID_ENGLISH_CODE,
1089            ),
1090            (entry(English, "hello", "a\0b", 1), CONTROL_CHARACTER),
1091            (entry(Pinyin, "ni", "你\u{7f}", 1), CONTROL_CHARACTER),
1092            (entry(Wubi, "ab", "词\n", 1), CONTROL_CHARACTER),
1093            (entry(Pinyin, "ni", "你", 0), WEIGHT_OUT_OF_RANGE),
1094            (entry(Pinyin, "ni", "你", 100_000_001), WEIGHT_OUT_OF_RANGE),
1095            (entry(Pinyin, "", "你", 1), UNBOUNDED_ENTRY),
1096            (entry(Pinyin, "ni", "", 1), UNBOUNDED_ENTRY),
1097            (
1098                entry(QuickPhrase, "a", &"x".repeat(4097), 1),
1099                UNBOUNDED_ENTRY,
1100            ),
1101        ] {
1102            let error = validate_personal_dictionary_entry(&invalid).unwrap_err();
1103            assert!(
1104                matches!(error, EngineError::InvalidArgument(_)),
1105                "{invalid:?}"
1106            );
1107            assert_eq!(error.to_string(), message, "{invalid:?}");
1108        }
1109    }
1110
1111    // test_personal_dictionary.cpp:61-163, probing the working tables where the reference typed into a session.
1112    /// 引擎层的编辑接口自己取维护租约:还有会话持共享租约时报 `Busy`、什么也不写;不合法的改动照旧先报出来;会话结束后同一改动照常生效。
1113    #[test]
1114    fn engine_level_edits_take_the_maintenance_lease() {
1115        use PersonalDictionaryKind::*;
1116        let root = tempfile::tempdir().unwrap();
1117        let paths = fixture(root.path());
1118        let pinyin =
1119            validate_personal_dictionary_entry(&entry(Pinyin, "NI HAO", "拟好", 12345)).unwrap();
1120        let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
1121            .unwrap()
1122            .unwrap();
1123
1124        let error = edit_personal_dictionary(&paths, None, Some(&pinyin), "busy-1").unwrap_err();
1125        assert_eq!(error.code(), crate::ErrorCode::Busy);
1126        assert_eq!(
1127            error.to_string(),
1128            crate::diagnostics::DICTIONARY_MAINTENANCE_BUSY
1129        );
1130        assert!(!has(&paths, &pinyin));
1131        let invalid = entry(Pinyin, "nihao", "你好", 1);
1132        let error = edit_personal_dictionary(&paths, None, Some(&invalid), "").unwrap_err();
1133        assert_eq!(error.code(), crate::ErrorCode::Failed);
1134        // 门面的 `_with` 版本假定调用方已持有租约,不再取锁。
1135        drop(session);
1136        let maintenance =
1137            crate::DictionaryAccess::try_maintenance(&paths.user_data, &paths.dictionaries)
1138                .unwrap()
1139                .unwrap();
1140        edit_personal_dictionary_with(&paths, true, None, Some(&pinyin), "busy-1").unwrap();
1141        assert!(has(&paths, &pinyin));
1142        drop(maintenance);
1143        edit_personal_dictionary(&paths, Some(&pinyin), None, "busy-2").unwrap();
1144        assert!(!has(&paths, &pinyin));
1145    }
1146
1147    #[test]
1148    fn edits_replace_page_refuse_stale_and_survive_an_upgrade() {
1149        use PersonalDictionaryKind::*;
1150        let root = tempfile::tempdir().unwrap();
1151        let paths = fixture(root.path());
1152        assert_eq!(
1153            personal_dictionary_entries(&paths, 0, 100, false).unwrap(),
1154            PersonalDictionaryPage::default()
1155        );
1156        let journal = paths.user(assets::USER_JOURNAL);
1157
1158        for invalid in [
1159            entry(Pinyin, "nihao", "你好", 1),
1160            entry(Wubi, "abcde", "词", 1),
1161            entry(English, "hello", "a\0b", 1),
1162        ] {
1163            let error = edit_personal_dictionary(&paths, None, Some(&invalid), "").unwrap_err();
1164            assert!(matches!(error, EngineError::Failed(_)), "{error}");
1165        }
1166        assert_eq!(
1167            edit_personal_dictionary(&paths, None, None, "")
1168                .unwrap_err()
1169                .to_string(),
1170            ENTRY_REQUIRED
1171        );
1172
1173        let pinyin =
1174            validate_personal_dictionary_entry(&entry(Pinyin, "NI HAO", "拟好", 12345)).unwrap();
1175        edit_personal_dictionary(&paths, None, Some(&pinyin), "").unwrap();
1176        assert!(has(&paths, &pinyin));
1177        assert_eq!(
1178            weight(
1179                &paths.dictionary(assets::MAIN_DICTIONARY),
1180                "SELECT count(*) FROM tbl_2_n WHERE jp='nh' AND weight=12345"
1181            ),
1182            Some(1)
1183        );
1184        let wubi = entry(Wubi, "wq", "拟好", 12345);
1185        let english = entry(English, "metasequoia", "Metasequoia", 12345);
1186        let quick = entry(QuickPhrase, "test1", "fixture\nsecond line", 12345);
1187        for added in [&wubi, &english, &quick] {
1188            edit_personal_dictionary(&paths, None, Some(added), "").unwrap();
1189            assert!(has(&paths, added), "{added:?}");
1190        }
1191        assert_eq!(
1192            weight(&journal, "SELECT count(*) FROM user_dictionary_operations WHERE dictionary='english' AND display='Metasequoia' AND user_inserted=1"),
1193            Some(1)
1194        );
1195
1196        // Moving between dictionaries commits the old tombstone and the new row together.
1197        let moved = entry(English, "fixtureword", "FixtureWord", quick.weight);
1198        edit_personal_dictionary(&paths, Some(&quick), Some(&moved), "").unwrap();
1199        assert!(has(&paths, &moved) && !has(&paths, &quick));
1200        edit_personal_dictionary(&paths, Some(&moved), Some(&quick), "").unwrap();
1201        assert!(!has(&paths, &moved) && has(&paths, &quick));
1202
1203        let first = personal_dictionary_entries(&paths, 0, 2, false).unwrap();
1204        assert_eq!(first.entries.len(), 2);
1205        assert!(first.has_more);
1206        assert_eq!(first.entries[0], english);
1207        let rest = personal_dictionary_entries(&paths, 2, 2, false).unwrap();
1208        assert_eq!(rest.entries.len(), 2);
1209        assert!(!rest.has_more);
1210        assert_eq!(rest.entries, vec![quick.clone(), wubi.clone()]);
1211        for (offset, limit) in [(0, 0), (0, 1001), (1_000_001, 1)] {
1212            assert_eq!(
1213                personal_dictionary_entries(&paths, offset, limit, false)
1214                    .unwrap_err()
1215                    .to_string(),
1216                INVALID_PAGE
1217            );
1218        }
1219
1220        // Seven syllables use a numbered table; eight and nine use `tbl_others_n` (the format contract).
1221        for count in [7, 8, 9] {
1222            let long = entry(
1223                Pinyin,
1224                &vec!["ni"; count].join("'"),
1225                &"你".repeat(count),
1226                100_000,
1227            );
1228            edit_personal_dictionary(&paths, None, Some(&long), "").unwrap();
1229            assert!(has(&paths, &long), "{count}");
1230        }
1231
1232        let changed = PersonalDictionaryEntry {
1233            value: "你好".into(),
1234            ..pinyin.clone()
1235        };
1236        let stale = PersonalDictionaryEntry {
1237            weight: pinyin.weight - 1,
1238            ..pinyin.clone()
1239        };
1240        assert_eq!(
1241            edit_personal_dictionary(&paths, Some(&stale), Some(&changed), "")
1242                .unwrap_err()
1243                .to_string(),
1244            ENTRY_CHANGED
1245        );
1246        assert!(has(&paths, &pinyin));
1247        // `bu'hao` has no table in the fixture, so the replacement fails and the removal of `previous` rolls back with it.
1248        let missing_table = entry(Pinyin, "bu'hao", "补好", pinyin.weight);
1249        assert_eq!(
1250            edit_personal_dictionary(&paths, Some(&pinyin), Some(&missing_table), "")
1251                .unwrap_err()
1252                .to_string(),
1253            ENTRY_NOT_SAVED
1254        );
1255        assert!(has(&paths, &pinyin));
1256
1257        edit_personal_dictionary(&paths, Some(&pinyin), Some(&changed), "edit-1").unwrap();
1258        assert!(!has(&paths, &pinyin) && has(&paths, &changed));
1259        edit_personal_dictionary(&paths, Some(&pinyin), Some(&changed), "edit-1").unwrap();
1260        assert_eq!(
1261            edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), "edit-1")
1262                .unwrap_err()
1263                .to_string(),
1264            RECEIPT_REUSED
1265        );
1266        assert!(has(&paths, &changed));
1267        for bad in ["bad id", "é", &"a".repeat(129)] {
1268            assert_eq!(
1269                edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), bad)
1270                    .unwrap_err()
1271                    .to_string(),
1272                INVALID_REQUEST_ID
1273            );
1274        }
1275
1276        // A journal write failure after the candidate update rolls both back and leaves no receipt.
1277        sql(&journal, "CREATE TRIGGER reject_edit BEFORE INSERT ON user_dictionary_operations BEGIN SELECT RAISE(ABORT,'fixture failure'); END;");
1278        assert!(
1279            edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), "failure-retry")
1280                .is_err()
1281        );
1282        assert!(has(&paths, &changed) && !has(&paths, &pinyin));
1283        sql(&journal, "DROP TRIGGER reject_edit;");
1284        edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), "failure-retry").unwrap();
1285        edit_personal_dictionary(&paths, Some(&pinyin), Some(&changed), "").unwrap();
1286        edit_personal_dictionary(&paths, Some(&changed), None, "").unwrap();
1287        assert!(!has(&paths, &changed));
1288        assert_eq!(
1289            weight(&journal, "SELECT user_inserted FROM user_dictionary_operations WHERE value='你好' AND operation='delete' AND weight=0"),
1290            Some(1)
1291        );
1292
1293        let upgraded =
1294            prepare_runtime_paths(&paths.resources, &paths.user_data, &paths.cache, "second")
1295                .unwrap();
1296        assert!(!has(&upgraded, &changed) && !has(&upgraded, &pinyin));
1297        assert!(has(&upgraded, &wubi) && has(&upgraded, &english) && has(&upgraded, &quick));
1298        // A retry of an edit committed before later changes stays a no-op instead of resurrecting the removed word.
1299        edit_personal_dictionary(&upgraded, Some(&pinyin), Some(&changed), "edit-1").unwrap();
1300        assert!(!has(&upgraded, &changed));
1301        let entries = personal_dictionary_entries(&upgraded, 0, 100, false).unwrap();
1302        assert_eq!(entries.entries.len(), 6);
1303    }
1304
1305    #[test]
1306    fn removing_a_pinyin_word_drops_its_personal_context_and_reweighting_keeps_it() {
1307        use PersonalDictionaryKind::*;
1308        let root = tempfile::tempdir().unwrap();
1309        let paths = fixture(root.path());
1310        let journal = paths.user(assets::USER_JOURNAL);
1311        let word = entry(Pinyin, "ni'hao", "你好", 100);
1312        edit_personal_dictionary(&paths, None, Some(&word), "").unwrap();
1313        sql(&journal, "INSERT INTO personal_bigram VALUES('\u{1}','你好',3),('你好','世界',2); INSERT INTO personal_trigram VALUES('我','你好','世界',1);");
1314        let reweighted = PersonalDictionaryEntry {
1315            weight: 200,
1316            ..word.clone()
1317        };
1318        edit_personal_dictionary(&paths, Some(&word), Some(&reweighted), "").unwrap();
1319        assert_eq!(
1320            weight(&journal, "SELECT count(*) FROM personal_bigram"),
1321            Some(2)
1322        );
1323        edit_personal_dictionary(&paths, Some(&reweighted), None, "").unwrap();
1324        assert_eq!(
1325            weight(&journal, "SELECT count(*) FROM personal_bigram"),
1326            Some(0)
1327        );
1328        assert_eq!(
1329            weight(&journal, "SELECT count(*) FROM personal_trigram"),
1330            Some(0)
1331        );
1332    }
1333
1334    #[test]
1335    fn learned_pinyin_is_exported_without_single_characters_and_unknown_rows_are_skipped() {
1336        let root = tempfile::tempdir().unwrap();
1337        let paths = fixture(root.path());
1338        let journal = paths.user(assets::USER_JOURNAL);
1339        ensure_user_database(&journal).unwrap();
1340        sql(
1341            &journal,
1342            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES
1343               ('pinyin','ni''hao','你好','upsert',1,0),
1344               ('pinyin','ni','你','upsert',2,0),
1345               ('pinyin','ni''men','你们','delete',0,0),
1346               ('wubi','wqvb','你好','upsert',3,0),
1347               ('japanese','ni','你','upsert',4,1),
1348               ('quick','dh',CAST(x'e794b5ff' AS TEXT),'upsert',5,1);",
1349        );
1350        let user_only = personal_dictionary_entries(&paths, 0, 100, false).unwrap();
1351        assert_eq!(
1352            user_only.entries,
1353            vec![entry(
1354                PersonalDictionaryKind::QuickPhrase,
1355                "dh",
1356                "电\u{fffd}",
1357                5
1358            )]
1359        );
1360        let exported = personal_dictionary_entries(&paths, 0, 100, true).unwrap();
1361        assert_eq!(
1362            exported.entries,
1363            vec![
1364                entry(PersonalDictionaryKind::Pinyin, "ni'hao", "你好", 1),
1365                entry(PersonalDictionaryKind::QuickPhrase, "dh", "电\u{fffd}", 5),
1366            ]
1367        );
1368    }
1369
1370    #[test]
1371    fn kind_export_filters_before_offset_and_keeps_export_rules() {
1372        use PersonalDictionaryKind::*;
1373        let root = tempfile::tempdir().unwrap();
1374        let paths = fixture(root.path());
1375        let journal = paths.user(assets::USER_JOURNAL);
1376        ensure_user_database(&journal).unwrap();
1377        let connection = open_database(&journal, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap();
1378        for kind in [Pinyin, Wubi, QuickPhrase, English, Wubi98] {
1379            for (key, value, weight) in [
1380                ("a", "合成甲", 11),
1381                ("a", "合成乙", 12),
1382                ("b", "合成丙", 13),
1383            ] {
1384                connection.execute(
1385                    "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES(?1,?2,?3,'upsert',?4,1)",
1386                    params![kind.journal_name(), key, value, weight],
1387                ).unwrap();
1388            }
1389            connection.execute(
1390                "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES(?1,'x','合成学习','upsert',21,0), (?1,'y','合成删除','delete',0,1)",
1391                [kind.journal_name()],
1392            ).unwrap();
1393        }
1394        connection.execute_batch(
1395            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES
1396             ('pinyin','c','合','upsert',22,1),
1397             ('japanese','a','合成未知','upsert',23,1);",
1398        ).unwrap();
1399        drop(connection);
1400        for kind in [Pinyin, Wubi, QuickPhrase, English, Wubi98] {
1401            let mut expected = vec![
1402                entry(kind, "a", "合成乙", 12),
1403                entry(kind, "a", "合成甲", 11),
1404                entry(kind, "b", "合成丙", 13),
1405            ];
1406            if kind == Pinyin {
1407                expected.push(entry(kind, "x", "合成学习", 21));
1408            }
1409            let first =
1410                personal_dictionary_export_entries_for_kind(&paths, true, kind, 0, 2).unwrap();
1411            assert_eq!(first.entries, expected[..2]);
1412            assert!(first.has_more);
1413            let last =
1414                personal_dictionary_export_entries_for_kind(&paths, true, kind, 2, 2).unwrap();
1415            assert_eq!(last.entries, expected[2..]);
1416            assert!(!last.has_more);
1417            assert_eq!(
1418                personal_dictionary_export_entries_for_kind(&paths, true, kind, expected.len(), 1)
1419                    .unwrap(),
1420                PersonalDictionaryPage::default()
1421            );
1422            let restricted =
1423                personal_dictionary_export_entries_for_kind(&paths, false, kind, 0, 1000).unwrap();
1424            assert_eq!(
1425                restricted.entries,
1426                if kind == English {
1427                    expected
1428                } else {
1429                    Vec::new()
1430                }
1431            );
1432            assert!(!restricted.has_more);
1433        }
1434    }
1435
1436    #[test]
1437    fn kind_export_validates_pages_and_reads_without_creating_a_journal() {
1438        use PersonalDictionaryKind::English;
1439        let root = tempfile::tempdir().unwrap();
1440        let paths = fixture(root.path());
1441        let journal = paths.user(assets::USER_JOURNAL);
1442        assert_eq!(
1443            personal_dictionary_export_entries_for_kind(&paths, true, English, 1_000_000, 1000)
1444                .unwrap(),
1445            PersonalDictionaryPage::default()
1446        );
1447        assert!(!journal.exists());
1448        for (offset, limit) in [(0, 0), (0, 1001), (1_000_001, 1)] {
1449            assert_eq!(
1450                personal_dictionary_export_entries_for_kind(&paths, true, English, offset, limit)
1451                    .unwrap_err()
1452                    .to_string(),
1453                INVALID_PAGE
1454            );
1455        }
1456        let mut relative = paths.clone();
1457        relative.user_data = "relative".into();
1458        assert_eq!(
1459            personal_dictionary_export_entries_for_kind(&relative, true, English, 0, 1)
1460                .unwrap_err()
1461                .to_string(),
1462            STORAGE_UNAVAILABLE
1463        );
1464        std::fs::write(&journal, b"synthetic invalid database").unwrap();
1465        assert_eq!(
1466            personal_dictionary_export_entries_for_kind(&paths, true, English, 0, 1)
1467                .unwrap_err()
1468                .to_string(),
1469            DICTIONARY_NOT_READ
1470        );
1471    }
1472
1473    /// English frequency learning lifts a user's own word by `max(listed)+1000` with no ceiling (ranking.rs, J:1027-1031) and keeps it user-inserted, so the listed row can carry a weight above `MAX_ENTRY_WEIGHT`. That row must still be editable and removable: only the replacement is held to the ceiling.
1474    #[test]
1475    fn a_learned_english_word_above_the_ceiling_stays_editable() {
1476        use PersonalDictionaryKind::English;
1477        let root = tempfile::tempdir().unwrap();
1478        let paths = fixture(root.path());
1479        let word = entry(English, "foo", "foo", 10);
1480        edit_personal_dictionary(&paths, None, Some(&word), "").unwrap();
1481        let learned = 20_000_001_000_i64;
1482        let journal = open_database(
1483            &paths.user(assets::USER_JOURNAL),
1484            OpenFlags::SQLITE_OPEN_READ_WRITE,
1485        )
1486        .unwrap();
1487        journal
1488            .execute(
1489                crate::user_dictionary::journal::UPSERT_JOURNAL_SQL,
1490                params!["english", "foo", "foo", learned, "foo"],
1491            )
1492            .unwrap();
1493        drop(journal);
1494        let listed = personal_dictionary_entries(&paths, 0, 10, false)
1495            .unwrap()
1496            .entries
1497            .into_iter()
1498            .find(|entry| entry.key == "foo")
1499            .unwrap();
1500        assert_eq!(listed.weight, learned);
1501        let reweighted = entry(English, "foo", "foo", 500);
1502        edit_personal_dictionary(&paths, Some(&listed), Some(&reweighted), "").unwrap();
1503        let listed = personal_dictionary_entries(&paths, 0, 10, false)
1504            .unwrap()
1505            .entries;
1506        assert_eq!(listed, vec![reweighted.clone()]);
1507        edit_personal_dictionary(&paths, Some(&reweighted), None, "").unwrap();
1508        assert!(personal_dictionary_entries(&paths, 0, 10, false)
1509            .unwrap()
1510            .entries
1511            .is_empty());
1512        // The replacement keeps the ceiling.
1513        let too_heavy = entry(English, "bar", "bar", MAX_ENTRY_WEIGHT + 1);
1514        assert!(edit_personal_dictionary(&paths, None, Some(&too_heavy), "").is_err());
1515    }
1516
1517    #[test]
1518    fn receipt_payloads_keep_the_reference_text() {
1519        let mut payload = String::new();
1520        append_payload(&mut payload, None);
1521        append_payload(
1522            &mut payload,
1523            Some(&entry(PersonalDictionaryKind::English, "dont", "don't", 7)),
1524        );
1525        assert_eq!(payload, "none;3;7;4:dont5:don't");
1526        let mut chinese = String::new();
1527        append_payload(
1528            &mut chinese,
1529            Some(&entry(
1530                PersonalDictionaryKind::Pinyin,
1531                "ni'hao",
1532                "你好",
1533                12345,
1534            )),
1535        );
1536        assert_eq!(chinese, "0;12345;6:ni'hao6:你好");
1537    }
1538}