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