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