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, 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 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
49pub 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 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 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 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 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
224fn 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
251pub 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
267pub 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 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 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 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
379fn 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
391fn 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
447fn 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 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 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 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 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 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 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 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 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 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 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}