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