1use rusqlite::types::ValueRef;
6use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Row, TransactionBehavior};
7
8use crate::assets;
9use crate::dictionary::english::ensure_english_schema;
10use crate::error::{EngineError, Result};
11use crate::lattice::personal::PersonalTransition;
12use crate::paths::RuntimePaths;
13use crate::pinyin::syllables::intact_pinyin_set;
14use crate::types::{PersonalDictionaryEntry, PersonalDictionaryKind};
15use crate::user_dictionary::journal::{ensure_user_database, open_database, pinyin_table};
16use crate::user_dictionary::ngram_store::{
17 delete_personal_ngram_word, flush_journal, forget_journal_rows,
18};
19use crate::user_dictionary::replay::{
20 apply_english, apply_pinyin, apply_simple, attach, open_without_main_dictionary,
21};
22
23pub const MAX_PAGE_LIMIT: usize = 1_000;
24pub const MAX_PAGE_OFFSET: usize = 1_000_000;
25pub const MAX_REQUEST_ID_LENGTH: usize = 128;
26
27pub const MAX_ENTRY_WEIGHT: i64 = 100_000_000;
28const MAX_KEY_BYTES: usize = 512;
29const MAX_VALUE_BYTES: usize = 4_096;
30const MAX_PINYIN_SYLLABLES: usize = 64;
31
32pub const WEIGHT_OUT_OF_RANGE: &str = "Weight must be between 1 and 100000000";
34pub const UNBOUNDED_ENTRY: &str = "A valid, bounded UTF-8 word and input code are required";
35pub const CONTROL_CHARACTER: &str = "The word contains an unsupported control character";
36pub const INCOMPLETE_PINYIN: &str =
37 "Use complete pinyin syllables separated by apostrophes or spaces";
38pub const SYLLABLE_COUNT_MISMATCH: &str =
39 "Each character must have one pinyin syllable (maximum 64)";
40pub const INVALID_WUBI_CODE: &str = "Wubi codes contain one to four letters";
41pub const INVALID_QUICK_PHRASE_CODE: &str =
42 "Quick-phrase codes contain one to 32 letters or digits";
43pub const INVALID_ENGLISH_CODE: &str = "English codes contain letters, hyphens and apostrophes";
44
45pub(super) const INVALID_REQUEST_ID: &str = "Invalid personal dictionary request ID";
47pub(super) const ENTRY_REQUIRED: &str = "An entry is required";
48pub(super) const STORAGE_NOT_PREPARED: &str = "Cannot prepare personal dictionary storage";
49pub(super) const DICTIONARY_NOT_OPENED: &str = "Cannot open dictionary";
50pub(super) const STORAGE_NOT_ATTACHED: &str = "Cannot attach personal dictionary storage";
51pub(super) const CONTEXT_NOT_WRITTEN: &str = "Cannot write personal context";
52pub(super) const EDIT_NOT_BEGUN: &str = "Cannot begin personal dictionary edit";
53pub(super) const RECEIPT_NOT_READ: &str = "Cannot read edit receipt";
54pub(super) const RECEIPT_REUSED: &str = "Request ID was already used for another edit";
55pub(super) const RETRY_NOT_FINISHED: &str = "Cannot finish edit retry";
56pub(super) const RECEIPT_READ_UNFINISHED: &str = "Cannot finish reading edit receipt";
57pub(super) const ENTRY_CHANGED: &str = "The entry changed; reload it before editing";
58pub(super) const PREVIOUS_NOT_REMOVED: &str = "Cannot remove previous personal entry";
59pub(super) const ENTRY_NOT_SAVED: &str = "Cannot save personal entry";
60pub(super) const CONTEXT_NOT_REMOVED: &str = "Cannot remove personal context";
61pub(super) const RECEIPT_NOT_SAVED: &str = "Cannot save edit receipt";
62pub(super) const EDIT_NOT_COMMITTED: &str = "Cannot commit personal dictionary edit";
63pub(super) const STORAGE_UNAVAILABLE: &str = "Cannot access personal dictionary storage";
64pub(super) const INVALID_PAGE: &str = "Invalid personal dictionary page";
65pub(super) const DICTIONARY_NOT_READ: &str = "Cannot read personal dictionary";
66pub(super) const PAGE_READ_UNFINISHED: &str = "Cannot finish reading personal dictionary";
67pub(super) const NO_CHINESE_DICTIONARY: &str =
69 "This input method has no Chinese dictionary; only English words can be edited";
70pub(super) const NO_ENGLISH_DICTIONARY: &str =
72 "This input method has no English dictionary; no words can be edited";
73
74pub(super) fn generation_has_main_dictionary(paths: &RuntimePaths) -> bool {
76 paths.dictionary(assets::MAIN_DICTIONARY).is_file()
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub struct PersonalDictionaryPage {
81 pub entries: Vec<PersonalDictionaryEntry>,
82 pub has_more: bool,
83}
84
85pub fn validate_personal_dictionary_entry(
87 entry: &PersonalDictionaryEntry,
88) -> Result<PersonalDictionaryEntry> {
89 validate_entry(entry, MAX_ENTRY_WEIGHT)
90}
91
92pub(crate) fn validate_existing_entry(
94 entry: &PersonalDictionaryEntry,
95) -> Result<PersonalDictionaryEntry> {
96 validate_entry(entry, i64::MAX)
97}
98
99fn validate_entry(
100 entry: &PersonalDictionaryEntry,
101 max_weight: i64,
102) -> Result<PersonalDictionaryEntry> {
103 let invalid = |message: &str| Err(EngineError::invalid(message));
104 if !(1..=max_weight).contains(&entry.weight) {
105 return invalid(WEIGHT_OUT_OF_RANGE);
106 }
107 if entry.key.is_empty()
108 || entry.key.len() > MAX_KEY_BYTES
109 || entry.value.is_empty()
110 || entry.value.len() > MAX_VALUE_BYTES
111 {
112 return invalid(UNBOUNDED_ENTRY);
113 }
114 let quick = entry.kind == PersonalDictionaryKind::QuickPhrase;
115 if entry
117 .value
118 .bytes()
119 .any(|byte| (byte < 32 && !(quick && (byte == b'\n' || byte == b'\t'))) || byte == 127)
120 {
121 return invalid(CONTROL_CHARACTER);
122 }
123 let mut key = entry.key.to_ascii_lowercase();
124 let letters = |byte: u8| byte.is_ascii_lowercase();
125 match entry.kind {
126 PersonalDictionaryKind::Pinyin => {
127 key = key.replace(' ', "'");
129 let mut count = 0;
130 for syllable in key.split('\'') {
131 if !intact_pinyin_set().contains(syllable) {
132 return invalid(INCOMPLETE_PINYIN);
133 }
134 count += 1;
135 }
136 if count > MAX_PINYIN_SYLLABLES || count != entry.value.chars().count() {
137 return invalid(SYLLABLE_COUNT_MISMATCH);
138 }
139 }
140 PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
141 if key.len() > 4 || !key.bytes().all(letters) {
142 return invalid(INVALID_WUBI_CODE);
143 }
144 }
145 PersonalDictionaryKind::QuickPhrase => {
146 if key.len() > 32
147 || !key
148 .bytes()
149 .all(|byte| letters(byte) || byte.is_ascii_digit())
150 {
151 return invalid(INVALID_QUICK_PHRASE_CODE);
152 }
153 }
154 PersonalDictionaryKind::English => {
155 if key.len() > 64
157 || !key
158 .bytes()
159 .all(|byte| letters(byte) || byte == b'-' || byte == b'\'')
160 {
161 return invalid(INVALID_ENGLISH_CODE);
162 }
163 }
164 }
165 Ok(PersonalDictionaryEntry {
166 kind: entry.kind,
167 key,
168 value: entry.value.clone(),
169 weight: entry.weight,
170 })
171}
172
173pub fn edit_personal_dictionary(
179 paths: &RuntimePaths,
180 previous: Option<&PersonalDictionaryEntry>,
181 replacement: Option<&PersonalDictionaryEntry>,
182 request_id: &str,
183) -> Result<()> {
184 let main_dictionary = generation_has_main_dictionary(paths);
185 normalize_edit(main_dictionary, previous, replacement, request_id)?;
186 let _access = maintenance(paths)?;
187 edit_personal_dictionary_with(paths, main_dictionary, previous, replacement, request_id)
188}
189
190pub(super) fn maintenance(paths: &RuntimePaths) -> Result<crate::access::DictionaryAccess> {
192 crate::access::maintenance(paths).map_err(|error| match error {
193 EngineError::Busy(_) => error,
194 _ => failed(STORAGE_UNAVAILABLE),
195 })
196}
197
198pub fn edit_personal_dictionary_with(
200 paths: &RuntimePaths,
201 main_dictionary: bool,
202 previous: Option<&PersonalDictionaryEntry>,
203 replacement: Option<&PersonalDictionaryEntry>,
204 request_id: &str,
205) -> Result<()> {
206 let (old, new) = normalize_edit(main_dictionary, previous, replacement, request_id)?;
207 paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
208 let journal = paths.user(assets::USER_JOURNAL);
209 let mut connection = open_edit_connection(paths, main_dictionary)?;
210 flush_journal(&journal).map_err(|_| failed(CONTEXT_NOT_WRITTEN))?;
212 let transaction = connection
213 .transaction_with_behavior(TransactionBehavior::Immediate)
214 .map_err(|_| failed(EDIT_NOT_BEGUN))?;
215 let applied =
216 apply_normalized_edit(&transaction, old.as_ref(), new.as_ref(), request_id, false)?;
217 transaction
218 .commit()
219 .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
220 if let Some(removed) = applied.forgotten {
221 forget_journal_rows(&journal, &removed);
222 }
223 Ok(())
224}
225
226pub const MAX_EDIT_BATCH: usize = 128;
228pub const MAX_COLLECTION_IMPORT_BATCH: usize = 4096;
230
231#[derive(Clone, Copy)]
233pub struct PersonalDictionaryEdit<'a> {
234 pub previous: Option<&'a PersonalDictionaryEntry>,
235 pub replacement: Option<&'a PersonalDictionaryEntry>,
236 pub request_id: &'a str,
237 pub preserve_existing: bool,
239}
240
241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243pub enum PersonalDictionaryApplied {
244 Written,
245 AlreadyPresent,
246}
247
248struct AppliedEdit {
249 outcome: PersonalDictionaryApplied,
250 forgotten: Option<Vec<PersonalTransition>>,
251}
252
253pub fn edit_personal_dictionary_batch_with(
255 paths: &RuntimePaths,
256 main_dictionary: bool,
257 edits: &[PersonalDictionaryEdit<'_>],
258) -> Result<Vec<Result<PersonalDictionaryApplied>>> {
259 edit_personal_dictionary_batch_with_limit(paths, main_dictionary, edits, MAX_EDIT_BATCH)
260}
261
262pub fn edit_personal_dictionary_collection_batch_with(
264 paths: &RuntimePaths,
265 main_dictionary: bool,
266 edits: &[PersonalDictionaryEdit<'_>],
267) -> Result<Vec<Result<PersonalDictionaryApplied>>> {
268 edit_personal_dictionary_batch_with_limit(
269 paths,
270 main_dictionary,
271 edits,
272 MAX_COLLECTION_IMPORT_BATCH,
273 )
274}
275
276fn edit_personal_dictionary_batch_with_limit(
277 paths: &RuntimePaths,
278 main_dictionary: bool,
279 edits: &[PersonalDictionaryEdit<'_>],
280 max_batch: usize,
281) -> Result<Vec<Result<PersonalDictionaryApplied>>> {
282 if edits.len() > max_batch {
283 return Err(failed("Personal dictionary edit batch is too large"));
284 }
285 if edits.is_empty() {
286 return Ok(Vec::new());
287 }
288 paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
289 let journal = paths.user(assets::USER_JOURNAL);
290 let mut connection = open_edit_connection(paths, main_dictionary)?;
291 flush_journal(&journal).map_err(|_| failed(CONTEXT_NOT_WRITTEN))?;
292 let mut transaction = connection
293 .transaction_with_behavior(TransactionBehavior::Immediate)
294 .map_err(|_| failed(EDIT_NOT_BEGUN))?;
295 let mut outcomes = Vec::with_capacity(edits.len());
296 let mut forgotten = Vec::new();
297 for edit in edits {
298 let (old, new) = match normalize_edit(
299 main_dictionary,
300 edit.previous,
301 edit.replacement,
302 edit.request_id,
303 ) {
304 Ok(entries) => entries,
305 Err(error) => {
306 outcomes.push(Err(error));
307 continue;
308 }
309 };
310 let mut savepoint = transaction
311 .savepoint()
312 .map_err(|_| failed(EDIT_NOT_BEGUN))?;
313 let result = apply_normalized_edit(
314 &savepoint,
315 old.as_ref(),
316 new.as_ref(),
317 edit.request_id,
318 edit.preserve_existing,
319 );
320 if savepoint.is_autocommit() {
322 return Err(failed(EDIT_NOT_COMMITTED));
323 }
324 match result {
325 Ok(applied) => {
326 savepoint.commit().map_err(|_| failed(EDIT_NOT_COMMITTED))?;
327 if let Some(removed) = applied.forgotten {
328 forgotten.extend(removed);
329 }
330 outcomes.push(Ok(applied.outcome));
331 }
332 Err(error) => {
333 savepoint
334 .rollback()
335 .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
336 savepoint.commit().map_err(|_| failed(EDIT_NOT_COMMITTED))?;
337 outcomes.push(Err(error));
338 }
339 }
340 }
341 transaction
342 .commit()
343 .map_err(|_| failed(EDIT_NOT_COMMITTED))?;
344 if !forgotten.is_empty() {
345 forget_journal_rows(&journal, &forgotten);
346 }
347 Ok(outcomes)
348}
349
350fn normalize_edit(
351 main_dictionary: bool,
352 previous: Option<&PersonalDictionaryEntry>,
353 replacement: Option<&PersonalDictionaryEntry>,
354 request_id: &str,
355) -> Result<(
356 Option<PersonalDictionaryEntry>,
357 Option<PersonalDictionaryEntry>,
358)> {
359 if !valid_request_id(request_id) {
360 return Err(failed(INVALID_REQUEST_ID));
361 }
362 if previous.is_none() && replacement.is_none() {
363 return Err(failed(ENTRY_REQUIRED));
364 }
365 let old = previous
366 .map(validate_existing_entry)
367 .transpose()
368 .map_err(as_failure)?;
369 let new = replacement
370 .map(validate_personal_dictionary_entry)
371 .transpose()
372 .map_err(as_failure)?;
373 if !main_dictionary
374 && [old.as_ref(), new.as_ref()]
375 .into_iter()
376 .flatten()
377 .any(|entry| entry.kind != PersonalDictionaryKind::English)
378 {
379 return Err(failed(NO_CHINESE_DICTIONARY));
380 }
381
382 Ok((old, new))
383}
384
385fn apply_normalized_edit(
386 transaction: &Connection,
387 old: Option<&PersonalDictionaryEntry>,
388 new: Option<&PersonalDictionaryEntry>,
389 request_id: &str,
390 preserve_existing: bool,
391) -> Result<AppliedEdit> {
392 let payload = if request_id.is_empty() {
393 String::new()
394 } else {
395 let mut payload = String::new();
396 append_payload(&mut payload, old);
397 append_payload(&mut payload, new);
398 match check_receipt(transaction, request_id, &payload)? {
399 Receipt::Fresh => {}
400 Receipt::Retry => {
401 return Ok(AppliedEdit {
402 outcome: PersonalDictionaryApplied::Written,
403 forgotten: None,
404 });
405 }
406 Receipt::AlreadyPresent => {
407 return Ok(AppliedEdit {
408 outcome: PersonalDictionaryApplied::AlreadyPresent,
409 forgotten: None,
410 });
411 }
412 }
413 payload
414 };
415 if preserve_existing && old.is_none() {
416 if let Some(new) = new {
417 let present = transaction.query_row(
418 "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3 AND operation='upsert' AND user_inserted=1",
419 params![new.kind.journal_name(), new.key, new.value], |_| Ok(()),
420 ).optional().map_err(|_| failed(DICTIONARY_NOT_READ))?;
421 if present.is_some() || bundled_entry_present(transaction, new)? {
422 if !request_id.is_empty() {
423 let receipt_payload = format!("{ALREADY_PRESENT_RECEIPT_PREFIX}{payload}");
424 save_receipt(transaction, request_id, &receipt_payload)?;
425 }
426 return Ok(AppliedEdit {
427 outcome: PersonalDictionaryApplied::AlreadyPresent,
428 forgotten: None,
429 });
430 }
431 }
432 }
433 if let Some(old) = old {
434 let current = transaction
435 .query_row(
436 "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3 AND weight=?4 AND operation='upsert' AND user_inserted=1",
437 params![old.kind.journal_name(), old.key, old.value, old.weight],
438 |_| Ok(()),
439 )
440 .optional();
441 if !matches!(current, Ok(Some(()))) {
442 return Err(failed(ENTRY_CHANGED));
443 }
444 if !matches!(apply_personal_edit(transaction, old, true), Ok(true)) {
445 return Err(failed(PREVIOUS_NOT_REMOVED));
446 }
447 }
448 if let Some(new) = new {
449 if !matches!(apply_personal_edit(transaction, new, false), Ok(true)) {
450 return Err(failed(ENTRY_NOT_SAVED));
451 }
452 }
453 let forgotten = match old {
455 Some(old)
456 if old.kind == PersonalDictionaryKind::Pinyin
457 && !new.is_some_and(|new| {
458 new.kind == PersonalDictionaryKind::Pinyin && new.value == old.value
459 }) =>
460 {
461 Some(
462 delete_personal_ngram_word(transaction, "personal_journal", &old.value)
463 .map_err(|_| failed(CONTEXT_NOT_REMOVED))?,
464 )
465 }
466 _ => None,
467 };
468 if !request_id.is_empty() {
469 save_receipt(transaction, request_id, &payload)?;
470 }
471 Ok(AppliedEdit {
472 outcome: PersonalDictionaryApplied::Written,
473 forgotten,
474 })
475}
476
477fn bundled_entry_present(connection: &Connection, entry: &PersonalDictionaryEntry) -> Result<bool> {
479 let (schema, table, key_column, value_column) = match entry.kind {
480 PersonalDictionaryKind::Pinyin => {
481 let Some(table) = pinyin_table(&entry.key) else {
482 return Ok(false);
483 };
484 ("main", table, "key", "value")
485 }
486 PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
487 let Some(table) = entry.kind.wubi_table() else {
488 return Ok(false);
489 };
490 ("main", table.to_owned(), "key", "value")
491 }
492 PersonalDictionaryKind::QuickPhrase => ("main", "quick_parases".to_owned(), "key", "value"),
493 PersonalDictionaryKind::English => (
494 "replay_english",
495 "english_words".to_owned(),
496 "word",
497 "display",
498 ),
499 };
500 let table_exists = connection
501 .query_row(
502 &format!("SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name=?1"),
503 [&table],
504 |_| Ok(()),
505 )
506 .optional()
507 .map_err(|_| failed(DICTIONARY_NOT_READ))?
508 .is_some();
509 if !table_exists {
510 return Ok(false);
511 }
512 connection
513 .query_row(
514 &format!(
515 "SELECT 1 FROM {schema}.\"{table}\" WHERE \"{key_column}\"=?1 AND \"{value_column}\"=?2 LIMIT 1"
516 ),
517 params![entry.key, entry.value],
518 |_| Ok(()),
519 )
520 .optional()
521 .map(|row| row.is_some())
522 .map_err(|_| failed(DICTIONARY_NOT_READ))
523}
524
525pub fn personal_dictionary_entries(
529 paths: &RuntimePaths,
530 offset: usize,
531 limit: usize,
532 include_learned_pinyin: bool,
533) -> Result<PersonalDictionaryPage> {
534 personal_dictionary_entries_with(
535 paths,
536 generation_has_main_dictionary(paths),
537 offset,
538 limit,
539 include_learned_pinyin,
540 )
541}
542
543pub fn personal_dictionary_entries_with(
545 paths: &RuntimePaths,
546 main_dictionary: bool,
547 offset: usize,
548 limit: usize,
549 include_learned_pinyin: bool,
550) -> Result<PersonalDictionaryPage> {
551 read_personal_dictionary_page(
552 paths,
553 main_dictionary,
554 offset,
555 limit,
556 include_learned_pinyin,
557 true,
558 None,
559 )
560}
561
562pub fn personal_dictionary_export_entries_for_kind(
564 paths: &RuntimePaths,
565 main_dictionary: bool,
566 kind: PersonalDictionaryKind,
567 offset: usize,
568 limit: usize,
569) -> Result<PersonalDictionaryPage> {
570 read_personal_dictionary_page(
571 paths,
572 main_dictionary,
573 offset,
574 limit,
575 kind == PersonalDictionaryKind::Pinyin,
576 true,
577 Some(kind),
578 )
579}
580
581pub fn personal_dictionary_entries_for_kind(
583 paths: &RuntimePaths,
584 main_dictionary: bool,
585 kind: PersonalDictionaryKind,
586 offset: usize,
587 limit: usize,
588) -> Result<PersonalDictionaryPage> {
589 read_personal_dictionary_page(
590 paths,
591 main_dictionary,
592 offset,
593 limit,
594 false,
595 false,
596 Some(kind),
597 )
598}
599
600fn read_personal_dictionary_page(
601 paths: &RuntimePaths,
602 main_dictionary: bool,
603 offset: usize,
604 limit: usize,
605 include_learned_pinyin: bool,
606 exclude_single_pinyin: bool,
607 kind: Option<PersonalDictionaryKind>,
608) -> Result<PersonalDictionaryPage> {
609 if limit == 0 || limit > MAX_PAGE_LIMIT || offset > MAX_PAGE_OFFSET {
610 return Err(failed(INVALID_PAGE));
611 }
612 paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
613 let journal = paths.user(assets::USER_JOURNAL);
614 if !journal.exists() {
616 return Ok(PersonalDictionaryPage::default());
617 }
618 let connection = open_database(&journal, OpenFlags::SQLITE_OPEN_READ_ONLY)
619 .map_err(|_| failed(DICTIONARY_NOT_READ))?;
620 let sql = if kind.is_some() {
621 "SELECT dictionary,key,value,weight FROM user_dictionary_operations WHERE operation='upsert' AND (user_inserted=1 OR (?3 AND dictionary='pinyin')) AND NOT (?3 AND ?5 AND dictionary='pinyin' AND length(value)<=1) AND (?4 OR dictionary='english') AND dictionary=?6 ORDER BY dictionary,key,value LIMIT ?1 OFFSET ?2"
622 } else {
623 "SELECT dictionary,key,value,weight FROM user_dictionary_operations WHERE operation='upsert' AND (user_inserted=1 OR (?3 AND dictionary='pinyin')) AND NOT (?3 AND ?5 AND dictionary='pinyin' AND length(value)<=1) AND (?4 OR dictionary='english') ORDER BY dictionary,key,value LIMIT ?1 OFFSET ?2"
624 };
625 let mut statement = connection
626 .prepare(sql)
627 .map_err(|_| failed(DICTIONARY_NOT_READ))?;
628 let mut page = PersonalDictionaryPage::default();
629 let read = (|| -> rusqlite::Result<()> {
630 let mut rows = if let Some(kind) = kind {
631 statement.query(params![
632 page_bind(limit + 1),
633 page_bind(offset),
634 i64::from(include_learned_pinyin),
635 i64::from(main_dictionary),
636 i64::from(exclude_single_pinyin),
637 kind.journal_name()
638 ])?
639 } else {
640 statement.query(params![
641 page_bind(limit + 1),
642 page_bind(offset),
643 i64::from(include_learned_pinyin),
644 i64::from(main_dictionary),
645 i64::from(exclude_single_pinyin)
646 ])?
647 };
648 while let Some(row) = rows.next()? {
649 if page.entries.len() == limit {
650 page.has_more = true;
651 break;
652 }
653 let Some(kind) = PersonalDictionaryKind::from_journal_name(&column_text(row, 0)?)
654 else {
655 continue;
656 };
657 page.entries.push(PersonalDictionaryEntry {
658 kind,
659 key: column_text(row, 1)?,
660 value: column_text(row, 2)?,
661 weight: column_i64(row, 3)?,
662 });
663 }
664 Ok(())
665 })();
666 if read.is_err() {
667 return Err(failed(PAGE_READ_UNFINISHED));
668 }
669 Ok(page)
670}
671
672pub(super) fn open_edit_connection(
676 paths: &RuntimePaths,
677 main_dictionary: bool,
678) -> Result<Connection> {
679 let journal = paths.user(assets::USER_JOURNAL);
680 let english = paths.dictionary(assets::ENGLISH_DICTIONARY);
681 if !main_dictionary && !english.is_file() {
682 return Err(failed(NO_ENGLISH_DICTIONARY));
683 }
684 if ensure_user_database(&journal).is_err() || ensure_english_schema(&english).is_err() {
685 return Err(failed(STORAGE_NOT_PREPARED));
686 }
687 let connection = if main_dictionary {
688 open_database(
689 &paths.dictionary(assets::MAIN_DICTIONARY),
690 OpenFlags::SQLITE_OPEN_READ_WRITE,
691 )
692 .map_err(|_| failed(DICTIONARY_NOT_OPENED))?
693 } else {
694 open_without_main_dictionary().map_err(|_| failed(DICTIONARY_NOT_OPENED))?
695 };
696 for (schema, path) in [("personal_journal", &journal), ("replay_english", &english)] {
697 attach(&connection, path, schema).map_err(|_| failed(STORAGE_NOT_ATTACHED))?;
698 }
699 Ok(connection)
700}
701
702#[derive(Debug, PartialEq, Eq)]
703pub(super) enum Receipt {
704 Fresh,
706 Retry,
708 AlreadyPresent,
710}
711
712const ALREADY_PRESENT_RECEIPT_PREFIX: &str = "already_present;";
713
714pub(super) fn check_receipt(
716 transaction: &Connection,
717 request_id: &str,
718 payload: &str,
719) -> Result<Receipt> {
720 let mut statement = transaction
721 .prepare(
722 "SELECT payload FROM personal_journal.personal_dictionary_receipts WHERE request_id=?1",
723 )
724 .map_err(|_| failed(RECEIPT_NOT_READ))?;
725 let stored = statement
726 .query_row([request_id], |row| row.get::<_, Option<String>>(0))
727 .optional()
728 .map_err(|_| failed(RECEIPT_READ_UNFINISHED))?;
729 match stored {
730 None => Ok(Receipt::Fresh),
731 Some(stored) if stored.as_deref() == Some(payload) => Ok(Receipt::Retry),
732 Some(stored)
733 if stored
734 .as_deref()
735 .and_then(|stored| stored.strip_prefix(ALREADY_PRESENT_RECEIPT_PREFIX))
736 == Some(payload) =>
737 {
738 Ok(Receipt::AlreadyPresent)
739 }
740 Some(_) => Err(failed(RECEIPT_REUSED)),
741 }
742}
743
744pub(super) fn save_receipt(
745 transaction: &Connection,
746 request_id: &str,
747 payload: &str,
748) -> Result<()> {
749 transaction
750 .execute(
751 "INSERT INTO personal_journal.personal_dictionary_receipts(request_id,payload) VALUES(?1,?2)",
752 params![request_id, payload],
753 )
754 .map_err(|_| failed(RECEIPT_NOT_SAVED))?;
755 Ok(())
756}
757
758fn append_payload(payload: &mut String, entry: Option<&PersonalDictionaryEntry>) {
760 let Some(entry) = entry else {
761 payload.push_str("none;");
762 return;
763 };
764 payload.push_str(&format!("{};{};", entry.kind as u8, entry.weight));
765 for text in [&entry.key, &entry.value] {
766 payload.push_str(&format!("{}:{}", text.len(), text));
767 }
768}
769
770fn apply_personal_edit(
772 connection: &Connection,
773 entry: &PersonalDictionaryEntry,
774 remove: bool,
775) -> rusqlite::Result<bool> {
776 let (key, value, weight) = (entry.key.as_str(), entry.value.as_str(), entry.weight);
777 let (key_bytes, value_bytes) = (key.as_bytes(), value.as_bytes());
778 let applied = match entry.kind {
779 PersonalDictionaryKind::Pinyin => {
780 apply_pinyin(connection, key_bytes, value_bytes, remove, weight)?
781 }
782 PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => apply_simple(
783 connection,
784 entry.kind.wubi_table().unwrap_or_default(),
785 key_bytes,
786 value_bytes,
787 remove,
788 weight,
789 )?,
790 PersonalDictionaryKind::QuickPhrase => apply_simple(
791 connection,
792 "quick_parases",
793 key_bytes,
794 value_bytes,
795 remove,
796 weight,
797 )?,
798 PersonalDictionaryKind::English => apply_english(
799 connection,
800 key_bytes,
801 value_bytes,
802 remove,
803 weight,
804 value_bytes,
805 )?,
806 };
807 if !applied {
808 return Ok(false);
809 }
810 let display = if entry.kind == PersonalDictionaryKind::English {
811 value
812 } else {
813 ""
814 };
815 connection.execute(
816 "INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,?4,?5,?6,1) ON CONFLICT(dictionary,key,value) DO UPDATE SET operation=excluded.operation, weight=excluded.weight,display=excluded.display,user_inserted=1,updated_at=unixepoch()",
817 params![
818 entry.kind.journal_name(),
819 key,
820 value,
821 if remove { "delete" } else { "upsert" },
822 if remove { 0 } else { weight },
823 display
824 ],
825 )?;
826 Ok(true)
827}
828
829pub(super) fn valid_request_id(request_id: &str) -> bool {
831 request_id.len() <= MAX_REQUEST_ID_LENGTH
832 && request_id
833 .bytes()
834 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
835}
836
837pub(super) fn failed(message: &str) -> EngineError {
838 EngineError::failed(message)
839}
840
841fn as_failure(error: EngineError) -> EngineError {
843 match error {
844 EngineError::InvalidArgument(message) => EngineError::Failed(message),
845 other => other,
846 }
847}
848
849pub(super) fn page_bind(value: usize) -> i64 {
851 value as i64
852}
853
854pub(super) fn column_text(row: &Row<'_>, index: usize) -> rusqlite::Result<String> {
856 Ok(match row.get_ref(index)? {
857 ValueRef::Null => String::new(),
858 ValueRef::Text(bytes) | ValueRef::Blob(bytes) => {
859 String::from_utf8_lossy(bytes).into_owned()
860 }
861 ValueRef::Integer(number) => number.to_string(),
862 ValueRef::Real(number) => number.to_string(),
863 })
864}
865
866pub(super) fn column_i64(row: &Row<'_>, index: usize) -> rusqlite::Result<i64> {
868 Ok(row.get::<_, Option<i64>>(index)?.unwrap_or(0))
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874 use crate::user_dictionary::generation::prepare_runtime_paths;
875 use crate::user_dictionary::replay::tests::{sql, weight};
876 use std::path::Path;
877
878 fn entry(
879 kind: PersonalDictionaryKind,
880 key: &str,
881 value: &str,
882 weight: i64,
883 ) -> PersonalDictionaryEntry {
884 PersonalDictionaryEntry {
885 kind,
886 key: key.into(),
887 value: value.into(),
888 weight,
889 }
890 }
891
892 fn fixture(root: &Path) -> RuntimePaths {
894 let resources = root.join("resources");
895 std::fs::create_dir_all(&resources).unwrap();
896 sql(
897 &resources.join(assets::MAIN_DICTIONARY),
898 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
899 CREATE TABLE tbl_7_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
900 CREATE TABLE tbl_others_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
901 CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
902 CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);",
903 );
904 ensure_english_schema(&resources.join(assets::ENGLISH_DICTIONARY)).unwrap();
905 prepare_runtime_paths(&resources, &root.join("user"), &root.join("cache"), "first").unwrap()
906 }
907
908 fn has(paths: &RuntimePaths, entry: &PersonalDictionaryEntry) -> bool {
910 let (database, query) = match entry.kind {
911 PersonalDictionaryKind::English => (
912 assets::ENGLISH_DICTIONARY,
913 "SELECT count(*) FROM english_words WHERE word=?1 AND display=?2".to_owned(),
914 ),
915 PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => (
916 assets::MAIN_DICTIONARY,
917 format!(
918 "SELECT count(*) FROM {} WHERE key=?1 AND value=?2",
919 entry.kind.wubi_table().unwrap()
920 ),
921 ),
922 PersonalDictionaryKind::QuickPhrase => (
923 assets::MAIN_DICTIONARY,
924 "SELECT count(*) FROM quick_parases WHERE key=?1 AND value=?2".to_owned(),
925 ),
926 PersonalDictionaryKind::Pinyin => (
927 assets::MAIN_DICTIONARY,
928 format!(
929 "SELECT count(*) FROM \"{}\" WHERE key=?1 AND value=?2",
930 crate::user_dictionary::journal::pinyin_table(&entry.key).unwrap()
931 ),
932 ),
933 };
934 let count: i64 = Connection::open(paths.dictionary(database))
935 .unwrap()
936 .query_row(&query, params![entry.key, entry.value], |row| row.get(0))
937 .unwrap();
938 count > 0
939 }
940
941 #[test]
942 fn preserved_import_receipt_survives_removal_before_retry() {
943 let root = tempfile::tempdir().unwrap();
944 let paths = fixture(root.path());
945 let original = entry(PersonalDictionaryKind::Pinyin, "ni'hao", "合成", 50_000);
946 edit_personal_dictionary(&paths, None, Some(&original), "fixture-owner").unwrap();
947 let imported = PersonalDictionaryEntry {
948 weight: 10_000,
949 ..original.clone()
950 };
951 let batch = [PersonalDictionaryEdit {
952 previous: None,
953 replacement: Some(&imported),
954 request_id: "collections-preserved-retry",
955 preserve_existing: true,
956 }];
957 let first = edit_personal_dictionary_batch_with(&paths, true, &batch).unwrap();
958 assert_eq!(
959 first[0].as_ref().unwrap(),
960 &PersonalDictionaryApplied::AlreadyPresent
961 );
962 edit_personal_dictionary(&paths, Some(&original), None, "fixture-owner-removes").unwrap();
963 assert!(!has(&paths, &original));
964 let retried = edit_personal_dictionary_batch_with(&paths, true, &batch).unwrap();
965 assert_eq!(
966 retried[0].as_ref().unwrap(),
967 &PersonalDictionaryApplied::AlreadyPresent
968 );
969 assert!(!has(&paths, &imported));
970 }
971
972 #[test]
973 fn preserved_import_receipt_rejects_a_changed_payload() {
974 let root = tempfile::tempdir().unwrap();
975 let paths = fixture(root.path());
976 let original = entry(PersonalDictionaryKind::Pinyin, "ni'hao", "合成", 50_000);
977 edit_personal_dictionary(&paths, None, Some(&original), "fixture-owner").unwrap();
978 let imported = PersonalDictionaryEntry {
979 weight: 10_000,
980 ..original
981 };
982 let attempt = |replacement| {
983 edit_personal_dictionary_batch_with(
984 &paths,
985 true,
986 &[PersonalDictionaryEdit {
987 previous: None,
988 replacement: Some(replacement),
989 request_id: "collections-preserved-payload",
990 preserve_existing: true,
991 }],
992 )
993 .unwrap()
994 .remove(0)
995 };
996 assert_eq!(
997 attempt(&imported).unwrap(),
998 PersonalDictionaryApplied::AlreadyPresent
999 );
1000 let changed = PersonalDictionaryEntry {
1001 weight: 10_001,
1002 ..imported.clone()
1003 };
1004 assert_eq!(attempt(&changed).unwrap_err().to_string(), RECEIPT_REUSED);
1005 }
1006
1007 #[test]
1008 fn preserved_import_receipt_failure_is_not_reported_as_success() {
1009 let root = tempfile::tempdir().unwrap();
1010 let paths = fixture(root.path());
1011 let original = entry(PersonalDictionaryKind::Pinyin, "ni'hao", "合成", 50_000);
1012 edit_personal_dictionary(&paths, None, Some(&original), "fixture-owner").unwrap();
1013 let journal = paths.user(assets::USER_JOURNAL);
1014 sql(&journal, "CREATE TRIGGER reject_preserved_receipt BEFORE INSERT ON personal_dictionary_receipts WHEN NEW.request_id='collections-preserved-failure' BEGIN SELECT RAISE(ABORT,'synthetic failure'); END;");
1015 let imported = PersonalDictionaryEntry {
1016 weight: 10_000,
1017 ..original.clone()
1018 };
1019 let result = edit_personal_dictionary_batch_with(
1020 &paths,
1021 true,
1022 &[PersonalDictionaryEdit {
1023 previous: None,
1024 replacement: Some(&imported),
1025 request_id: "collections-preserved-failure",
1026 preserve_existing: true,
1027 }],
1028 )
1029 .unwrap()
1030 .remove(0);
1031 assert_eq!(result.unwrap_err().to_string(), RECEIPT_NOT_SAVED);
1032 assert!(has(&paths, &original));
1033 let receipts: i64 = Connection::open(journal).unwrap().query_row(
1034 "SELECT count(*) FROM personal_dictionary_receipts WHERE request_id='collections-preserved-failure'",
1035 [], |row| row.get(0)
1036 ).unwrap();
1037 assert_eq!(receipts, 0);
1038 }
1039
1040 #[test]
1042 fn validation_normalises_and_rejects_with_the_reference_messages() {
1043 use PersonalDictionaryKind::*;
1044 let normalized =
1045 validate_personal_dictionary_entry(&entry(Pinyin, "NI HAO", "拟好", 12345)).unwrap();
1046 assert_eq!(normalized, entry(Pinyin, "ni'hao", "拟好", 12345));
1047 assert_eq!(
1048 validate_personal_dictionary_entry(&entry(English, "dont", "don't", 100_000)).unwrap(),
1049 entry(English, "dont", "don't", 100_000)
1050 );
1051 assert_eq!(
1052 validate_personal_dictionary_entry(&entry(English, "hello", "different", 100_000))
1053 .unwrap(),
1054 entry(English, "hello", "different", 100_000)
1055 );
1056 assert_eq!(
1057 validate_personal_dictionary_entry(&entry(
1058 QuickPhrase,
1059 "TEST1",
1060 "fixture\nsecond\tline",
1061 1
1062 ))
1063 .unwrap()
1064 .key,
1065 "test1"
1066 );
1067 for (invalid, message) in [
1068 (entry(Pinyin, "nihao", "你好", 1), INCOMPLETE_PINYIN),
1069 (entry(Pinyin, "ni''hao", "你好", 1), INCOMPLETE_PINYIN),
1070 (entry(Pinyin, "ni'hao", "你", 1), SYLLABLE_COUNT_MISMATCH),
1071 (
1072 entry(Pinyin, &vec!["ni"; 65].join("'"), &"你".repeat(65), 1),
1073 SYLLABLE_COUNT_MISMATCH,
1074 ),
1075 (entry(Wubi, "abcde", "词", 1), INVALID_WUBI_CODE),
1076 (entry(Wubi, "ab1", "词", 1), INVALID_WUBI_CODE),
1077 (entry(Wubi98, "abcde", "词", 1), INVALID_WUBI_CODE),
1078 (
1079 entry(QuickPhrase, "bad;code", "text", 1),
1080 INVALID_QUICK_PHRASE_CODE,
1081 ),
1082 (
1083 entry(QuickPhrase, &"a".repeat(33), "text", 1),
1084 INVALID_QUICK_PHRASE_CODE,
1085 ),
1086 (
1087 entry(English, "wrong_code", "Word", 1),
1088 INVALID_ENGLISH_CODE,
1089 ),
1090 (entry(English, "hello", "a\0b", 1), CONTROL_CHARACTER),
1091 (entry(Pinyin, "ni", "你\u{7f}", 1), CONTROL_CHARACTER),
1092 (entry(Wubi, "ab", "词\n", 1), CONTROL_CHARACTER),
1093 (entry(Pinyin, "ni", "你", 0), WEIGHT_OUT_OF_RANGE),
1094 (entry(Pinyin, "ni", "你", 100_000_001), WEIGHT_OUT_OF_RANGE),
1095 (entry(Pinyin, "", "你", 1), UNBOUNDED_ENTRY),
1096 (entry(Pinyin, "ni", "", 1), UNBOUNDED_ENTRY),
1097 (
1098 entry(QuickPhrase, "a", &"x".repeat(4097), 1),
1099 UNBOUNDED_ENTRY,
1100 ),
1101 ] {
1102 let error = validate_personal_dictionary_entry(&invalid).unwrap_err();
1103 assert!(
1104 matches!(error, EngineError::InvalidArgument(_)),
1105 "{invalid:?}"
1106 );
1107 assert_eq!(error.to_string(), message, "{invalid:?}");
1108 }
1109 }
1110
1111 #[test]
1114 fn engine_level_edits_take_the_maintenance_lease() {
1115 use PersonalDictionaryKind::*;
1116 let root = tempfile::tempdir().unwrap();
1117 let paths = fixture(root.path());
1118 let pinyin =
1119 validate_personal_dictionary_entry(&entry(Pinyin, "NI HAO", "拟好", 12345)).unwrap();
1120 let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
1121 .unwrap()
1122 .unwrap();
1123
1124 let error = edit_personal_dictionary(&paths, None, Some(&pinyin), "busy-1").unwrap_err();
1125 assert_eq!(error.code(), crate::ErrorCode::Busy);
1126 assert_eq!(
1127 error.to_string(),
1128 crate::diagnostics::DICTIONARY_MAINTENANCE_BUSY
1129 );
1130 assert!(!has(&paths, &pinyin));
1131 let invalid = entry(Pinyin, "nihao", "你好", 1);
1132 let error = edit_personal_dictionary(&paths, None, Some(&invalid), "").unwrap_err();
1133 assert_eq!(error.code(), crate::ErrorCode::Failed);
1134 drop(session);
1136 let maintenance =
1137 crate::DictionaryAccess::try_maintenance(&paths.user_data, &paths.dictionaries)
1138 .unwrap()
1139 .unwrap();
1140 edit_personal_dictionary_with(&paths, true, None, Some(&pinyin), "busy-1").unwrap();
1141 assert!(has(&paths, &pinyin));
1142 drop(maintenance);
1143 edit_personal_dictionary(&paths, Some(&pinyin), None, "busy-2").unwrap();
1144 assert!(!has(&paths, &pinyin));
1145 }
1146
1147 #[test]
1148 fn edits_replace_page_refuse_stale_and_survive_an_upgrade() {
1149 use PersonalDictionaryKind::*;
1150 let root = tempfile::tempdir().unwrap();
1151 let paths = fixture(root.path());
1152 assert_eq!(
1153 personal_dictionary_entries(&paths, 0, 100, false).unwrap(),
1154 PersonalDictionaryPage::default()
1155 );
1156 let journal = paths.user(assets::USER_JOURNAL);
1157
1158 for invalid in [
1159 entry(Pinyin, "nihao", "你好", 1),
1160 entry(Wubi, "abcde", "词", 1),
1161 entry(English, "hello", "a\0b", 1),
1162 ] {
1163 let error = edit_personal_dictionary(&paths, None, Some(&invalid), "").unwrap_err();
1164 assert!(matches!(error, EngineError::Failed(_)), "{error}");
1165 }
1166 assert_eq!(
1167 edit_personal_dictionary(&paths, None, None, "")
1168 .unwrap_err()
1169 .to_string(),
1170 ENTRY_REQUIRED
1171 );
1172
1173 let pinyin =
1174 validate_personal_dictionary_entry(&entry(Pinyin, "NI HAO", "拟好", 12345)).unwrap();
1175 edit_personal_dictionary(&paths, None, Some(&pinyin), "").unwrap();
1176 assert!(has(&paths, &pinyin));
1177 assert_eq!(
1178 weight(
1179 &paths.dictionary(assets::MAIN_DICTIONARY),
1180 "SELECT count(*) FROM tbl_2_n WHERE jp='nh' AND weight=12345"
1181 ),
1182 Some(1)
1183 );
1184 let wubi = entry(Wubi, "wq", "拟好", 12345);
1185 let english = entry(English, "metasequoia", "Metasequoia", 12345);
1186 let quick = entry(QuickPhrase, "test1", "fixture\nsecond line", 12345);
1187 for added in [&wubi, &english, &quick] {
1188 edit_personal_dictionary(&paths, None, Some(added), "").unwrap();
1189 assert!(has(&paths, added), "{added:?}");
1190 }
1191 assert_eq!(
1192 weight(&journal, "SELECT count(*) FROM user_dictionary_operations WHERE dictionary='english' AND display='Metasequoia' AND user_inserted=1"),
1193 Some(1)
1194 );
1195
1196 let moved = entry(English, "fixtureword", "FixtureWord", quick.weight);
1198 edit_personal_dictionary(&paths, Some(&quick), Some(&moved), "").unwrap();
1199 assert!(has(&paths, &moved) && !has(&paths, &quick));
1200 edit_personal_dictionary(&paths, Some(&moved), Some(&quick), "").unwrap();
1201 assert!(!has(&paths, &moved) && has(&paths, &quick));
1202
1203 let first = personal_dictionary_entries(&paths, 0, 2, false).unwrap();
1204 assert_eq!(first.entries.len(), 2);
1205 assert!(first.has_more);
1206 assert_eq!(first.entries[0], english);
1207 let rest = personal_dictionary_entries(&paths, 2, 2, false).unwrap();
1208 assert_eq!(rest.entries.len(), 2);
1209 assert!(!rest.has_more);
1210 assert_eq!(rest.entries, vec![quick.clone(), wubi.clone()]);
1211 for (offset, limit) in [(0, 0), (0, 1001), (1_000_001, 1)] {
1212 assert_eq!(
1213 personal_dictionary_entries(&paths, offset, limit, false)
1214 .unwrap_err()
1215 .to_string(),
1216 INVALID_PAGE
1217 );
1218 }
1219
1220 for count in [7, 8, 9] {
1222 let long = entry(
1223 Pinyin,
1224 &vec!["ni"; count].join("'"),
1225 &"你".repeat(count),
1226 100_000,
1227 );
1228 edit_personal_dictionary(&paths, None, Some(&long), "").unwrap();
1229 assert!(has(&paths, &long), "{count}");
1230 }
1231
1232 let changed = PersonalDictionaryEntry {
1233 value: "你好".into(),
1234 ..pinyin.clone()
1235 };
1236 let stale = PersonalDictionaryEntry {
1237 weight: pinyin.weight - 1,
1238 ..pinyin.clone()
1239 };
1240 assert_eq!(
1241 edit_personal_dictionary(&paths, Some(&stale), Some(&changed), "")
1242 .unwrap_err()
1243 .to_string(),
1244 ENTRY_CHANGED
1245 );
1246 assert!(has(&paths, &pinyin));
1247 let missing_table = entry(Pinyin, "bu'hao", "补好", pinyin.weight);
1249 assert_eq!(
1250 edit_personal_dictionary(&paths, Some(&pinyin), Some(&missing_table), "")
1251 .unwrap_err()
1252 .to_string(),
1253 ENTRY_NOT_SAVED
1254 );
1255 assert!(has(&paths, &pinyin));
1256
1257 edit_personal_dictionary(&paths, Some(&pinyin), Some(&changed), "edit-1").unwrap();
1258 assert!(!has(&paths, &pinyin) && has(&paths, &changed));
1259 edit_personal_dictionary(&paths, Some(&pinyin), Some(&changed), "edit-1").unwrap();
1260 assert_eq!(
1261 edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), "edit-1")
1262 .unwrap_err()
1263 .to_string(),
1264 RECEIPT_REUSED
1265 );
1266 assert!(has(&paths, &changed));
1267 for bad in ["bad id", "é", &"a".repeat(129)] {
1268 assert_eq!(
1269 edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), bad)
1270 .unwrap_err()
1271 .to_string(),
1272 INVALID_REQUEST_ID
1273 );
1274 }
1275
1276 sql(&journal, "CREATE TRIGGER reject_edit BEFORE INSERT ON user_dictionary_operations BEGIN SELECT RAISE(ABORT,'fixture failure'); END;");
1278 assert!(
1279 edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), "failure-retry")
1280 .is_err()
1281 );
1282 assert!(has(&paths, &changed) && !has(&paths, &pinyin));
1283 sql(&journal, "DROP TRIGGER reject_edit;");
1284 edit_personal_dictionary(&paths, Some(&changed), Some(&pinyin), "failure-retry").unwrap();
1285 edit_personal_dictionary(&paths, Some(&pinyin), Some(&changed), "").unwrap();
1286 edit_personal_dictionary(&paths, Some(&changed), None, "").unwrap();
1287 assert!(!has(&paths, &changed));
1288 assert_eq!(
1289 weight(&journal, "SELECT user_inserted FROM user_dictionary_operations WHERE value='你好' AND operation='delete' AND weight=0"),
1290 Some(1)
1291 );
1292
1293 let upgraded =
1294 prepare_runtime_paths(&paths.resources, &paths.user_data, &paths.cache, "second")
1295 .unwrap();
1296 assert!(!has(&upgraded, &changed) && !has(&upgraded, &pinyin));
1297 assert!(has(&upgraded, &wubi) && has(&upgraded, &english) && has(&upgraded, &quick));
1298 edit_personal_dictionary(&upgraded, Some(&pinyin), Some(&changed), "edit-1").unwrap();
1300 assert!(!has(&upgraded, &changed));
1301 let entries = personal_dictionary_entries(&upgraded, 0, 100, false).unwrap();
1302 assert_eq!(entries.entries.len(), 6);
1303 }
1304
1305 #[test]
1306 fn removing_a_pinyin_word_drops_its_personal_context_and_reweighting_keeps_it() {
1307 use PersonalDictionaryKind::*;
1308 let root = tempfile::tempdir().unwrap();
1309 let paths = fixture(root.path());
1310 let journal = paths.user(assets::USER_JOURNAL);
1311 let word = entry(Pinyin, "ni'hao", "你好", 100);
1312 edit_personal_dictionary(&paths, None, Some(&word), "").unwrap();
1313 sql(&journal, "INSERT INTO personal_bigram VALUES('\u{1}','你好',3),('你好','世界',2); INSERT INTO personal_trigram VALUES('我','你好','世界',1);");
1314 let reweighted = PersonalDictionaryEntry {
1315 weight: 200,
1316 ..word.clone()
1317 };
1318 edit_personal_dictionary(&paths, Some(&word), Some(&reweighted), "").unwrap();
1319 assert_eq!(
1320 weight(&journal, "SELECT count(*) FROM personal_bigram"),
1321 Some(2)
1322 );
1323 edit_personal_dictionary(&paths, Some(&reweighted), None, "").unwrap();
1324 assert_eq!(
1325 weight(&journal, "SELECT count(*) FROM personal_bigram"),
1326 Some(0)
1327 );
1328 assert_eq!(
1329 weight(&journal, "SELECT count(*) FROM personal_trigram"),
1330 Some(0)
1331 );
1332 }
1333
1334 #[test]
1335 fn learned_pinyin_is_exported_without_single_characters_and_unknown_rows_are_skipped() {
1336 let root = tempfile::tempdir().unwrap();
1337 let paths = fixture(root.path());
1338 let journal = paths.user(assets::USER_JOURNAL);
1339 ensure_user_database(&journal).unwrap();
1340 sql(
1341 &journal,
1342 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES
1343 ('pinyin','ni''hao','你好','upsert',1,0),
1344 ('pinyin','ni','你','upsert',2,0),
1345 ('pinyin','ni''men','你们','delete',0,0),
1346 ('wubi','wqvb','你好','upsert',3,0),
1347 ('japanese','ni','你','upsert',4,1),
1348 ('quick','dh',CAST(x'e794b5ff' AS TEXT),'upsert',5,1);",
1349 );
1350 let user_only = personal_dictionary_entries(&paths, 0, 100, false).unwrap();
1351 assert_eq!(
1352 user_only.entries,
1353 vec![entry(
1354 PersonalDictionaryKind::QuickPhrase,
1355 "dh",
1356 "电\u{fffd}",
1357 5
1358 )]
1359 );
1360 let exported = personal_dictionary_entries(&paths, 0, 100, true).unwrap();
1361 assert_eq!(
1362 exported.entries,
1363 vec![
1364 entry(PersonalDictionaryKind::Pinyin, "ni'hao", "你好", 1),
1365 entry(PersonalDictionaryKind::QuickPhrase, "dh", "电\u{fffd}", 5),
1366 ]
1367 );
1368 }
1369
1370 #[test]
1371 fn kind_export_filters_before_offset_and_keeps_export_rules() {
1372 use PersonalDictionaryKind::*;
1373 let root = tempfile::tempdir().unwrap();
1374 let paths = fixture(root.path());
1375 let journal = paths.user(assets::USER_JOURNAL);
1376 ensure_user_database(&journal).unwrap();
1377 let connection = open_database(&journal, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap();
1378 for kind in [Pinyin, Wubi, QuickPhrase, English, Wubi98] {
1379 for (key, value, weight) in [
1380 ("a", "合成甲", 11),
1381 ("a", "合成乙", 12),
1382 ("b", "合成丙", 13),
1383 ] {
1384 connection.execute(
1385 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES(?1,?2,?3,'upsert',?4,1)",
1386 params![kind.journal_name(), key, value, weight],
1387 ).unwrap();
1388 }
1389 connection.execute(
1390 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES(?1,'x','合成学习','upsert',21,0), (?1,'y','合成删除','delete',0,1)",
1391 [kind.journal_name()],
1392 ).unwrap();
1393 }
1394 connection.execute_batch(
1395 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted) VALUES
1396 ('pinyin','c','合','upsert',22,1),
1397 ('japanese','a','合成未知','upsert',23,1);",
1398 ).unwrap();
1399 drop(connection);
1400 for kind in [Pinyin, Wubi, QuickPhrase, English, Wubi98] {
1401 let mut expected = vec![
1402 entry(kind, "a", "合成乙", 12),
1403 entry(kind, "a", "合成甲", 11),
1404 entry(kind, "b", "合成丙", 13),
1405 ];
1406 if kind == Pinyin {
1407 expected.push(entry(kind, "x", "合成学习", 21));
1408 }
1409 let first =
1410 personal_dictionary_export_entries_for_kind(&paths, true, kind, 0, 2).unwrap();
1411 assert_eq!(first.entries, expected[..2]);
1412 assert!(first.has_more);
1413 let last =
1414 personal_dictionary_export_entries_for_kind(&paths, true, kind, 2, 2).unwrap();
1415 assert_eq!(last.entries, expected[2..]);
1416 assert!(!last.has_more);
1417 assert_eq!(
1418 personal_dictionary_export_entries_for_kind(&paths, true, kind, expected.len(), 1)
1419 .unwrap(),
1420 PersonalDictionaryPage::default()
1421 );
1422 let restricted =
1423 personal_dictionary_export_entries_for_kind(&paths, false, kind, 0, 1000).unwrap();
1424 assert_eq!(
1425 restricted.entries,
1426 if kind == English {
1427 expected
1428 } else {
1429 Vec::new()
1430 }
1431 );
1432 assert!(!restricted.has_more);
1433 }
1434 }
1435
1436 #[test]
1437 fn kind_export_validates_pages_and_reads_without_creating_a_journal() {
1438 use PersonalDictionaryKind::English;
1439 let root = tempfile::tempdir().unwrap();
1440 let paths = fixture(root.path());
1441 let journal = paths.user(assets::USER_JOURNAL);
1442 assert_eq!(
1443 personal_dictionary_export_entries_for_kind(&paths, true, English, 1_000_000, 1000)
1444 .unwrap(),
1445 PersonalDictionaryPage::default()
1446 );
1447 assert!(!journal.exists());
1448 for (offset, limit) in [(0, 0), (0, 1001), (1_000_001, 1)] {
1449 assert_eq!(
1450 personal_dictionary_export_entries_for_kind(&paths, true, English, offset, limit)
1451 .unwrap_err()
1452 .to_string(),
1453 INVALID_PAGE
1454 );
1455 }
1456 let mut relative = paths.clone();
1457 relative.user_data = "relative".into();
1458 assert_eq!(
1459 personal_dictionary_export_entries_for_kind(&relative, true, English, 0, 1)
1460 .unwrap_err()
1461 .to_string(),
1462 STORAGE_UNAVAILABLE
1463 );
1464 std::fs::write(&journal, b"synthetic invalid database").unwrap();
1465 assert_eq!(
1466 personal_dictionary_export_entries_for_kind(&paths, true, English, 0, 1)
1467 .unwrap_err()
1468 .to_string(),
1469 DICTIONARY_NOT_READ
1470 );
1471 }
1472
1473 #[test]
1475 fn a_learned_english_word_above_the_ceiling_stays_editable() {
1476 use PersonalDictionaryKind::English;
1477 let root = tempfile::tempdir().unwrap();
1478 let paths = fixture(root.path());
1479 let word = entry(English, "foo", "foo", 10);
1480 edit_personal_dictionary(&paths, None, Some(&word), "").unwrap();
1481 let learned = 20_000_001_000_i64;
1482 let journal = open_database(
1483 &paths.user(assets::USER_JOURNAL),
1484 OpenFlags::SQLITE_OPEN_READ_WRITE,
1485 )
1486 .unwrap();
1487 journal
1488 .execute(
1489 crate::user_dictionary::journal::UPSERT_JOURNAL_SQL,
1490 params!["english", "foo", "foo", learned, "foo"],
1491 )
1492 .unwrap();
1493 drop(journal);
1494 let listed = personal_dictionary_entries(&paths, 0, 10, false)
1495 .unwrap()
1496 .entries
1497 .into_iter()
1498 .find(|entry| entry.key == "foo")
1499 .unwrap();
1500 assert_eq!(listed.weight, learned);
1501 let reweighted = entry(English, "foo", "foo", 500);
1502 edit_personal_dictionary(&paths, Some(&listed), Some(&reweighted), "").unwrap();
1503 let listed = personal_dictionary_entries(&paths, 0, 10, false)
1504 .unwrap()
1505 .entries;
1506 assert_eq!(listed, vec![reweighted.clone()]);
1507 edit_personal_dictionary(&paths, Some(&reweighted), None, "").unwrap();
1508 assert!(personal_dictionary_entries(&paths, 0, 10, false)
1509 .unwrap()
1510 .entries
1511 .is_empty());
1512 let too_heavy = entry(English, "bar", "bar", MAX_ENTRY_WEIGHT + 1);
1514 assert!(edit_personal_dictionary(&paths, None, Some(&too_heavy), "").is_err());
1515 }
1516
1517 #[test]
1518 fn receipt_payloads_keep_the_reference_text() {
1519 let mut payload = String::new();
1520 append_payload(&mut payload, None);
1521 append_payload(
1522 &mut payload,
1523 Some(&entry(PersonalDictionaryKind::English, "dont", "don't", 7)),
1524 );
1525 assert_eq!(payload, "none;3;7;4:dont5:don't");
1526 let mut chinese = String::new();
1527 append_payload(
1528 &mut chinese,
1529 Some(&entry(
1530 PersonalDictionaryKind::Pinyin,
1531 "ni'hao",
1532 "你好",
1533 12345,
1534 )),
1535 );
1536 assert_eq!(chinese, "0;12345;6:ni'hao6:你好");
1537 }
1538}