msime-engine 0.2.0

Input method engine for Chinese and other languages: quanpin, shuangpin, nine-key, wubi, zhuyin, Cantonese, Japanese, Korean and more, ranked over a word lattice with typo correction and learning from what the user picks. The engine of 水杉输入法 (Metasequoia IME).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Session-free dictionary calls on `EngineOptions` paths (api-contract §1b), and the snapshot record stream.

use std::path::Path;

use super::options::{runtime_paths, EngineOptions};
use crate::diagnostics;
use crate::error::{EngineError, Result};
use crate::types::{PersonalDictionaryEntry, PersonalDictionaryKind};
use crate::user_dictionary::journal::release_thread_journal;
use crate::user_dictionary::{bundled, habits, personal, replay, reset, state};

// 产品胶水:桥接层沿用下来的名字,指的就是根上文档化的 `PersonalDictionaryKind`、`PersonalDictionaryEntry`、`PersonalDictionaryPage`。msime 自己的宿主照旧用这几个名字,第三方用根上的名字。
pub type DictionaryKind = PersonalDictionaryKind;
pub type DictionaryEntry = PersonalDictionaryEntry;
use crate::user_dictionary::bundled::DictionaryTablePage;
pub use crate::user_dictionary::habits::{habit_is_storable, LearningHabit, LearningHabitsMerge};
pub use crate::user_dictionary::personal::PersonalDictionaryPage as DictionaryPage;
pub use crate::user_dictionary::state::DictionaryStateMerge;
use crate::user_dictionary::state::DictionaryStateRecord;

/// A sanitised transport failure (cancellation, truncation, bad checksum).
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("Snapshot record stream failed")]
pub struct SnapshotReadError;

/// 代次里有没有 `msime-pinyin.db`:会话允许的方案里有读它的方案时才有(`SchemeSet::reads_main_dictionary`)。没有它的版本,个人词库只收英文词。
fn main_dictionary(options: &EngineOptions) -> bool {
    options.enabled_schemes.reads_main_dictionary()
}

/// These calls come from pool threads (a Tauri blocking task, the MCP server) that may never touch the journal again, so each one closes its thread's cached connection before returning, on success and failure alike.
fn released<T>(value: T) -> T {
    release_thread_journal();
    value
}

pub fn dictionary_entries(
    options: &EngineOptions,
    offset: usize,
    limit: usize,
) -> Result<DictionaryPage> {
    released(personal::personal_dictionary_entries_with(
        &runtime_paths(options),
        main_dictionary(options),
        offset,
        limit,
        false,
    ))
}

/// [`dictionary_entries`] 和 [`dictionary_entries_for_kind`] 的读取来源:是否读主词库,加上用户日志和它的 `-wal` 的戳(Unix 上是 inode、长度、修改时间和状态改变时间,别处是长度和修改时间;文件不在记作 `-`)。两次结果相同,同一页就读得出同样的内容,宿主据此跳过重查。空的 `-wal` 没有任何帧,与没有 `-wal` 同等看待:只读连接打开 WAL 日志时会建出空 `-wal`,读写连接最后关闭时又会删掉它,不这样处理的话,列表读取本身就会让下一次的戳变掉。读不出戳(路径无效、元数据读取失败)时为 `None`,调用方必须照常重查。
///
/// 文件最后一次改动离现在还不到 `state::REVISION_SETTLE`(两秒)时也是 `None`,与 `state::cached_dictionary_state_revision` 同一个道理:修改时间的精度有限,取戳、读页之后同一个时间粒度里又来一次长度不变的写入(WAL 重置后从头重写 `-wal`、回滚日志模式下就地改页),戳会完全一样,旧的那一页就会一直被当成最新的。刚写过的日志每次都重查,落定以后的戳才拿来跳过。
pub fn dictionary_entries_source(options: &EngineOptions) -> Option<String> {
    let paths = runtime_paths(options);
    paths.validate().ok()?;
    entries_source(
        &paths.user(crate::assets::USER_JOURNAL),
        main_dictionary(options),
        std::time::SystemTime::now,
    )
}

