1use 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 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
50pub 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 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 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 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 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
225fn 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
252pub 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
269pub 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 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 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 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
381fn 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
393fn 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
449fn 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 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 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 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 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 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 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 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 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 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 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}