use rusqlite::{params, OpenFlags, OptionalExtension, TransactionBehavior};
use crate::assets;
use crate::error::Result;
use crate::format;
use crate::paths::RuntimePaths;
use crate::types::{PersonalDictionaryEntry, PersonalDictionaryKind};
use crate::user_dictionary::journal::{open_database, pinyin_table};
use crate::user_dictionary::personal::{
check_receipt, column_i64, column_text, failed, generation_has_main_dictionary, maintenance,
open_edit_connection, page_bind, save_receipt, valid_request_id, Receipt,
DICTIONARY_NOT_OPENED, EDIT_NOT_BEGUN, ENTRY_CHANGED, INVALID_REQUEST_ID, MAX_ENTRY_WEIGHT,
MAX_PAGE_LIMIT, MAX_PAGE_OFFSET, NO_CHINESE_DICTIONARY, RETRY_NOT_FINISHED,
STORAGE_NOT_ATTACHED, STORAGE_UNAVAILABLE,
};
use crate::user_dictionary::replay::attach;
pub const MAX_QUERY_BYTES: usize = 256;
const MAX_BUNDLED_KEY_BYTES: usize = 1_024;
const MAX_BUNDLED_VALUE_BYTES: usize = 4_096;
const INVALID_LOOKUP: &str = "Invalid dictionary lookup";
const DICTIONARY_NOT_READ: &str = "Cannot read dictionary";
const LOOKUP_UNFINISHED: &str = "Cannot finish reading dictionary";
const WEIGHT_OUT_OF_RANGE: &str = "Weight is outside 1 to 100000000";
const INVALID_BUNDLED_ENTRY: &str = "Invalid bundled dictionary entry";
const PERSONAL_NOT_READ: &str = "Cannot read personal dictionary";
const USER_ENTRY: &str = "The entry is a user entry; edit it as one";
const ENTRY_NOT_SAVED: &str = "Cannot save dictionary entry";
const ENTRY_NOT_REMOVED: &str = "Cannot remove dictionary entry";
const EDIT_NOT_JOURNALED: &str = "Cannot journal dictionary edit";
const EDIT_NOT_COMMITTED: &str = "Cannot commit dictionary edit";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DictionaryTableEntry {
pub entry: PersonalDictionaryEntry,
pub user_inserted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DictionaryTablePage {
pub entries: Vec<DictionaryTableEntry>,
pub has_more: bool,
}
pub fn dictionary_table_entries(
paths: &RuntimePaths,
kind: PersonalDictionaryKind,
query: &str,
offset: usize,
limit: usize,
) -> Result<DictionaryTablePage> {
if limit == 0
|| limit > MAX_PAGE_LIMIT
|| offset > MAX_PAGE_OFFSET
|| query.len() > MAX_QUERY_BYTES
{
return Err(failed(INVALID_LOOKUP));
}
let word_search = !query.is_ascii();
let code = if word_search {
query.trim().to_owned()
} else {
let Some(code) = lookup_code(kind, query) else {
return Ok(DictionaryTablePage::default());
};
code
};
if code.is_empty() && !query.trim().is_empty() {
return Ok(DictionaryTablePage::default());
}
paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
let english = kind == PersonalDictionaryKind::English;
let dictionary = paths.dictionary(if english {
assets::ENGLISH_DICTIONARY
} else {
assets::MAIN_DICTIONARY
});
if !dictionary.exists() {
return Ok(DictionaryTablePage::default());
}
let connection = open_database(&dictionary, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|_| failed(DICTIONARY_NOT_OPENED))?;
let journal_path = paths.user(assets::USER_JOURNAL);
let journal = journal_path.exists()
&& {
attach(&connection, &journal_path, "lookup_journal")
.map_err(|_| failed(STORAGE_NOT_ATTACHED))?;
connection
.query_row(
"SELECT 1 FROM lookup_journal.sqlite_master WHERE type='table' AND name='user_dictionary_operations'",
[],
|_| Ok(()),
)
.optional()
.is_ok_and(|found| found.is_some())
};
let tables = lookup_tables(kind, &code, word_search);
let (key_column, value_column) = if english {
("word", "display")
} else {
("key", "value")
};
let (matches, exact) = if code.is_empty() {
("1".to_owned(), "0".to_owned())
} else if word_search {
(
format!("instr({value_column},?1)>0"),
format!("{value_column}=?1"),
)
} else {
match kind {
PersonalDictionaryKind::Pinyin => (
"substr(replace(key,'''',''),1,?2)=?1".to_owned(),
"replace(key,'''','')=?1".to_owned(),
),
PersonalDictionaryKind::QuickPhrase => (
"substr(lower(key),1,?2)=?1".to_owned(),
"lower(key)=?1".to_owned(),
),
PersonalDictionaryKind::Wubi
| PersonalDictionaryKind::Wubi98
| PersonalDictionaryKind::English => (
format!("{key_column}>=?1 AND {key_column}<?3"),
format!("{key_column}=?1"),
),
}
};
let rows = lookup_rows(
&tables,
key_column,
value_column,
&exact,
&matches,
|table| {
connection
.query_row(
"SELECT 1 FROM main.sqlite_master WHERE type='table' AND name=?1",
[table],
|_| Ok(()),
)
.optional()
.map(|present| present.is_some())
},
)
.map_err(|_| failed(DICTIONARY_NOT_READ))?;
if rows.is_empty() {
return Ok(DictionaryTablePage::default());
}
let mut sql = format!(
"SELECT r.key,r.value,{} AS entry_weight,{} AS user_row FROM (SELECT key,value,MAX(weight) AS weight,MAX(exact) AS exact FROM ({}) GROUP BY key,value) AS r",
if journal { "COALESCE(j.weight,r.weight)" } else { "r.weight" },
if journal { "j.key IS NOT NULL" } else { "0" },
rows.join(" UNION ALL "),
);
if journal {
sql.push_str(" LEFT JOIN lookup_journal.user_dictionary_operations AS j ON j.dictionary=?4 AND j.key=r.key AND j.value=r.value AND j.user_inserted=1 AND j.operation='upsert'");
}
sql.push_str(
" ORDER BY user_row DESC,r.exact DESC,entry_weight DESC,r.key,r.value LIMIT ?5 OFFSET ?6",
);
let upper = if word_search {
String::new()
} else {
let mut upper = code.clone().into_bytes();
if let Some(last) = upper.last_mut() {
*last += 1;
}
String::from_utf8(upper).expect("codes are ASCII")
};
let mut statement = connection
.prepare(&sql)
.map_err(|_| failed(DICTIONARY_NOT_READ))?;
let mut page = DictionaryTablePage {
entries: Vec::new(),
has_more: false,
};
let read = (|| -> rusqlite::Result<()> {
let mut cursor = statement.query(params![
code,
page_bind(code.len()),
upper,
kind.journal_name(),
page_bind(limit + 1),
page_bind(offset),
])?;
while let Some(row) = cursor.next()? {
if page.entries.len() == limit {
page.has_more = true;
break;
}
if page.entries.capacity() == 0 {
page.entries.reserve_exact(limit);
}
page.entries.push(DictionaryTableEntry {
entry: PersonalDictionaryEntry {
kind,
key: column_text(row, 0)?,
value: column_text(row, 1)?,
weight: column_i64(row, 2)?,
},
user_inserted: column_i64(row, 3)? != 0,
});
}
Ok(())
})();
if read.is_err() {
return Err(failed(LOOKUP_UNFINISHED));
}
Ok(page)
}
fn check_bundled_edit(
main_dictionary: bool,
previous: &PersonalDictionaryEntry,
weight: Option<i64>,
request_id: &str,
) -> Result<()> {
if !valid_request_id(request_id) {
return Err(failed(INVALID_REQUEST_ID));
}
if weight.is_some_and(|weight| !(1..=MAX_ENTRY_WEIGHT).contains(&weight)) {
return Err(failed(WEIGHT_OUT_OF_RANGE));
}
if bundled_table(previous.kind, &previous.key).is_none()
|| previous.key.is_empty()
|| previous.value.is_empty()
|| previous.key.len() > MAX_BUNDLED_KEY_BYTES
|| previous.value.len() > MAX_BUNDLED_VALUE_BYTES
{
return Err(failed(INVALID_BUNDLED_ENTRY));
}
if !main_dictionary && previous.kind != PersonalDictionaryKind::English {
return Err(failed(NO_CHINESE_DICTIONARY));
}
Ok(())
}
pub fn edit_bundled_dictionary_entry(
paths: &RuntimePaths,
previous: &PersonalDictionaryEntry,
weight: Option<i64>,
request_id: &str,
) -> Result<()> {
let main_dictionary = generation_has_main_dictionary(paths);
check_bundled_edit(main_dictionary, previous, weight, request_id)?;
let _access = maintenance(paths)?;
edit_bundled_dictionary_entry_with(paths, main_dictionary, previous, weight, request_id)
}
pub fn edit_bundled_dictionary_entry_with(
paths: &RuntimePaths,
main_dictionary: bool,
previous: &PersonalDictionaryEntry,
weight: Option<i64>,
request_id: &str,
) -> Result<()> {
check_bundled_edit(main_dictionary, previous, weight, request_id)?;
let Some(table) = bundled_table(previous.kind, &previous.key) else {
return Err(failed(INVALID_BUNDLED_ENTRY));
};
let english = previous.kind == PersonalDictionaryKind::English;
let target = format!(
"{}.\"{table}\"",
if english { "replay_english" } else { "main" }
);
let row = if english {
" WHERE word=?1 AND display=?2"
} else {
" WHERE key=?1 AND value=?2"
};
let dictionary = previous.kind.journal_name();
paths.validate().map_err(|_| failed(STORAGE_UNAVAILABLE))?;
let mut connection = open_edit_connection(paths, main_dictionary)?;
let transaction = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|_| failed(EDIT_NOT_BEGUN))?;
let payload = format!(
"bundled;{dictionary};{};{};{}:{}{}:{}",
previous.weight,
weight.map_or_else(|| "delete".to_owned(), |weight| weight.to_string()),
previous.key.len(),
previous.key,
previous.value.len(),
previous.value,
);
if !request_id.is_empty()
&& check_receipt(&transaction, request_id, &payload)? == Receipt::Retry
{
return transaction.commit().map_err(|_| failed(RETRY_NOT_FINISHED));
}
let user_row = transaction
.query_row(
"SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3 AND user_inserted=1 AND operation='upsert'",
params![dictionary, previous.key, previous.value],
|_| Ok(()),
)
.optional()
.map_err(|_| failed(PERSONAL_NOT_READ))?;
if user_row.is_some() {
return Err(failed(USER_ENTRY));
}
let current = transaction
.query_row(
&format!("SELECT MAX(weight) FROM {target}{row}"),
params![previous.key, previous.value],
|row| row.get::<_, Option<i64>>(0),
)
.map_err(|_| failed(ENTRY_CHANGED))?;
if current != Some(previous.weight) {
return Err(failed(ENTRY_CHANGED));
}
let changed = match weight {
Some(weight) => transaction.execute(
&format!("UPDATE {target} SET weight=?3{row}"),
params![previous.key, previous.value, weight],
),
None => transaction.execute(
&format!("DELETE FROM {target}{row}"),
params![previous.key, previous.value],
),
};
if !matches!(changed, Ok(count) if count > 0) {
return Err(failed(if weight.is_some() {
ENTRY_NOT_SAVED
} else {
ENTRY_NOT_REMOVED
}));
}
let journaled = match weight {
Some(weight) => transaction.execute(
"INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,'upsert',?4,?5,0) ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='upsert',weight=excluded.weight,display=excluded.display,user_inserted=0, updated_at=unixepoch()",
params![
dictionary,
previous.key,
previous.value,
weight,
if english { previous.value.as_str() } else { "" }
],
),
None => transaction.execute(
"INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation) VALUES(?1,?2,?3,'delete') ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='delete',weight=0,display='',updated_at=unixepoch()",
params![dictionary, previous.key, previous.value],
),
};
if journaled.is_err() {
return Err(failed(EDIT_NOT_JOURNALED));
}
if !request_id.is_empty() {
save_receipt(&transaction, request_id, &payload)?;
}
transaction
.commit()
.map_err(|_| failed(EDIT_NOT_COMMITTED))?;
Ok(())
}
fn bundled_table(kind: PersonalDictionaryKind, key: &str) -> Option<String> {
match kind {
PersonalDictionaryKind::Pinyin => pinyin_table(key),
PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
kind.wubi_table().map(str::to_owned)
}
PersonalDictionaryKind::QuickPhrase => Some("quick_parases".to_owned()),
PersonalDictionaryKind::English => Some("english_words".to_owned()),
}
}
fn lookup_tables(kind: PersonalDictionaryKind, code: &str, word_search: bool) -> Vec<String> {
let initials = if !word_search && !code.is_empty() {
&code.as_bytes()[..code.len().min(1)]
} else {
b"abcdefghijklmnopqrstuvwxyz"
};
let capacity = match kind {
PersonalDictionaryKind::Pinyin => (format::MAXIMUM_NUMBERED_SYLLABLES + 1) * initials.len(),
PersonalDictionaryKind::Wubi
| PersonalDictionaryKind::Wubi98
| PersonalDictionaryKind::QuickPhrase
| PersonalDictionaryKind::English => 1,
};
let mut tables = Vec::with_capacity(capacity);
match kind {
PersonalDictionaryKind::Pinyin => {
for initial in initials {
for syllables in 1..=format::MAXIMUM_NUMBERED_SYLLABLES + 1 {
if let Some(table) = format::quanpin_table(syllables, *initial) {
tables.push(table);
}
}
}
}
PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
tables.extend(kind.wubi_table().map(str::to_owned))
}
PersonalDictionaryKind::QuickPhrase => tables.push("quick_parases".to_owned()),
PersonalDictionaryKind::English => tables.push("english_words".to_owned()),
}
tables
}
fn lookup_rows<F>(
tables: &[String],
key_column: &str,
value_column: &str,
exact: &str,
matches: &str,
mut table_exists: F,
) -> rusqlite::Result<Vec<String>>
where
F: FnMut(&str) -> rusqlite::Result<bool>,
{
let mut rows = Vec::with_capacity(tables.len());
for table in tables {
if table_exists(table)? {
rows.push(format!(
"SELECT {key_column} AS key,{value_column} AS value,weight,{exact} AS exact FROM main.\"{table}\" WHERE {matches}"
));
}
}
Ok(rows)
}
fn lookup_code(kind: PersonalDictionaryKind, query: &str) -> Option<String> {
let mut code = String::with_capacity(query.len());
for byte in query.bytes() {
let byte = byte.to_ascii_lowercase();
if kind == PersonalDictionaryKind::Pinyin && (byte == b'\'' || byte == b' ') {
continue;
}
let allowed = byte.is_ascii_lowercase()
|| (kind == PersonalDictionaryKind::QuickPhrase && byte.is_ascii_digit())
|| (kind == PersonalDictionaryKind::English && (byte == b'\'' || byte == b'-'));
if !allowed {
return None;
}
code.push(byte as char);
}
Some(code)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::EngineError;
use crate::user_dictionary::personal::{edit_personal_dictionary, personal_dictionary_entries};
use crate::user_dictionary::replay::replay;
use crate::user_dictionary::replay::tests::sql;
struct Fixture {
_root: tempfile::TempDir,
paths: RuntimePaths,
}
impl Fixture {
fn new() -> Self {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = |name: &str| {
let path = root.join(name);
std::fs::create_dir_all(&path).unwrap();
path
};
let paths = RuntimePaths {
resources: path("resources"),
user_data: path("user"),
cache: path("cache"),
dictionaries: path("dictionaries"),
};
sql(
&paths.resource(assets::MAIN_DICTIONARY),
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
INSERT INTO tbl_1_n VALUES('ni','n','你',900000);
CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',500000);
INSERT INTO tbl_2_n VALUES('ni''hao','nh','拟好',1000);
INSERT INTO tbl_2_n VALUES('ni''men','nm','你们',400000);
CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
INSERT INTO wubi86 VALUES('wqvb','你好',300);
CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
INSERT INTO quick_parases VALUES('dh','电话',1);",
);
sql(
&paths.resource(assets::ENGLISH_DICTIONARY),
"CREATE TABLE english_words(word TEXT COLLATE BINARY NOT NULL,display TEXT NOT NULL,weight INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(word,display)) WITHOUT ROWID;
CREATE TABLE en_zh_glosses(english TEXT PRIMARY KEY,chinese_gloss TEXT);
CREATE TABLE zh_en_glosses(chinese TEXT PRIMARY KEY,english_gloss TEXT);
INSERT INTO english_words VALUES('hello','hello',611054034);",
);
let fixture = Self {
_root: directory,
paths,
};
fixture.install_packaged_dictionaries();
fixture
}
fn install_packaged_dictionaries(&self) {
for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
std::fs::copy(self.paths.resource(name), self.paths.dictionary(name)).unwrap();
}
}
fn upgrade(&self) {
self.install_packaged_dictionaries();
let result = replay(
&self.paths.user(assets::USER_JOURNAL),
&self.paths.dictionary(assets::MAIN_DICTIONARY),
&self.paths.dictionary(assets::ENGLISH_DICTIONARY),
);
assert!(
result.applied > 0 && result.failed == 0 && result.error.is_empty(),
"{}",
result.error
);
}
fn lookup(&self, kind: PersonalDictionaryKind, query: &str) -> Vec<DictionaryTableEntry> {
dictionary_table_entries(&self.paths, kind, query, 0, 100)
.unwrap()
.entries
}
}
fn row<'a>(rows: &'a [DictionaryTableEntry], value: &str) -> Option<&'a DictionaryTableEntry> {
rows.iter().find(|row| row.entry.value == value)
}
fn values(rows: &[DictionaryTableEntry]) -> Vec<&str> {
rows.iter().map(|row| row.entry.value.as_str()).collect()
}
#[test]
fn lookup_tables_reserve_the_bounded_table_count() {
use PersonalDictionaryKind::*;
let pinyin = lookup_tables(Pinyin, "ni", false);
assert_eq!(pinyin.len(), format::MAXIMUM_NUMBERED_SYLLABLES + 1);
assert_eq!(pinyin.capacity(), format::MAXIMUM_NUMBERED_SYLLABLES + 1);
let wubi = lookup_tables(Wubi, "wqv", false);
assert_eq!(wubi.capacity(), 1);
}
#[test]
fn lookup_rows_reserve_the_bounded_table_count() {
let tables = vec!["tbl_a".to_owned(), "tbl_b".to_owned(), "tbl_c".to_owned()];
let rows = lookup_rows(&tables, "key", "value", "exact", "matches", |_| Ok(true)).unwrap();
assert_eq!(rows.len(), tables.len());
assert!(rows.capacity() >= tables.len());
}
#[test]
fn a_bundled_word_is_found_reweighted_and_deleted_and_the_change_survives_an_upgrade() {
use PersonalDictionaryKind::*;
let fixture = Fixture::new();
for query in ["ni'hao", "nihao", "NiHao", "ni hao"] {
let rows = fixture.lookup(Pinyin, query);
assert_eq!(values(&rows), ["你好", "拟好"], "{query}");
assert!(rows.iter().all(|row| !row.user_inserted), "{query}");
}
let partial = fixture.lookup(Pinyin, "nih");
assert!(row(&partial, "你好").is_some() && row(&partial, "你们").is_none());
assert!(fixture.lookup(Pinyin, "hao").is_empty());
assert_eq!(
values(&fixture.lookup(Pinyin, "ni")),
["你", "你好", "你们", "拟好"]
);
let nihao = row(&fixture.lookup(Pinyin, "nihao"), "你好")
.unwrap()
.entry
.clone();
assert_eq!(nihao.weight, 500_000);
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(100_000_001), "heavy")
.unwrap_err()
.to_string(),
WEIGHT_OUT_OF_RANGE
);
edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(1), "demote-bundled").unwrap();
edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(1), "demote-bundled").unwrap();
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &nihao, Some(2), "demote-bundled")
.unwrap_err()
.to_string(),
"Request ID was already used for another edit"
);
let rows = fixture.lookup(Pinyin, "nihao");
assert_eq!(rows[0].entry.value, "拟好");
assert_eq!(row(&rows, "你好").unwrap().entry.weight, 1);
assert!(!row(&rows, "你好").unwrap().user_inserted);
let error = edit_bundled_dictionary_entry(&fixture.paths, &nihao, None, "stale-bundled")
.unwrap_err();
assert!(matches!(error, EngineError::Failed(_)));
assert_eq!(error.to_string(), ENTRY_CHANGED);
let ni = row(&fixture.lookup(Pinyin, "ni"), "你")
.unwrap()
.entry
.clone();
edit_bundled_dictionary_entry(&fixture.paths, &ni, Some(2), "demote-single").unwrap();
let exported = personal_dictionary_entries(&fixture.paths, 0, 1000, true)
.unwrap()
.entries;
assert!(exported
.iter()
.any(|entry| entry.value == "你好" && entry.weight == 1));
assert!(!exported.iter().any(|entry| entry.value == "你"));
let wubi = row(&fixture.lookup(Wubi, "wqv"), "你好")
.unwrap()
.entry
.clone();
edit_bundled_dictionary_entry(&fixture.paths, &wubi, Some(3), "demote-wubi").unwrap();
assert!(!personal_dictionary_entries(&fixture.paths, 0, 1000, false)
.unwrap()
.entries
.iter()
.any(|entry| entry.kind == Wubi));
fixture.upgrade();
assert_eq!(
row(&fixture.lookup(Pinyin, "nihao"), "你好")
.unwrap()
.entry
.weight,
1
);
assert_eq!(
row(&fixture.lookup(Wubi, "wqvb"), "你好")
.unwrap()
.entry
.weight,
3
);
let nihao = row(&fixture.lookup(Pinyin, "nihao"), "你好")
.unwrap()
.entry
.clone();
edit_bundled_dictionary_entry(&fixture.paths, &nihao, None, "delete-bundled").unwrap();
assert!(row(&fixture.lookup(Pinyin, "nihao"), "你好").is_none());
fixture.upgrade();
let rows = fixture.lookup(Pinyin, "nihao");
assert!(row(&rows, "你好").is_none());
assert!(row(&rows, "拟好").is_some());
assert!(!personal_dictionary_entries(&fixture.paths, 0, 1000, true)
.unwrap()
.entries
.iter()
.any(|entry| entry.value == "你好"));
}
#[test]
fn user_words_lead_the_lookup_and_are_not_edited_as_bundled_rows() {
use PersonalDictionaryKind::*;
let fixture = Fixture::new();
let user = PersonalDictionaryEntry {
kind: Pinyin,
key: "nihao".into(),
value: "妳好".into(),
weight: 10,
};
assert!(edit_personal_dictionary(&fixture.paths, None, Some(&user), "add-user").is_err());
let user = PersonalDictionaryEntry {
key: "ni'hao".into(),
..user
};
edit_personal_dictionary(&fixture.paths, None, Some(&user), "add-user").unwrap();
let rows = fixture.lookup(Pinyin, "nihao");
assert_eq!(rows[0].entry, user);
assert!(rows[0].user_inserted);
assert!(rows[1..].iter().all(|row| !row.user_inserted));
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &user, None, "disguised-user")
.unwrap_err()
.to_string(),
USER_ENTRY
);
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &user, None, "add-user")
.unwrap_err()
.to_string(),
"Request ID was already used for another edit"
);
}
#[test]
fn quick_phrases_list_whole_and_english_words_are_found_by_prefix() {
use PersonalDictionaryKind::*;
let fixture = Fixture::new();
let phrases = fixture.lookup(QuickPhrase, "");
assert_eq!(
phrases,
[DictionaryTableEntry {
entry: PersonalDictionaryEntry {
kind: QuickPhrase,
key: "dh".into(),
value: "电话".into(),
weight: 1
},
user_inserted: false
}]
);
assert_eq!(fixture.lookup(QuickPhrase, "D").len(), 1);
assert!(fixture.lookup(QuickPhrase, "x").is_empty());
let words = fixture.lookup(English, "hel");
assert_eq!(values(&words), ["hello"]);
assert_eq!(words[0].entry.weight, 611_054_034);
assert!(fixture.lookup(English, "help").is_empty());
edit_bundled_dictionary_entry(&fixture.paths, &words[0].entry, None, "delete-english")
.unwrap();
assert!(fixture.lookup(English, "hel").is_empty());
fixture.upgrade();
assert!(fixture.lookup(English, "hel").is_empty());
assert_eq!(values(&fixture.lookup(Wubi, "你")), ["你好"]);
assert!(!fixture.lookup(Pinyin, "").is_empty());
assert!(fixture.lookup(Wubi, "a1").is_empty());
}
#[test]
fn lookups_validate_their_page_and_never_create_files() {
use PersonalDictionaryKind::*;
let fixture = Fixture::new();
for (offset, limit, query) in [
(0, 0, "ni"),
(0, 1001, "ni"),
(1_000_001, 1, "ni"),
(0, 1, &"n".repeat(257)[..]),
] {
assert_eq!(
dictionary_table_entries(&fixture.paths, Pinyin, query, offset, limit)
.unwrap_err()
.to_string(),
INVALID_LOOKUP
);
}
let page = dictionary_table_entries(&fixture.paths, Pinyin, "ni", 1, 2).unwrap();
assert_eq!(values(&page.entries), ["你好", "你们"]);
assert!(page.has_more);
let empty = dictionary_table_entries(&fixture.paths, Pinyin, "nx", 0, 100).unwrap();
assert!(empty.entries.is_empty());
assert_eq!(empty.entries.capacity(), 0);
assert!(!fixture.paths.user(assets::USER_JOURNAL).exists());
std::fs::remove_file(fixture.paths.dictionary(assets::MAIN_DICTIONARY)).unwrap();
assert!(fixture.lookup(Pinyin, "ni").is_empty());
assert!(!fixture.paths.dictionary(assets::MAIN_DICTIONARY).exists());
}
#[test]
fn bundled_edits_validate_before_touching_storage() {
use PersonalDictionaryKind::*;
let fixture = Fixture::new();
let entry = |key: &str, value: &str| PersonalDictionaryEntry {
kind: Pinyin,
key: key.into(),
value: value.into(),
weight: 1000,
};
for (previous, message) in [
(entry("ni''hao", "拟好"), INVALID_BUNDLED_ENTRY),
(entry("", "拟好"), INVALID_BUNDLED_ENTRY),
(entry("ni'hao", ""), INVALID_BUNDLED_ENTRY),
] {
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &previous, None, "")
.unwrap_err()
.to_string(),
message
);
}
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &entry("ni'hao", "拟好"), Some(0), "")
.unwrap_err()
.to_string(),
WEIGHT_OUT_OF_RANGE
);
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &entry("ni'hao", "拟好"), None, "bad id")
.unwrap_err()
.to_string(),
INVALID_REQUEST_ID
);
assert!(!fixture.paths.user(assets::USER_JOURNAL).exists());
assert_eq!(
edit_bundled_dictionary_entry(&fixture.paths, &entry("ni'hao", "尼好"), None, "")
.unwrap_err()
.to_string(),
ENTRY_CHANGED
);
}
#[test]
fn engine_level_bundled_edits_take_the_maintenance_lease() {
use PersonalDictionaryKind::*;
let fixture = Fixture::new();
let paths = &fixture.paths;
let nihao = row(&fixture.lookup(Pinyin, "nihao"), "你好")
.unwrap()
.entry
.clone();
let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
.unwrap()
.unwrap();
let error =
edit_bundled_dictionary_entry(paths, &nihao, Some(1), "busy-bundled").unwrap_err();
assert_eq!(error.code(), crate::ErrorCode::Busy);
assert_eq!(
error.to_string(),
crate::diagnostics::DICTIONARY_MAINTENANCE_BUSY
);
assert_eq!(
row(&fixture.lookup(Pinyin, "nihao"), "你好")
.unwrap()
.entry
.weight,
500_000
);
assert!(!paths.user(assets::USER_JOURNAL).exists());
let error = edit_bundled_dictionary_entry(paths, &nihao, Some(0), "").unwrap_err();
assert_eq!(error.code(), crate::ErrorCode::Failed);
assert_eq!(error.to_string(), WEIGHT_OUT_OF_RANGE);
drop(session);
edit_bundled_dictionary_entry(paths, &nihao, Some(1), "busy-bundled").unwrap();
assert_eq!(
row(&fixture.lookup(Pinyin, "nihao"), "你好")
.unwrap()
.entry
.weight,
1
);
}
}