/// `now` 只在有文件要判断是否落定时才取:wasm32-unknown-unknown 上取系统时间会 panic,而那里读元数据本来就失败,走不到这一步。
fn entries_source(
    journal: &Path,
    main_dictionary: bool,
    now: impl FnOnce() -> std::time::SystemTime,
) -> Option<String> {
    let mut wal = journal.as_os_str().to_owned();
    wal.push("-wal");
    let (journal_stamp, journal_change) = source_stamp(journal, false)?;
    let (wal_stamp, wal_change) = source_stamp(Path::new(&wal), true)?;
    let changes = [journal_change, wal_change];
    if changes.iter().any(Option::is_some) {
        let horizon = now().checked_sub(crate::user_dictionary::state::REVISION_SETTLE)?;
        if !changes.iter().flatten().all(|change| *change < horizon) {
            return None;
        }
    }
    Some(format!(
        "{}|{}|{}",
        u8::from(main_dictionary),
        journal_stamp,
        wal_stamp
    ))
}

/// 一个文件的戳和它最后一次改动的时间(修改时间与 Unix 上的状态改变时间里较晚的一个);不在(或 `empty_is_missing` 时为空)的文件是 `("-", None)`。
fn source_stamp(
    path: &Path,
    empty_is_missing: bool,
) -> Option<(String, Option<std::time::SystemTime>)> {
    let metadata = match std::fs::metadata(path) {
        Ok(metadata) if empty_is_missing && metadata.len() == 0 => return Some(("-".into(), None)),
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Some(("-".into(), None))
        }
        Err(_) => return None,
    };
    let modified = metadata.modified().ok()?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        let changed = std::time::UNIX_EPOCH.checked_add(std::time::Duration::new(
            u64::try_from(metadata.ctime()).ok()?,
            u32::try_from(metadata.ctime_nsec()).ok()?,
        ))?;
        Some((
            format!(
                "{}:{}:{}.{}:{}.{}",
                metadata.ino(),
                metadata.len(),
                metadata.mtime(),
                metadata.mtime_nsec(),
                metadata.ctime(),
                metadata.ctime_nsec()
            ),
            Some(modified.max(changed)),
        ))
    }
    #[cfg(not(unix))]
    {
        let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
        Some((
            format!("{}:{}", metadata.len(), since_epoch.as_nanos()),
            Some(modified),
        ))
    }
}

pub fn dictionary_export_entries(
    options: &EngineOptions,
    offset: usize,
    limit: usize,
    include_learned_pinyin: bool,
) -> Result<DictionaryPage> {
    released(personal::personal_dictionary_entries_with(
        &runtime_paths(options),
        main_dictionary(options),
        offset,
        limit,
        include_learned_pinyin,
    ))
}

/// 只查询指定词库的一页,偏移和后续页状态都以该词库为准。
pub fn dictionary_export_entries_for_kind(
    options: &EngineOptions,
    kind: DictionaryKind,
    offset: usize,
    limit: usize,
) -> Result<DictionaryPage> {
    released(personal::personal_dictionary_export_entries_for_kind(
        &runtime_paths(options),
        main_dictionary(options),
        kind,
        offset,
        limit,
    ))
}

/// 只查询指定词库的用户词条一页,供设置列表在数据库层先筛选再分页。
pub fn dictionary_entries_for_kind(
    options: &EngineOptions,
    kind: DictionaryKind,
    offset: usize,
    limit: usize,
) -> Result<DictionaryPage> {
    released(personal::personal_dictionary_entries_for_kind(
        &runtime_paths(options),
        main_dictionary(options),
        kind,
        offset,
        limit,
    ))
}

pub fn dictionary_table_entries(
    options: &EngineOptions,
    kind: DictionaryKind,
    query: &str,
    offset: usize,
    limit: usize,
) -> Result<DictionaryTablePage> {
    released(bundled::dictionary_table_entries(
        &runtime_paths(options),
        kind,
        query,
        offset,
        limit,
    ))
}

pub fn dictionary_edit_bundled(
    options: &EngineOptions,
    previous: &DictionaryEntry,
    weight: Option<i64>,
    request_id: &str,
) -> Result<()> {
    released(bundled::edit_bundled_dictionary_entry_with(
        &runtime_paths(options),
        main_dictionary(options),
        previous,
        weight,
        request_id,
    ))
}

pub fn dictionary_validate(entry: &DictionaryEntry) -> Result<DictionaryEntry> {
    personal::validate_personal_dictionary_entry(entry)
}

/// `dictionary_validate` for the row an edit or removal starts from: a listed row keeps whatever weight learning gave it, so only the floor applies to its weight.
pub fn dictionary_validate_previous(entry: &DictionaryEntry) -> Result<DictionaryEntry> {
    personal::validate_existing_entry(entry)
}

