Skip to main content

msime_engine/user_dictionary/
bundled.rs

1//! Browsing and editing shipped dictionary rows (overlays.md §9.3, user-dictionary.md §15.4-§15.5).
2
3use rusqlite::{params, OpenFlags, OptionalExtension, TransactionBehavior};
4
5use crate::assets;
6use crate::error::Result;
7use crate::format;
8use crate::paths::RuntimePaths;
9use crate::types::{PersonalDictionaryEntry, PersonalDictionaryKind};
10use crate::user_dictionary::journal::{open_database, pinyin_table};
11use crate::user_dictionary::personal::{
12    check_receipt, column_i64, column_text, failed, maintenance, open_edit_connection, page_bind,
13    save_receipt, valid_request_id, Receipt, DICTIONARY_NOT_OPENED, EDIT_NOT_BEGUN, ENTRY_CHANGED,
14    INVALID_REQUEST_ID, MAX_ENTRY_WEIGHT, MAX_PAGE_LIMIT, MAX_PAGE_OFFSET, NO_CHINESE_DICTIONARY,
15    RETRY_NOT_FINISHED, STORAGE_NOT_ATTACHED, STORAGE_UNAVAILABLE,
16};
17use crate::user_dictionary::replay::attach;
18
19pub const MAX_QUERY_BYTES: usize = 256;
20
21const MAX_BUNDLED_KEY_BYTES: usize = 1_024;
22const MAX_BUNDLED_VALUE_BYTES: usize = 4_096;
23
24const INVALID_LOOKUP: &str = "Invalid dictionary lookup";
25const DICTIONARY_NOT_READ: &str = "Cannot read dictionary";
26const LOOKUP_UNFINISHED: &str = "Cannot finish reading dictionary";
27const WEIGHT_OUT_OF_RANGE: &str = "Weight is outside 1 to 100000000";
28const INVALID_BUNDLED_ENTRY: &str = "Invalid bundled dictionary entry";
29const PERSONAL_NOT_READ: &str = "Cannot read personal dictionary";
30const USER_ENTRY: &str = "The entry is a user entry; edit it as one";
31const ENTRY_NOT_SAVED: &str = "Cannot save dictionary entry";
32const ENTRY_NOT_REMOVED: &str = "Cannot remove dictionary entry";
33const EDIT_NOT_JOURNALED: &str = "Cannot journal dictionary edit";
34const EDIT_NOT_COMMITTED: &str = "Cannot commit dictionary edit";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DictionaryTableEntry {
38    pub entry: PersonalDictionaryEntry,
39    /// The user's own word, edited through `edit_personal_dictionary`; any other row shipped (or was learned) and only its weight can change.
40    pub user_inserted: bool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
44pub struct DictionaryTablePage {
45    pub entries: Vec<DictionaryTableEntry>,
46    pub has_more: bool,
47}
48
49/// 只读查询:ASCII 按编码前缀,含非 ASCII 字符时按词条字面包含匹配,空查询列出全部。拼音编码忽略分隔符、空格和大小写。个人行优先,再按精确匹配、权重、编码和词条排序。
50///
51/// 无效编码或缺少目标词库时返回空页;查询不创建或写入词库和 journal。
52pub fn dictionary_table_entries(
53    paths: &RuntimePaths,
54    kind: PersonalDictionaryKind,
55    query: &str,
56    offset: usize,
57    limit: usize,
58) -> Result<DictionaryTablePage> {
59    if limit == 0
60        || limit > MAX_PAGE_LIMIT
61        || offset > MAX_PAGE_OFFSET
62        || query.len() > MAX_QUERY_BYTES
63    {
64        return Err(failed(INVALID_LOOKUP));
65    }
66    let word_search = !query.is_ascii();
67    let code = if word_search {
68        query.trim().to_owned()
69    } else {
70        let Some(code) = lookup_code(kind, query) else {
71            return Ok(DictionaryTablePage::default());
72        };
73        code
74    };
75    if code.is_empty() && !query.trim().is_empty() {
76        return Ok(DictionaryTablePage::default());
77    }
78    paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
79    let english = kind == PersonalDictionaryKind::English;
80    let dictionary = paths.dictionary(if english {
81        assets::ENGLISH_DICTIONARY
82    } else {
83        assets::MAIN_DICTIONARY
84    });
85    if !dictionary.exists() {
86        return Ok(DictionaryTablePage::default());
87    }
88    let connection = open_database(&dictionary, OpenFlags::SQLITE_OPEN_READ_ONLY)
89        .map_err(|_| failed(DICTIONARY_NOT_OPENED))?;
90    // Attached through a read-only connection, the journal is read-only too: the lookup never takes a write lock.
91    let journal_path = paths.user(assets::USER_JOURNAL);
92    let journal = journal_path.exists()
93        && {
94            attach(&connection, &journal_path, "lookup_journal")
95                .map_err(|_| failed(STORAGE_NOT_ATTACHED))?;
96            connection
97            .query_row(
98                "SELECT 1 FROM lookup_journal.sqlite_master WHERE type='table' AND name='user_dictionary_operations'",
99                [],
100                |_| Ok(()),
101            )
102            .optional()
103            .is_ok_and(|found| found.is_some())
104        };
105
106    let tables = lookup_tables(kind, &code, word_search);
107    let (key_column, value_column) = if english {
108        ("word", "display")
109    } else {
110        ("key", "value")
111    };
112    let (matches, exact) = if code.is_empty() {
113        ("1".to_owned(), "0".to_owned())
114    } else if word_search {
115        (
116            format!("instr({value_column},?1)>0"),
117            format!("{value_column}=?1"),
118        )
119    } else {
120        match kind {
121            PersonalDictionaryKind::Pinyin => (
122                "substr(replace(key,'''',''),1,?2)=?1".to_owned(),
123                "replace(key,'''','')=?1".to_owned(),
124            ),
125            PersonalDictionaryKind::QuickPhrase => (
126                "substr(lower(key),1,?2)=?1".to_owned(),
127                "lower(key)=?1".to_owned(),
128            ),
129            // 五笔与英文编码按小写存储,前缀范围可以使用主索引。
130            PersonalDictionaryKind::Wubi
131            | PersonalDictionaryKind::Wubi98
132            | PersonalDictionaryKind::English => (
133                format!("{key_column}>=?1 AND {key_column}<?3"),
134                format!("{key_column}=?1"),
135            ),
136        }
137    };
138    let rows = lookup_rows(
139        &tables,
140        key_column,
141        value_column,
142        &exact,
143        &matches,
144        |table| {
145            connection
146                .query_row(
147                    "SELECT 1 FROM main.sqlite_master WHERE type='table' AND name=?1",
148                    [table],
149                    |_| Ok(()),
150                )
151                .optional()
152                .map(|present| present.is_some())
153        },
154    )
155    .map_err(|_| failed(DICTIONARY_NOT_READ))?;
156    if rows.is_empty() {
157        return Ok(DictionaryTablePage::default());
158    }
159    let mut sql = format!(
160        "SELECT r.key,r.value,{} AS entry_weight,{} AS user_row FROM (SELECT key,value,MAX(weight) AS weight,MAX(exact) AS exact FROM ({}) GROUP BY key,value) AS r",
161        if journal { "COALESCE(j.weight,r.weight)" } else { "r.weight" },
162        if journal { "j.key IS NOT NULL" } else { "0" },
163        rows.join(" UNION ALL "),
164    );
165    if journal {
166        sql.push_str(" LEFT JOIN lookup_journal.user_dictionary_operations AS j ON j.dictionary=?4 AND j.key=r.key AND j.value=r.value AND j.user_inserted=1 AND j.operation='upsert'");
167    }
168    sql.push_str(
169        " ORDER BY user_row DESC,r.exact DESC,entry_weight DESC,r.key,r.value LIMIT ?5 OFFSET ?6",
170    );
171    // 只有 ASCII 编码范围使用排他上界;词条包含查询不改动 UTF-8 字节。
172    let upper = if word_search {
173        String::new()
174    } else {
175        let mut upper = code.clone().into_bytes();
176        if let Some(last) = upper.last_mut() {
177            *last += 1;
178        }
179        String::from_utf8(upper).expect("codes are ASCII")
180    };
181    let mut statement = connection
182        .prepare(&sql)
183        .map_err(|_| failed(DICTIONARY_NOT_READ))?;
184    let mut page = DictionaryTablePage {
185        entries: Vec::new(),
186        has_more: false,
187    };
188    let read = (|| -> rusqlite::Result<()> {
189        // Every statement binds all six numbers even when a clause does not use one: SQLite counts parameters up to the highest number.
190        let mut cursor = statement.query(params![
191            code,
192            page_bind(code.len()),
193            upper,
194            kind.journal_name(),
195            page_bind(limit + 1),
196            page_bind(offset),
197        ])?;
198        while let Some(row) = cursor.next()? {
199            if page.entries.len() == limit {
200                page.has_more = true;
201                break;
202            }
203            if page.entries.capacity() == 0 {
204                page.entries.reserve_exact(limit);
205            }
206            page.entries.push(DictionaryTableEntry {
207                entry: PersonalDictionaryEntry {
208                    kind,
209                    key: column_text(row, 0)?,
210                    value: column_text(row, 1)?,
211                    weight: column_i64(row, 2)?,
212                },
213                user_inserted: column_i64(row, 3)? != 0,
214            });
215        }
216        Ok(())
217    })();
218    if read.is_err() {
219        return Err(failed(LOOKUP_UNFINISHED));
220    }
221    Ok(page)
222}
223
224/// 不碰磁盘就能判定的拒绝:请求号、新权重、行所在的表和长度,没有工作主词库时只能改英文行。
225fn check_bundled_edit(
226    main_dictionary: bool,
227    previous: &PersonalDictionaryEntry,
228    weight: Option<i64>,
229    request_id: &str,
230) -> Result<()> {
231    if !valid_request_id(request_id) {
232        return Err(failed(INVALID_REQUEST_ID));
233    }
234    if weight.is_some_and(|weight| !(1..=MAX_ENTRY_WEIGHT).contains(&weight)) {
235        return Err(failed(WEIGHT_OUT_OF_RANGE));
236    }
237    if bundled_table(previous.kind, &previous.key).is_none()
238        || previous.key.is_empty()
239        || previous.value.is_empty()
240        || previous.key.len() > MAX_BUNDLED_KEY_BYTES
241        || previous.value.len() > MAX_BUNDLED_VALUE_BYTES
242    {
243        return Err(failed(INVALID_BUNDLED_ENTRY));
244    }
245    if !main_dictionary && previous.kind != PersonalDictionaryKind::English {
246        return Err(failed(NO_CHINESE_DICTIONARY));
247    }
248    Ok(())
249}
250
251/// Re-weight (`Some`) or delete (`None`) a row that is not user-inserted, journaled so replay repeats it; `previous.weight` must be the current weight. Request ids behave as for `edit_personal_dictionary`.
252///
253/// Only the new weight is range-checked: a shipped English weight can lie far above what a user entry may carry, and such a row must still be deletable.
254///
255/// 自己取独占的维护租约,规则同 [`crate::edit_personal_dictionary`]。
256pub fn edit_bundled_dictionary_entry(
257    paths: &RuntimePaths,
258    previous: &PersonalDictionaryEntry,
259    weight: Option<i64>,
260    request_id: &str,
261) -> Result<()> {
262    check_bundled_edit(true, previous, weight, request_id)?;
263    let _access = maintenance(paths)?;
264    edit_bundled_dictionary_entry_with(paths, true, previous, weight, request_id)
265}
266
267/// [`edit_bundled_dictionary_entry`],`main_dictionary` 说明代次里有没有 `msime-pinyin.db`。没有时只有英文词库的行能改,其余种类以 `NO_CHINESE_DICTIONARY` 失败。不取维护租约,调用方已经持有。
268pub fn edit_bundled_dictionary_entry_with(
269    paths: &RuntimePaths,
270    main_dictionary: bool,
271    previous: &PersonalDictionaryEntry,
272    weight: Option<i64>,
273    request_id: &str,
274) -> Result<()> {
275    check_bundled_edit(main_dictionary, previous, weight, request_id)?;
276    let Some(table) = bundled_table(previous.kind, &previous.key) else {
277        return Err(failed(INVALID_BUNDLED_ENTRY));
278    };
279    let english = previous.kind == PersonalDictionaryKind::English;
280    let target = format!(
281        "{}.\"{table}\"",
282        if english { "replay_english" } else { "main" }
283    );
284    let row = if english {
285        " WHERE word=?1 AND display=?2"
286    } else {
287        " WHERE key=?1 AND value=?2"
288    };
289    let dictionary = previous.kind.journal_name();
290
291    paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
292    let mut connection = open_edit_connection(paths, main_dictionary)?;
293    let transaction = connection
294        .transaction_with_behavior(TransactionBehavior::Immediate)
295        .map_err(|_| failed(EDIT_NOT_BEGUN))?;
296    // Prefixed so a receipt can never be mistaken for one written by edit_personal_dictionary.
297    let payload = format!(
298        "bundled;{dictionary};{};{};{}:{}{}:{}",
299        previous.weight,
300        weight.map_or_else(|| "delete".to_owned(), |weight| weight.to_string()),
301        previous.key.len(),
302        previous.key,
303        previous.value.len(),
304        previous.value,
305    );
306    if !request_id.is_empty()
307        && check_receipt(&transaction, request_id, &payload)? == Receipt::Retry
308    {
309        return transaction.commit().map_err(|_| failed(RETRY_NOT_FINISHED));
310    }
311    let user_row = transaction
312        .query_row(
313            "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3 AND user_inserted=1 AND operation='upsert'",
314            params![dictionary, previous.key, previous.value],
315            |_| Ok(()),
316        )
317        .optional()
318        .map_err(|_| failed(PERSONAL_NOT_READ))?;
319    if user_row.is_some() {
320        return Err(failed(USER_ENTRY));
321    }
322    // Pinyin tables have no unique key, so a pair can repeat; the lookup lists the highest weight, and that is what `previous` carries.
323    let current = transaction
324        .query_row(
325            &format!("SELECT MAX(weight) FROM {target}{row}"),
326            params![previous.key, previous.value],
327            |row| row.get::<_, Option<i64>>(0),
328        )
329        .map_err(|_| failed(ENTRY_CHANGED))?;
330    if current != Some(previous.weight) {
331        return Err(failed(ENTRY_CHANGED));
332    }
333    let changed = match weight {
334        Some(weight) => transaction.execute(
335            &format!("UPDATE {target} SET weight=?3{row}"),
336            params![previous.key, previous.value, weight],
337        ),
338        None => transaction.execute(
339            &format!("DELETE FROM {target}{row}"),
340            params![previous.key, previous.value],
341        ),
342    };
343    if !matches!(changed, Ok(count) if count > 0) {
344        return Err(failed(if weight.is_some() {
345            ENTRY_NOT_SAVED
346        } else {
347            ENTRY_NOT_REMOVED
348        }));
349    }
350    // The same rows `record_upsert` and `record_delete` write, so replay repeats the change on a fresh dictionary.
351    let journaled = match weight {
352        Some(weight) => transaction.execute(
353            "INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,'upsert',?4,?5,0) ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='upsert',weight=excluded.weight,display=excluded.display,user_inserted=0, updated_at=unixepoch()",
354            params![
355                dictionary,
356                previous.key,
357                previous.value,
358                weight,
359                if english { previous.value.as_str() } else { "" }
360            ],
361        ),
362        None => transaction.execute(
363            "INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation) VALUES(?1,?2,?3,'delete') ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='delete',weight=0,display='',updated_at=unixepoch()",
364            params![dictionary, previous.key, previous.value],
365        ),
366    };
367    if journaled.is_err() {
368        return Err(failed(EDIT_NOT_JOURNALED));
369    }
370    if !request_id.is_empty() {
371        save_receipt(&transaction, request_id, &payload)?;
372    }
373    transaction
374        .commit()
375        .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
376    Ok(())
377}
378
379/// The table a row of the kind lives in, `None` for a malformed pinyin key; English rows live in the attached English dictionary. Unlike the learning tables, quick phrases have one here (J:2001-2015).
380fn bundled_table(kind: PersonalDictionaryKind, key: &str) -> Option<String> {
381    match kind {
382        PersonalDictionaryKind::Pinyin => pinyin_table(key),
383        PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
384            kind.wubi_table().map(str::to_owned)
385        }
386        PersonalDictionaryKind::QuickPhrase => Some("quick_parases".to_owned()),
387        PersonalDictionaryKind::English => Some("english_words".to_owned()),
388    }
389}
390
391/// 编码查询遍历首字母的全部拼音表,词条查询遍历所有首字母;其他种类各用一张表。
392fn lookup_tables(kind: PersonalDictionaryKind, code: &str, word_search: bool) -> Vec<String> {
393    let initials = if !word_search && !code.is_empty() {
394        &code.as_bytes()[..code.len().min(1)]
395    } else {
396        b"abcdefghijklmnopqrstuvwxyz"
397    };
398    let capacity = match kind {
399        PersonalDictionaryKind::Pinyin => (format::MAXIMUM_NUMBERED_SYLLABLES + 1) * initials.len(),
400        PersonalDictionaryKind::Wubi
401        | PersonalDictionaryKind::Wubi98
402        | PersonalDictionaryKind::QuickPhrase
403        | PersonalDictionaryKind::English => 1,
404    };
405    let mut tables = Vec::with_capacity(capacity);
406    match kind {
407        PersonalDictionaryKind::Pinyin => {
408            for initial in initials {
409                for syllables in 1..=format::MAXIMUM_NUMBERED_SYLLABLES + 1 {
410                    if let Some(table) = format::quanpin_table(syllables, *initial) {
411                        tables.push(table);
412                    }
413                }
414            }
415        }
416        PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
417            tables.extend(kind.wubi_table().map(str::to_owned))
418        }
419        PersonalDictionaryKind::QuickPhrase => tables.push("quick_parases".to_owned()),
420        PersonalDictionaryKind::English => tables.push("english_words".to_owned()),
421    }
422    tables
423}
424
425fn lookup_rows<F>(
426    tables: &[String],
427    key_column: &str,
428    value_column: &str,
429    exact: &str,
430    matches: &str,
431    mut table_exists: F,
432) -> rusqlite::Result<Vec<String>>
433where
434    F: FnMut(&str) -> rusqlite::Result<bool>,
435{
436    let mut rows = Vec::with_capacity(tables.len());
437    for table in tables {
438        if table_exists(table)? {
439            rows.push(format!(
440                "SELECT {key_column} AS key,{value_column} AS value,weight,{exact} AS exact FROM main.\"{table}\" WHERE {matches}"
441            ));
442        }
443    }
444    Ok(rows)
445}
446
447/// The query folded to the form codes of the kind are stored in, or `None` when it holds a character no such code can contain. Pinyin drops its separators so a query matches a key however it was split (J:2033-2049).
448fn lookup_code(kind: PersonalDictionaryKind, query: &str) -> Option<String> {
449    let mut code = String::with_capacity(query.len());
450    for byte in query.bytes() {
451        let byte = byte.to_ascii_lowercase();
452        if kind == PersonalDictionaryKind::Pinyin && (byte == b'\'' || byte == b' ') {
453            continue;
454        }
455        let allowed = byte.is_ascii_lowercase()
456            || (kind == PersonalDictionaryKind::QuickPhrase && byte.is_ascii_digit())
457            || (kind == PersonalDictionaryKind::English && (byte == b'\'' || byte == b'-'));
458        if !allowed {
459            return None;
460        }
461        code.push(byte as char);
462    }
463    Some(code)
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::error::EngineError;
470    use crate::user_dictionary::personal::{edit_personal_dictionary, personal_dictionary_entries};
471    use crate::user_dictionary::replay::replay;
472    use crate::user_dictionary::replay::tests::sql;
473
474    /// host-api tests/dictionary_bundled_entries.rs `Fixture`.
475    struct Fixture {
476        _root: tempfile::TempDir,
477        paths: RuntimePaths,
478    }
479
480    impl Fixture {
481        fn new() -> Self {
482            let directory = tempfile::tempdir().unwrap();
483            let root = directory.path().canonicalize().unwrap();
484            let path = |name: &str| {
485                let path = root.join(name);
486                std::fs::create_dir_all(&path).unwrap();
487                path
488            };
489            let paths = RuntimePaths {
490                resources: path("resources"),
491                user_data: path("user"),
492                cache: path("cache"),
493                dictionaries: path("dictionaries"),
494            };
495            sql(
496                &paths.resource(assets::MAIN_DICTIONARY),
497                "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
498                 INSERT INTO tbl_1_n VALUES('ni','n','你',900000);
499                 CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
500                 INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',500000);
501                 INSERT INTO tbl_2_n VALUES('ni''hao','nh','拟好',1000);
502                 INSERT INTO tbl_2_n VALUES('ni''men','nm','你们',400000);
503                 CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
504                 INSERT INTO wubi86 VALUES('wqvb','你好',300);
505                 CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
506                 INSERT INTO quick_parases VALUES('dh','电话',1);",
507            );
508            sql(
509                &paths.resource(assets::ENGLISH_DICTIONARY),
510                "CREATE TABLE english_words(word TEXT COLLATE BINARY NOT NULL,display TEXT NOT NULL,weight INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(word,display)) WITHOUT ROWID;
511                 CREATE TABLE en_zh_glosses(english TEXT PRIMARY KEY,chinese_gloss TEXT);
512                 CREATE TABLE zh_en_glosses(chinese TEXT PRIMARY KEY,english_gloss TEXT);
513                 INSERT INTO english_words VALUES('hello','hello',611054034);",
514            );
515            let fixture = Self {
516                _root: directory,
517                paths,
518            };
519            fixture.install_packaged_dictionaries();
520            fixture
521        }
522
523        fn install_packaged_dictionaries(&self) {
524            for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
525                std::fs::copy(self.paths.resource(name), self.paths.dictionary(name)).unwrap();
526            }
527        }
528
529        /// What an upgrade does: fresh copies of the packaged dictionaries, then the journal replayed onto them.
530        fn upgrade(&self) {
531            self.install_packaged_dictionaries();
532            let result = replay(
533                &self.paths.user(assets::USER_JOURNAL),
534                &self.paths.dictionary(assets::MAIN_DICTIONARY),
535                &self.paths.dictionary(assets::ENGLISH_DICTIONARY),
536            );
537            assert!(
538                result.applied > 0 && result.failed == 0 && result.error.is_empty(),
539                "{}",
540                result.error
541            );
542        }
543
544        fn lookup(&self, kind: PersonalDictionaryKind, query: &str) -> Vec<DictionaryTableEntry> {
545            dictionary_table_entries(&self.paths, kind, query, 0, 100)
546                .unwrap()
547                .entries
548        }
549    }
550
551    fn row<'a>(rows: &'a [DictionaryTableEntry], value: &str) -> Option<&'a DictionaryTableEntry> {
552        rows.iter().find(|row| row.entry.value == value)
553    }
554
555    fn values(rows: &[DictionaryTableEntry]) -> Vec<&str> {
556        rows.iter().map(|row| row.entry.value.as_str()).collect()
557    }
558
559    #[test]
560    fn lookup_tables_reserve_the_bounded_table_count() {
561        use PersonalDictionaryKind::*;
562
563        let pinyin = lookup_tables(Pinyin, "ni", false);
564        assert_eq!(pinyin.len(), format::MAXIMUM_NUMBERED_SYLLABLES + 1);
565        assert_eq!(pinyin.capacity(), format::MAXIMUM_NUMBERED_SYLLABLES + 1);
566
567        let wubi = lookup_tables(Wubi, "wqv", false);
568        assert_eq!(wubi.capacity(), 1);
569    }
570
571    #[test]
572    fn lookup_rows_reserve_the_bounded_table_count() {
573        let tables = vec!["tbl_a".to_owned(), "tbl_b".to_owned(), "tbl_c".to_owned()];
574        let rows = lookup_rows(&tables, "key", "value", "exact", "matches", |_| Ok(true)).unwrap();
575
576        assert_eq!(rows.len(), tables.len());
577        assert!(rows.capacity() >= tables.len());
578    }
579
580    #[test]
581    fn a_bundled_word_is_found_reweighted_and_deleted_and_the_change_survives_an_upgrade() {
582        use PersonalDictionaryKind::*;
583        let fixture = Fixture::new();
584        for query in ["ni'hao", "nihao", "NiHao", "ni hao"] {
585            let rows = fixture.lookup(Pinyin, query);
586            assert_eq!(values(&rows), ["你好", "拟好"], "{query}");
587            assert!(rows.iter().all(|row| !row.user_inserted), "{query}");
588        }
589        let partial = fixture.lookup(Pinyin, "nih");
590        assert!(row(&partial, "你好").is_some() && row(&partial, "你们").is_none());
591        assert!(fixture.lookup(Pinyin, "hao").is_empty());
592        // Exact codes lead heavier prefix matches.
593        assert_eq!(
594            values(&fixture.lookup(Pinyin, "ni")),
595            ["你", "你好", "你们", "拟好"]
596        );
597
598        let nihao = row(&fixture.lookup(Pinyin, "nihao"), "你好")
599            .unwrap()
600            .entry
601            .clone();
602        assert_eq!(nihao.weight, 500_000);
603        assert_eq!(
604            edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(100_000_001), "heavy")
605                .unwrap_err()
606                .to_string(),
607            WEIGHT_OUT_OF_RANGE
608        );
609        edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(1), "demote-bundled").unwrap();
610        edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(1), "demote-bundled").unwrap();
611        assert_eq!(
612            edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(2), "demote-bundled")
613                .unwrap_err()
614                .to_string(),
615            "Request ID was already used for another edit"
616        );
617        let rows = fixture.lookup(Pinyin, "nihao");
618        assert_eq!(rows[0].entry.value, "拟好");
619        assert_eq!(row(&rows, "你好").unwrap().entry.weight, 1);
620        assert!(!row(&rows, "你好").unwrap().user_inserted);
621        // The listed weight is now stale.
622        let error = edit_bundled_dictionary_entry(&fixture.paths, &nihao, None, "stale-bundled")
623            .unwrap_err();
624        assert!(matches!(error, EngineError::Failed(_)));
625        assert_eq!(error.to_string(), ENTRY_CHANGED);
626
627        // A learned single character is journaled too, but the pinyin export leaves it out.
628        let ni = row(&fixture.lookup(Pinyin, "ni"), "你")
629            .unwrap()
630            .entry
631            .clone();
632        edit_bundled_dictionary_entry(&fixture.paths, &ni, Some(2), "demote-single").unwrap();
633        let exported = personal_dictionary_entries(&fixture.paths, 0, 1000, true)
634            .unwrap()
635            .entries;
636        assert!(exported
637            .iter()
638            .any(|entry| entry.value == "你好" && entry.weight == 1));
639        assert!(!exported.iter().any(|entry| entry.value == "你"));
640        let wubi = row(&fixture.lookup(Wubi, "wqv"), "你好")
641            .unwrap()
642            .entry
643            .clone();
644        edit_bundled_dictionary_entry(&fixture.paths, &wubi, Some(3), "demote-wubi").unwrap();
645        assert!(!personal_dictionary_entries(&fixture.paths, 0, 1000, false)
646            .unwrap()
647            .entries
648            .iter()
649            .any(|entry| entry.kind == Wubi));
650
651        fixture.upgrade();
652        assert_eq!(
653            row(&fixture.lookup(Pinyin, "nihao"), "你好")
654                .unwrap()
655                .entry
656                .weight,
657            1
658        );
659        assert_eq!(
660            row(&fixture.lookup(Wubi, "wqvb"), "你好")
661                .unwrap()
662                .entry
663                .weight,
664            3
665        );
666
667        let nihao = row(&fixture.lookup(Pinyin, "nihao"), "你好")
668            .unwrap()
669            .entry
670            .clone();
671        edit_bundled_dictionary_entry(&fixture.paths, &nihao, None, "delete-bundled").unwrap();
672        assert!(row(&fixture.lookup(Pinyin, "nihao"), "你好").is_none());
673        fixture.upgrade();
674        let rows = fixture.lookup(Pinyin, "nihao");
675        assert!(row(&rows, "你好").is_none());
676        assert!(row(&rows, "拟好").is_some());
677        assert!(!personal_dictionary_entries(&fixture.paths, 0, 1000, true)
678            .unwrap()
679            .entries
680            .iter()
681            .any(|entry| entry.value == "你好"));
682    }
683
684    #[test]
685    fn user_words_lead_the_lookup_and_are_not_edited_as_bundled_rows() {
686        use PersonalDictionaryKind::*;
687        let fixture = Fixture::new();
688        let user = PersonalDictionaryEntry {
689            kind: Pinyin,
690            key: "nihao".into(),
691            value: "妳好".into(),
692            weight: 10,
693        };
694        assert!(edit_personal_dictionary(&fixture.paths, None, Some(&user), "add-user").is_err());
695        let user = PersonalDictionaryEntry {
696            key: "ni'hao".into(),
697            ..user
698        };
699        edit_personal_dictionary(&fixture.paths, None, Some(&user), "add-user").unwrap();
700        let rows = fixture.lookup(Pinyin, "nihao");
701        assert_eq!(rows[0].entry, user);
702        assert!(rows[0].user_inserted);
703        assert!(rows[1..].iter().all(|row| !row.user_inserted));
704        assert_eq!(
705            edit_bundled_dictionary_entry(&fixture.paths, &user, None, "disguised-user")
706                .unwrap_err()
707                .to_string(),
708            USER_ENTRY
709        );
710        // A receipt of a personal edit is never taken for a bundled one.
711        assert_eq!(
712            edit_bundled_dictionary_entry(&fixture.paths, &user, None, "add-user")
713                .unwrap_err()
714                .to_string(),
715            "Request ID was already used for another edit"
716        );
717    }
718
719    #[test]
720    fn quick_phrases_list_whole_and_english_words_are_found_by_prefix() {
721        use PersonalDictionaryKind::*;
722        let fixture = Fixture::new();
723        let phrases = fixture.lookup(QuickPhrase, "");
724        assert_eq!(
725            phrases,
726            [DictionaryTableEntry {
727                entry: PersonalDictionaryEntry {
728                    kind: QuickPhrase,
729                    key: "dh".into(),
730                    value: "电话".into(),
731                    weight: 1
732                },
733                user_inserted: false
734            }]
735        );
736        assert_eq!(fixture.lookup(QuickPhrase, "D").len(), 1);
737        assert!(fixture.lookup(QuickPhrase, "x").is_empty());
738        // A shipped English weight can lie above the range a user entry may have; the row is still listed and can be deleted.
739        let words = fixture.lookup(English, "hel");
740        assert_eq!(values(&words), ["hello"]);
741        assert_eq!(words[0].entry.weight, 611_054_034);
742        assert!(fixture.lookup(English, "help").is_empty());
743        edit_bundled_dictionary_entry(&fixture.paths, &words[0].entry, None, "delete-english")
744            .unwrap();
745        assert!(fixture.lookup(English, "hel").is_empty());
746        fixture.upgrade();
747        assert!(fixture.lookup(English, "hel").is_empty());
748        // 中文按词条内容查询,无效 ASCII 编码仍返回空页。
749        assert_eq!(values(&fixture.lookup(Wubi, "你")), ["你好"]);
750        assert!(!fixture.lookup(Pinyin, "").is_empty());
751        assert!(fixture.lookup(Wubi, "a1").is_empty());
752    }
753
754    #[test]
755    fn lookups_validate_their_page_and_never_create_files() {
756        use PersonalDictionaryKind::*;
757        let fixture = Fixture::new();
758        for (offset, limit, query) in [
759            (0, 0, "ni"),
760            (0, 1001, "ni"),
761            (1_000_001, 1, "ni"),
762            (0, 1, &"n".repeat(257)[..]),
763        ] {
764            assert_eq!(
765                dictionary_table_entries(&fixture.paths, Pinyin, query, offset, limit)
766                    .unwrap_err()
767                    .to_string(),
768                INVALID_LOOKUP
769            );
770        }
771        let page = dictionary_table_entries(&fixture.paths, Pinyin, "ni", 1, 2).unwrap();
772        assert_eq!(values(&page.entries), ["你好", "你们"]);
773        assert!(page.has_more);
774        let empty = dictionary_table_entries(&fixture.paths, Pinyin, "nx", 0, 100).unwrap();
775        assert!(empty.entries.is_empty());
776        assert_eq!(empty.entries.capacity(), 0);
777        assert!(!fixture.paths.user(assets::USER_JOURNAL).exists());
778        std::fs::remove_file(fixture.paths.dictionary(assets::MAIN_DICTIONARY)).unwrap();
779        assert!(fixture.lookup(Pinyin, "ni").is_empty());
780        assert!(!fixture.paths.dictionary(assets::MAIN_DICTIONARY).exists());
781    }
782
783    #[test]
784    fn bundled_edits_validate_before_touching_storage() {
785        use PersonalDictionaryKind::*;
786        let fixture = Fixture::new();
787        let entry = |key: &str, value: &str| PersonalDictionaryEntry {
788            kind: Pinyin,
789            key: key.into(),
790            value: value.into(),
791            weight: 1000,
792        };
793        for (previous, message) in [
794            (entry("ni''hao", "拟好"), INVALID_BUNDLED_ENTRY),
795            (entry("", "拟好"), INVALID_BUNDLED_ENTRY),
796            (entry("ni'hao", ""), INVALID_BUNDLED_ENTRY),
797        ] {
798            assert_eq!(
799                edit_bundled_dictionary_entry(&fixture.paths, &previous, None, "")
800                    .unwrap_err()
801                    .to_string(),
802                message
803            );
804        }
805        assert_eq!(
806            edit_bundled_dictionary_entry(&fixture.paths, &entry("ni'hao", "拟好"), Some(0), "")
807                .unwrap_err()
808                .to_string(),
809            WEIGHT_OUT_OF_RANGE
810        );
811        assert_eq!(
812            edit_bundled_dictionary_entry(&fixture.paths, &entry("ni'hao", "拟好"), None, "bad id")
813                .unwrap_err()
814                .to_string(),
815            INVALID_REQUEST_ID
816        );
817        assert!(!fixture.paths.user(assets::USER_JOURNAL).exists());
818        // A row that is not in the dictionary has no current weight to match.
819        assert_eq!(
820            edit_bundled_dictionary_entry(&fixture.paths, &entry("ni'hao", "尼好"), None, "")
821                .unwrap_err()
822                .to_string(),
823            ENTRY_CHANGED
824        );
825    }
826
827    #[test]
828    fn engine_level_bundled_edits_take_the_maintenance_lease() {
829        use PersonalDictionaryKind::*;
830        let fixture = Fixture::new();
831        let paths = &fixture.paths;
832        let nihao = row(&fixture.lookup(Pinyin, "nihao"), "你好")
833            .unwrap()
834            .entry
835            .clone();
836        let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
837            .unwrap()
838            .unwrap();
839
840        let error =
841            edit_bundled_dictionary_entry(paths, &nihao, Some(1), "busy-bundled").unwrap_err();
842        assert_eq!(error.code(), crate::ErrorCode::Busy);
843        assert_eq!(
844            error.to_string(),
845            crate::diagnostics::DICTIONARY_MAINTENANCE_BUSY
846        );
847        assert_eq!(
848            row(&fixture.lookup(Pinyin, "nihao"), "你好")
849                .unwrap()
850                .entry
851                .weight,
852            500_000
853        );
854        assert!(!paths.user(assets::USER_JOURNAL).exists());
855        // 不合法的改动在取租约之前就被拒绝,报的仍是校验错误,不是 Busy。
856        let error = edit_bundled_dictionary_entry(paths, &nihao, Some(0), "").unwrap_err();
857        assert_eq!(error.code(), crate::ErrorCode::Failed);
858        assert_eq!(error.to_string(), WEIGHT_OUT_OF_RANGE);
859
860        drop(session);
861        edit_bundled_dictionary_entry(paths, &nihao, Some(1), "busy-bundled").unwrap();
862        assert_eq!(
863            row(&fixture.lookup(Pinyin, "nihao"), "你好")
864                .unwrap()
865                .entry
866                .weight,
867            1
868        );
869    }
870}