pub fn dictionary_edit(
    options: &EngineOptions,
    previous: Option<&DictionaryEntry>,
    replacement: Option<&DictionaryEntry>,
    request_id: &str,
) -> Result<()> {
    released(personal::edit_personal_dictionary_with(
        &runtime_paths(options),
        main_dictionary(options),
        previous,
        replacement,
        request_id,
    ))
}

/// 有界批量编辑的参数;维护租约由 host-api 持有。
pub use personal::{
    PersonalDictionaryApplied as DictionaryApplied, PersonalDictionaryEdit as DictionaryEdit,
};

/// 一次事务提交一批编辑,逐项结果只在外层提交成功后返回。
pub fn dictionary_edit_batch(
    options: &EngineOptions,
    edits: &[DictionaryEdit<'_>],
) -> Result<Vec<Result<DictionaryApplied>>> {
    released(personal::edit_personal_dictionary_batch_with(
        &runtime_paths(options),
        main_dictionary(options),
        edits,
    ))
}

/// 集合直达导入的专用批量入口,使用更大的有界事务;普通编辑仍限制为 128 项。
pub fn dictionary_edit_collection_batch(
    options: &EngineOptions,
    edits: &[DictionaryEdit<'_>],
) -> Result<Vec<Result<DictionaryApplied>>> {
    released(personal::edit_personal_dictionary_collection_batch_with(
        &runtime_paths(options),
        main_dictionary(options),
        edits,
    ))
}

/// Refuses to reset the packaged bundle in place and needs both packaged dictionaries. 方案集合不读 `msime-pinyin.db` 时只需要、也只换回 `msime-english.db`。
pub fn reset_learned_data(options: &EngineOptions) -> Result<()> {
    released(reset::reset_learned_data_with(
        &runtime_paths(options),
        main_dictionary(options),
    ))
}

/// `(applied, skipped, failed, error)`; never errors.
pub fn replay_user_dictionary(
    user_db_path: &str,
    main_db_path: &str,
    english_db_path: &str,
) -> (i32, i32, i32, String) {
    let result = released(replay::replay(
        Path::new(user_db_path),
        Path::new(main_db_path),
        Path::new(english_db_path),
    ));
    (result.applied, result.skipped, result.failed, result.error)
}

/// `INVALID_SNAPSHOT_RECORD_LIMIT` for a zero limit; returns `options` with the four paths of the staged generation.
pub fn stage_dictionary_state(
    options: &EngineOptions,
    generation: &str,
    content_id: &str,
    maximum_records: usize,
    records: impl Iterator<Item = std::result::Result<DictionaryStateRecord, SnapshotReadError>>,
) -> Result<EngineOptions> {
    if maximum_records == 0 {
        return Err(EngineError::invalid(
            diagnostics::INVALID_SNAPSHOT_RECORD_LIMIT,
        ));
    }
    // A transport failure reaches the stager as an error, never as the end of the stream: only verified EOF may finish a generation (bridge.cpp:473-476).
    let mut records = records
        .map(|record| record.map_err(|_| EngineError::failed(diagnostics::SNAPSHOT_STREAM_FAILED)));
    let paths = released(state::stage_dictionary_state_for(
        Path::new(&options.resources),
        Path::new(generation),
        content_id,
        &mut records,
        maximum_records,
        options.enabled_schemes,
    ))?;
    let text = |path: &Path| path.to_string_lossy().into_owned();
    Ok(EngineOptions {
        resources: text(&paths.resources),
        user_data: text(&paths.user_data),
        cache: text(&paths.cache),
        dictionaries: text(&paths.dictionaries),
        ..options.clone()
    })
}

pub fn dictionary_state_revision(options: &EngineOptions) -> Result<String> {
    released(state::dictionary_state_revision(&runtime_paths(options)))
}

/// 与 [`dictionary_state_revision`] 相同,但日志文件自上次算过以来没变时直接返回上次的结果(见 `state::cached_dictionary_state_revision`)。只给空闲时发布本机词库版本这类预览用;准备、激活快照前的比对仍用不走缓存的 [`dictionary_state_revision`]。
pub fn cached_dictionary_state_revision(options: &EngineOptions) -> Result<String> {
    released(state::cached_dictionary_state_revision(&runtime_paths(
        options,
    )))
}

/// 在一个只读事务里依次读出日志的全部操作行(含学习调权和删除记录)、固定位置和选词计数,交给 `emit`;`emit` 返回假时停下并报 `INVALID_DICTIONARY_STATE`。本地备份导出输入记录用它。
pub fn stream_dictionary_state(
    options: &EngineOptions,
    emit: &mut dyn FnMut(&DictionaryStateRecord) -> bool,
) -> Result<()> {
    released(state::stream_dictionary_state(
        &runtime_paths(options),
        emit,
    ))
}

/// 把一串词库状态记录合并进本机正在用的日志和词库,本机已有的保留本机、选词计数取大(规则见 `state::merge_dictionary_state`)。读流失败与 [`stage_dictionary_state`] 一样报 `SNAPSHOT_STREAM_FAILED`,整体回滚。调用方必须持有独占维护权。
pub fn merge_dictionary_state(
    options: &EngineOptions,
    maximum_records: usize,
    records: impl Iterator<Item = std::result::Result<DictionaryStateRecord, SnapshotReadError>>,
) -> Result<DictionaryStateMerge> {
    let mut records = records
        .map(|record| record.map_err(|_| EngineError::failed(diagnostics::SNAPSHOT_STREAM_FAILED)));
    released(state::merge_dictionary_state(
        &runtime_paths(options),
        main_dictionary(options),
        &mut records,
        maximum_records,
    ))
}

/// 在一个只读事务里依次读出日志里的六张输入习惯表(整句联想、选词对、拼写纠错、自动纠错抑制、置顶),交给 `emit`;`emit` 返回假时停下并报 `INVALID_DICTIONARY_STATE`。本地备份导出输入习惯用它,规则见 `habits::stream_learning_habits`。
pub fn stream_learning_habits(
    options: &EngineOptions,
    emit: &mut dyn FnMut(&LearningHabit) -> bool,
) -> Result<()> {
    released(habits::stream_learning_habits(
        &runtime_paths(options),
        emit,
    ))
}

/// 本机输入习惯的行数,只读。
pub fn count_learning_habits(options: &EngineOptions) -> Result<usize> {
    released(habits::count_learning_habits(&runtime_paths(options)))
}

/// 把一串输入习惯合并进本机正在用的日志,计数取大、置顶保留本机,写完压回各表的上限(规则见 `habits::merge_learning_habits`)。读流失败报 `SNAPSHOT_STREAM_FAILED`,整体回滚。调用方必须持有独占维护权。
pub fn merge_learning_habits(
    options: &EngineOptions,
    maximum_records: usize,
    records: impl Iterator<Item = std::result::Result<LearningHabit, SnapshotReadError>>,
) -> Result<LearningHabitsMerge> {
    let mut records = records
        .map(|record| record.map_err(|_| EngineError::failed(diagnostics::SNAPSHOT_STREAM_FAILED)));
    released(habits::merge_learning_habits(
        &runtime_paths(options),
        &mut records,
        maximum_records,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::SystemTime;

    /// 刚写过的日志没有可用的来源戳,落定以后才有;之后没有写入就一直相同,空的 `-wal` 出现也不算改动,写一次就变。
    #[test]
    fn the_entries_source_is_withheld_until_the_journal_settles() {
        let directory = tempfile::tempdir().unwrap();
        let journal = directory.path().join("user.db");
        let later = || SystemTime::now() + crate::user_dictionary::state::REVISION_SETTLE * 30;

        // 没有日志:没什么要落定的,戳是确定的。
        assert_eq!(
            entries_source(&journal, true, || -> SystemTime {
                panic!("nothing to settle, the clock must not be read")
            })
            .as_deref(),
            Some("1|-|-")
        );

        std::fs::write(&journal, b"first").unwrap();
        assert_eq!(entries_source(&journal, true, SystemTime::now), None);
        let settled = entries_source(&journal, true, later).unwrap();
        assert_eq!(
            entries_source(&journal, true, later).as_deref(),
            Some(settled.as_str())
        );

        let mut wal = journal.as_os_str().to_owned();
        wal.push("-wal");
        std::fs::write(&wal, b"").unwrap();
        assert_eq!(
            entries_source(&journal, true, later).as_deref(),
            Some(settled.as_str())
        );

        std::fs::write(&wal, b"frame").unwrap();
        assert_eq!(entries_source(&journal, true, SystemTime::now), None);
        let written = entries_source(&journal, true, later).unwrap();
        assert_ne!(written, settled);
    }
}