Files
msime-windows/engine/user_dictionary/user_dictionary_journal.cpp
T
fanlusky a7779d2b05 refactor(engine): 将 engine 从 submodule 内嵌为仓内一等组件
`vendor/MetasequoiaImeEngine` submodule 改为 `engine/` 目录,导入的是
product-lock.json 原本钉住的提交 c810d201,逐文件核对过 blob,因此这是一次
搬迁而不是引擎升级。引擎自己的嵌套 submodule(googlepinyinime-rev、utfcpp、
miniaudio)按各自 gitlink 一并展开,来源与提交见 engine/UPSTREAM.md。

Windows 专门化裁掉了不属于本产品的部分:whisper.cpp 与本地推理 provider、
macOS 示例宿主、整个 dictionary/ 建库流水线、上游 CI 与 release-please 配置。
词库仍按 product-lock.json 在构建时取发布产物。

引擎不再进 product-lock.json:没有 gitlink 就没有可比对的东西,锁里写一个上游
commit 等于宣称这棵树仍等于上游,而第一个 Windows 专门化改动落地时这句话就假了。
verify-contracts / verify-checkout 随之移除,verify-published 保留。

CI 与工具链:引擎源码并入根 scripts/format.sh(删掉引擎自己的那份,避免两套排除
列表分叉);引擎 voice.yml 里的 silero_vad.onnx 摘要校验移到本仓 lock job;
engine-bump-triage 工作流与其测试删除;gitsubmodule 的 dependabot 频率由 daily
改为 monthly。

README、README.en、AGENTS.md、CONTRIBUTING、docs/product-release.md 及各子目录
文档同步说明新的位置与边界。
2026-09-12 03:40:48 +08:00

1430 lines
62 KiB
C++

#include "../contracts/assets/assets.h"
#include "../contracts/dictionary/format.h"
#include "user_dictionary_journal.h"
#include <metasequoia/personal_dictionary.h>
#include <sqlite3.h>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <vector>
#include <algorithm>
#include <limits>
#include <mutex>
#include <numeric>
#include "../core/data_path.h"
#include "../english/english_dictionary.h"
namespace user_dictionary
{
namespace
{
struct DbCloser
{
void operator()(sqlite3 *db) const
{
if (db != nullptr)
sqlite3_close(db);
}
};
using Db = std::unique_ptr<sqlite3, DbCloser>;
struct StmtCloser
{
void operator()(sqlite3_stmt *stmt) const
{
if (stmt != nullptr)
sqlite3_finalize(stmt);
}
};
using Stmt = std::unique_ptr<sqlite3_stmt, StmtCloser>;
const char *kind_name(DictionaryKind kind)
{
switch (kind)
{
case DictionaryKind::Pinyin:
return "pinyin";
case DictionaryKind::Wubi:
return "wubi";
case DictionaryKind::QuickPhrase:
return "quick";
case DictionaryKind::English:
return "english";
}
return "";
}
Db open_database(const std::string &path, int flags)
{
sqlite3 *raw = nullptr;
if (sqlite3_open_v2(path.c_str(), &raw, flags | SQLITE_OPEN_FULLMUTEX, nullptr) != SQLITE_OK)
{
if (raw != nullptr)
sqlite3_close(raw);
return {};
}
sqlite3_busy_timeout(raw, 5000);
return Db(raw);
}
Stmt prepare(sqlite3 *db, const std::string &sql)
{
sqlite3_stmt *raw = nullptr;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &raw, nullptr) != SQLITE_OK)
return {};
return Stmt(raw);
}
bool execute_sql(sqlite3 *db, const char *sql)
{
return db != nullptr && sqlite3_exec(db, sql, nullptr, nullptr, nullptr) == SQLITE_OK;
}
bool bind_text(sqlite3_stmt *stmt, int index, const std::string &value)
{
return sqlite3_bind_text(stmt, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
}
Stmt prepare_upsert_journal(sqlite3 *db)
{
return prepare(db, "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display)"
" VALUES(?1,?2,?3,'upsert',?4,?5)"
" ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='upsert',weight=excluded.weight,"
" display=excluded.display,updated_at=unixepoch()");
}
bool write_upsert_journal(sqlite3_stmt *stmt, DictionaryKind kind, const std::string &key, const std::string &value,
std::int64_t weight, const std::string &display = {})
{
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
return bind_text(stmt, 1, kind_name(kind)) && bind_text(stmt, 2, key) && bind_text(stmt, 3, value) &&
sqlite3_bind_int64(stmt, 4, weight) == SQLITE_OK && bind_text(stmt, 5, display) &&
sqlite3_step(stmt) == SQLITE_DONE;
}
bool ensure_schema(sqlite3 *db)
{
constexpr const char *sql =
"CREATE TABLE IF NOT EXISTS user_dictionary_operations("
"dictionary TEXT NOT NULL,"
"key TEXT NOT NULL,"
"value TEXT NOT NULL,"
"operation TEXT NOT NULL CHECK(operation IN ('upsert','delete')),"
"weight INTEGER NOT NULL DEFAULT 0,"
"display TEXT NOT NULL DEFAULT '',"
"user_inserted INTEGER NOT NULL DEFAULT 0,"
"updated_at INTEGER NOT NULL DEFAULT(unixepoch()),"
"PRIMARY KEY(dictionary,key,value));"
"CREATE TABLE IF NOT EXISTS personal_dictionary_receipts( request_id TEXT PRIMARY KEY, payload TEXT NOT NULL);"
"CREATE TABLE IF NOT EXISTS candidate_selection_state("
"context_key TEXT NOT NULL,entry_key TEXT NOT NULL,value TEXT NOT NULL,"
"selection_count INTEGER NOT NULL DEFAULT 0,"
"PRIMARY KEY(context_key,entry_key,value));"
"CREATE TABLE IF NOT EXISTS fixed_candidate_positions("
"context_key TEXT NOT NULL,entry_key TEXT NOT NULL,value TEXT NOT NULL,"
"position INTEGER NOT NULL CHECK(position BETWEEN 1 AND 5),"
"PRIMARY KEY(context_key,entry_key,value),UNIQUE(context_key,position));";
if (sqlite3_exec(db, sql, nullptr, nullptr, nullptr) != SQLITE_OK)
return false;
bool has_user_inserted = false;
auto columns = prepare(db, "PRAGMA table_info(user_dictionary_operations)");
if (!columns)
return false;
while (sqlite3_step(columns.get()) == SQLITE_ROW)
{
const unsigned char *name = sqlite3_column_text(columns.get(), 1);
if (name != nullptr && std::string(reinterpret_cast<const char *>(name)) == "user_inserted")
{
has_user_inserted = true;
break;
}
}
columns.reset();
if (!has_user_inserted && sqlite3_exec(db,
"ALTER TABLE user_dictionary_operations "
"ADD COLUMN user_inserted INTEGER NOT NULL DEFAULT 0",
nullptr, nullptr, nullptr) != SQLITE_OK)
return false;
return sqlite3_exec(db, "PRAGMA user_version=3", nullptr, nullptr, nullptr) == SQLITE_OK;
}
std::vector<std::string> pinyin_segments(const std::string &key)
{
std::vector<std::string> segments;
size_t start = 0;
while (start <= key.size())
{
const size_t delimiter = key.find('\'', start);
const std::string segment =
key.substr(start, delimiter == std::string::npos ? std::string::npos : delimiter - start);
if (segment.empty())
return {};
segments.push_back(segment);
if (delimiter == std::string::npos)
break;
start = delimiter + 1;
}
return segments;
}
std::string pinyin_table(const std::string &key)
{
const auto segments = pinyin_segments(key);
if (segments.empty())
return {};
return metasequoia::dictionary_format::quanpin_table(segments.size(), segments.front().front());
}
constexpr std::int64_t kManagedWeightCeiling = 100000000LL;
constexpr std::int64_t kManagedWeightFloor = 1LL;
constexpr std::int64_t kRebalanceGap = 1000LL;
constexpr size_t kRebalanceCount = 16;
std::int64_t clamp_managed_weight(std::int64_t weight)
{
if (weight < kManagedWeightFloor)
return kManagedWeightFloor;
if (weight > kManagedWeightCeiling)
return kManagedWeightCeiling;
return weight;
}
size_t utf8_char_count(const std::string &text)
{
return static_cast<size_t>(
std::count_if(text.begin(), text.end(), [](unsigned char ch) { return (ch & 0xC0) != 0x80; }));
}
std::string candidate_dictionary_key(const WordItem &item, const std::string &context_key)
{
if (!item.canonical_pinyin.empty())
return item.canonical_pinyin;
std::string item_key = item.pinyin;
const auto context_segments = pinyin_segments(context_key);
if (context_segments.size() <= 1)
return item_key;
auto item_segments = context_segments;
const size_t char_count = utf8_char_count(item.word);
if (char_count > 0 && item_segments.size() > char_count)
item_segments.resize(char_count);
item_key.clear();
for (size_t j = 0; j < item_segments.size(); ++j)
{
if (j)
item_key += '\'';
item_key += item_segments[j];
}
return item_key;
}
bool update_pinyin_weight(sqlite3 *main_db, sqlite3_stmt *journal_upsert, const std::string &key,
const std::string &value, std::int64_t weight)
{
weight = clamp_managed_weight(weight);
const std::string table = pinyin_table(key);
if (table.empty() || main_db == nullptr || journal_upsert == nullptr)
return false;
auto stmt = prepare(main_db, "UPDATE \"" + table + "\" SET weight=?1 WHERE key=?2 AND value=?3");
return stmt && sqlite3_bind_int64(stmt.get(), 1, weight) == SQLITE_OK && bind_text(stmt.get(), 2, key) &&
bind_text(stmt.get(), 3, value) && sqlite3_step(stmt.get()) == SQLITE_DONE &&
write_upsert_journal(journal_upsert, DictionaryKind::Pinyin, key, value, weight);
}
bool update_wubi_weight(sqlite3 *main_db, sqlite3_stmt *journal_upsert, const std::string &key,
const std::string &value, std::int64_t weight)
{
weight = clamp_managed_weight(weight);
if (main_db == nullptr || journal_upsert == nullptr || key.empty() || value.empty())
return false;
auto stmt = prepare(main_db, "UPDATE wubi86 SET weight=?1 WHERE key=?2 AND value=?3");
return stmt && sqlite3_bind_int64(stmt.get(), 1, weight) == SQLITE_OK && bind_text(stmt.get(), 2, key) &&
bind_text(stmt.get(), 3, value) && sqlite3_step(stmt.get()) == SQLITE_DONE && sqlite3_changes(main_db) > 0 &&
write_upsert_journal(journal_upsert, DictionaryKind::Wubi, key, value, weight);
}
bool update_ranked_weight(sqlite3 *main_db, sqlite3_stmt *journal_upsert, DictionaryKind kind, const std::string &key,
const std::string &value, std::int64_t weight)
{
if (kind == DictionaryKind::Wubi)
return update_wubi_weight(main_db, journal_upsert, key, value, weight);
return update_pinyin_weight(main_db, journal_upsert, key, value, weight);
}
size_t ranking_target(size_t rank, const std::string &mode, int linear_step, bool force_top)
{
if (force_top)
return 0;
if (mode == "halve")
return rank / 2;
if (mode == "linear")
return rank > static_cast<size_t>(linear_step) ? rank - static_cast<size_t>(linear_step) : 0;
if (mode == "promote")
return rank > 4 ? 4 : rank - 1;
return 0;
}
std::string jianpin(const std::vector<std::string> &segments)
{
std::string result;
result.reserve(segments.size());
for (const auto &segment : segments)
result.push_back(segment.front());
return result;
}
bool apply_pinyin(sqlite3 *db, const std::string &key, const std::string &value, const std::string &operation,
std::int64_t weight)
{
const auto segments = pinyin_segments(key);
if (segments.empty())
return false;
const std::string table = pinyin_table(key);
if (operation == "delete")
{
auto stmt = prepare(db, "DELETE FROM \"" + table + "\" WHERE key=?1 AND value=?2");
return stmt && bind_text(stmt.get(), 1, key) && bind_text(stmt.get(), 2, value) &&
sqlite3_step(stmt.get()) == SQLITE_DONE;
}
auto update = prepare(db, "UPDATE \"" + table + "\" SET jp=?1,weight=?2 WHERE key=?3 AND value=?4");
if (!update || !bind_text(update.get(), 1, jianpin(segments)) ||
sqlite3_bind_int64(update.get(), 2, weight) != SQLITE_OK || !bind_text(update.get(), 3, key) ||
!bind_text(update.get(), 4, value) || sqlite3_step(update.get()) != SQLITE_DONE)
return false;
if (sqlite3_changes(db) > 0)
return true;
auto insert = prepare(db, "INSERT INTO \"" + table + "\"(key,jp,value,weight) VALUES(?1,?2,?3,?4)");
return insert && bind_text(insert.get(), 1, key) && bind_text(insert.get(), 2, jianpin(segments)) &&
bind_text(insert.get(), 3, value) && sqlite3_bind_int64(insert.get(), 4, weight) == SQLITE_OK &&
sqlite3_step(insert.get()) == SQLITE_DONE;
}
bool apply_simple(sqlite3 *db, const std::string &table, const std::string &key_column, const std::string &value_column,
const std::string &key, const std::string &value, const std::string &operation, std::int64_t weight)
{
if (operation == "delete")
{
auto stmt = prepare(db, "DELETE FROM \"" + table + "\" WHERE \"" + key_column + "\"=?1 AND \"" + value_column +
"\"=?2");
return stmt && bind_text(stmt.get(), 1, key) && bind_text(stmt.get(), 2, value) &&
sqlite3_step(stmt.get()) == SQLITE_DONE;
}
auto update = prepare(db, "UPDATE \"" + table + "\" SET weight=?1 WHERE \"" + key_column + "\"=?2 AND \"" +
value_column + "\"=?3");
if (!update || sqlite3_bind_int64(update.get(), 1, weight) != SQLITE_OK || !bind_text(update.get(), 2, key) ||
!bind_text(update.get(), 3, value) || sqlite3_step(update.get()) != SQLITE_DONE)
return false;
if (sqlite3_changes(db) > 0)
return true;
auto insert = prepare(db, "INSERT INTO \"" + table + "\"(\"" + key_column + "\",\"" + value_column +
"\",weight) VALUES(?1,?2,?3)");
return insert && bind_text(insert.get(), 1, key) && bind_text(insert.get(), 2, value) &&
sqlite3_bind_int64(insert.get(), 3, weight) == SQLITE_OK && sqlite3_step(insert.get()) == SQLITE_DONE;
}
bool apply_english(sqlite3 *db, const std::string &key, const std::string &value, const std::string &operation,
std::int64_t weight, const std::string &display)
{
const std::string effective_display = display.empty() ? value : display;
if (operation == "delete")
{
auto stmt = prepare(db, "DELETE FROM replay_english.english_words WHERE word=?1 AND display=?2");
return stmt && bind_text(stmt.get(), 1, key) && bind_text(stmt.get(), 2, effective_display) &&
sqlite3_step(stmt.get()) == SQLITE_DONE;
}
auto upsert = prepare(db, "INSERT INTO replay_english.english_words(word,display,weight) VALUES(?1,?2,?3) "
"ON CONFLICT(word,display) DO UPDATE SET weight=excluded.weight");
return upsert && bind_text(upsert.get(), 1, key) && bind_text(upsert.get(), 2, effective_display) &&
sqlite3_bind_int64(upsert.get(), 3, weight) == SQLITE_OK && sqlite3_step(upsert.get()) == SQLITE_DONE;
}
} // namespace
std::string default_user_db_path()
{
return metasequoia::path_to_utf8(metasequoia::data_file_path(metasequoia::assets::user_journal));
}
namespace
{
// Opening the journal costs a file open plus a full ensure_schema() pass, and
// callers on the hot path pay it per keystroke: apply_fixed_positions runs while
// the candidate list for every input is being built. Measured on Windows with a
// busy disk, that open dominated the candidate build -- 90% of its time, with
// single calls reaching 737 ms -- which stalled the IME server's task thread long
// enough for the client's in-edit-session reply wait to time out and drop the
// commit key. Keep one connection per path alive instead; sqlite3 connections are
// opened with SQLITE_OPEN_FULLMUTEX, so sharing one across threads is safe.
struct CachedDatabase
{
std::string path;
std::shared_ptr<sqlite3> connection;
};
std::mutex &database_cache_mutex()
{
static std::mutex mutex;
return mutex;
}
CachedDatabase &default_database_cache()
{
static CachedDatabase cache;
return cache;
}
// The handle is shared rather than borrowed so that close_default_user_database()
// can drop the cache entry at any time without invalidating a connection an
// operation is still running on; the file is released once the last user is done.
std::shared_ptr<sqlite3> open_shared_database(const std::string &path)
{
Db opened = open_database(path, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
if (!opened || !ensure_schema(opened.get()))
return {};
return std::shared_ptr<sqlite3>(opened.release(), [](sqlite3 *db) { sqlite3_close(db); });
}
std::shared_ptr<sqlite3> acquire_database(const std::string &path)
{
// Only the default journal is worth caching, and only it is safe to cache:
// every other path belongs to an import tool or a test that works on a
// database and then deletes the directory holding it, which on Windows fails
// while a connection is still open. Those keep the original one-per-call
// connection, so nothing but the hot path changes behaviour.
if (path != default_user_db_path())
return open_shared_database(path);
const std::lock_guard<std::mutex> guard(database_cache_mutex());
auto &cache = default_database_cache();
if (cache.connection && cache.path == path)
return cache.connection;
auto connection = open_shared_database(path);
if (!connection)
return {};
// Assigning also releases a connection cached for an earlier data directory,
// so at most one journal file is ever held open.
cache.path = path;
cache.connection = connection;
return connection;
}
class UserDatabase
{
public:
explicit UserDatabase(const std::string &path) : db_(acquire_database(path))
{
}
// A caller that returns between BEGIN IMMEDIATE and COMMIT used to be rescued
// by its private connection closing, which rolls back implicitly. A cached
// connection outlives the call, so an abandoned transaction would keep its
// write lock and fail every later operation. End it here instead.
~UserDatabase()
{
if (db_ && sqlite3_get_autocommit(db_.get()) == 0)
sqlite3_exec(db_.get(), "ROLLBACK", nullptr, nullptr, nullptr);
}
UserDatabase(const UserDatabase &) = delete;
UserDatabase &operator=(const UserDatabase &) = delete;
explicit operator bool() const
{
return db_ != nullptr;
}
sqlite3 *get() const
{
return db_.get();
}
private:
std::shared_ptr<sqlite3> db_;
};
} // namespace
void close_default_user_database()
{
// Callers use this to let go of the journal before deleting or replacing the
// data directory; a connection left open would block that on Windows.
const std::lock_guard<std::mutex> guard(database_cache_mutex());
default_database_cache() = {};
}
bool ensure_user_database(const std::string &user_db_path)
{
UserDatabase db(user_db_path);
if (!db)
return false;
// Drop ranking rows left by the old rebalance staircase so installer replay
// cannot bury shipped frequencies such as 先/xian under negative weights.
sqlite3_exec(db.get(),
"DELETE FROM user_dictionary_operations "
"WHERE dictionary='pinyin' AND operation='upsert' AND weight < 1",
nullptr, nullptr, nullptr);
return true;
}
bool record_upsert(const std::string &user_db_path, DictionaryKind kind, const std::string &key,
const std::string &value, std::int64_t weight, const std::string &display)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt = prepare_upsert_journal(db.get());
return stmt && write_upsert_journal(stmt.get(), kind, key, value, weight, display);
}
bool record_user_insert(const std::string &user_db_path, DictionaryKind kind, const std::string &key,
const std::string &value, std::int64_t weight, const std::string &display)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt =
prepare(db.get(), "INSERT INTO user_dictionary_operations("
"dictionary,key,value,operation,weight,display,user_inserted)"
" VALUES(?1,?2,?3,'upsert',?4,?5,1)"
" ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='upsert',weight=excluded.weight,"
" display=excluded.display,user_inserted=1,updated_at=unixepoch()");
return stmt && bind_text(stmt.get(), 1, kind_name(kind)) && bind_text(stmt.get(), 2, key) &&
bind_text(stmt.get(), 3, value) && sqlite3_bind_int64(stmt.get(), 4, weight) == SQLITE_OK &&
bind_text(stmt.get(), 5, display) && sqlite3_step(stmt.get()) == SQLITE_DONE;
}
bool record_delete(const std::string &user_db_path, DictionaryKind kind, const std::string &key,
const std::string &value)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt =
prepare(db.get(), "INSERT INTO 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()");
return stmt && bind_text(stmt.get(), 1, kind_name(kind)) && bind_text(stmt.get(), 2, key) &&
bind_text(stmt.get(), 3, value) && sqlite3_step(stmt.get()) == SQLITE_DONE;
}
bool is_user_inserted(const std::string &user_db_path, DictionaryKind kind, const std::string &key,
const std::string &value)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt = prepare(db.get(), "SELECT user_inserted FROM user_dictionary_operations "
"WHERE dictionary=?1 AND key=?2 AND value=?3 LIMIT 1");
return stmt && bind_text(stmt.get(), 1, kind_name(kind)) && bind_text(stmt.get(), 2, key) &&
bind_text(stmt.get(), 3, value) && sqlite3_step(stmt.get()) == SQLITE_ROW &&
sqlite3_column_int(stmt.get(), 0) != 0;
}
bool record_pinyin_upsert_from_database(const std::string &main_db_path, const std::string &key,
const std::string &value, const std::string &user_db_path)
{
auto db = open_database(main_db_path, SQLITE_OPEN_READONLY);
const std::string table = pinyin_table(key);
if (!db || table.empty())
return false;
auto stmt = prepare(db.get(), "SELECT weight FROM \"" + table + "\" WHERE key=?1 AND value=?2 LIMIT 1");
if (!stmt || !bind_text(stmt.get(), 1, key) || !bind_text(stmt.get(), 2, value) ||
sqlite3_step(stmt.get()) != SQLITE_ROW)
return false;
return record_upsert(user_db_path, DictionaryKind::Pinyin, key, value, sqlite3_column_int64(stmt.get(), 0));
}
bool set_fixed_position(const std::string &user_db_path, const std::string &context_key, const std::string &entry_key,
const std::string &value, int position)
{
if (position < 1 || position > 5 || context_key.empty() || entry_key.empty() || value.empty())
return false;
UserDatabase db(user_db_path);
if (!db)
return false;
sqlite3_exec(db.get(), "BEGIN IMMEDIATE", nullptr, nullptr, nullptr);
auto clear_slot = prepare(db.get(), "DELETE FROM fixed_candidate_positions WHERE context_key=?1 AND position=?2");
auto upsert = prepare(
db.get(), "INSERT INTO fixed_candidate_positions(context_key,entry_key,value,position) VALUES(?1,?2,?3,?4)"
" ON CONFLICT(context_key,entry_key,value) DO UPDATE SET position=excluded.position");
const bool ok = clear_slot && upsert && bind_text(clear_slot.get(), 1, context_key) &&
sqlite3_bind_int(clear_slot.get(), 2, position) == SQLITE_OK &&
sqlite3_step(clear_slot.get()) == SQLITE_DONE && bind_text(upsert.get(), 1, context_key) &&
bind_text(upsert.get(), 2, entry_key) && bind_text(upsert.get(), 3, value) &&
sqlite3_bind_int(upsert.get(), 4, position) == SQLITE_OK &&
sqlite3_step(upsert.get()) == SQLITE_DONE;
sqlite3_exec(db.get(), ok ? "COMMIT" : "ROLLBACK", nullptr, nullptr, nullptr);
return ok;
}
bool adjust_english_candidate_ranking(const std::string &english_db_path, const std::string &user_db_path,
const std::string &context_key, const std::vector<WordItem> &ordered_candidates,
const std::string &entry_key, const std::string &value, const std::string &mode,
int linear_step, int trigger_count, bool force_top, bool *ranking_changed)
{
if (ranking_changed)
*ranking_changed = false;
if (entry_key.empty() || value.empty() || ordered_candidates.empty() ||
is_fixed(user_db_path, context_key, entry_key, value))
return false;
if (!force_top && mode == "disabled")
return true;
UserDatabase user_db(user_db_path);
if (!user_db)
return false;
auto journal = prepare_upsert_journal(user_db.get());
if (!journal)
return false;
if (!execute_sql(user_db.get(), "BEGIN IMMEDIATE"))
return false;
const auto rollback_user = [&]() { (void)execute_sql(user_db.get(), "ROLLBACK"); };
trigger_count = (std::max)(1, (std::min)(10, trigger_count));
if (!force_top)
{
auto counter = prepare(
user_db.get(),
"INSERT INTO candidate_selection_state(context_key,entry_key,value,selection_count) VALUES(?1,?2,?3,1)"
" ON CONFLICT(context_key,entry_key,value) DO UPDATE SET selection_count=selection_count+1"
" RETURNING selection_count");
if (!counter || !bind_text(counter.get(), 1, context_key) || !bind_text(counter.get(), 2, entry_key) ||
!bind_text(counter.get(), 3, value) || sqlite3_step(counter.get()) != SQLITE_ROW)
{
rollback_user();
return false;
}
if (sqlite3_column_int(counter.get(), 0) < trigger_count)
{
counter.reset();
const bool committed = execute_sql(user_db.get(), "COMMIT");
if (!committed)
rollback_user();
return committed;
}
}
const auto selected = std::find_if(ordered_candidates.begin(), ordered_candidates.end(), [&](const WordItem &item) {
return item.source == CandidateSource::EnglishDictionary && item.pinyin == entry_key && item.word == value;
});
if (selected == ordered_candidates.end())
{
rollback_user();
return false;
}
const size_t rank = static_cast<size_t>(selected - ordered_candidates.begin());
if (rank == 0)
{
const bool committed = execute_sql(user_db.get(), "COMMIT");
if (!committed)
rollback_user();
return committed;
}
size_t target = 0;
if (!force_top && mode == "halve")
target = rank / 2;
else if (!force_top && mode == "linear")
target = rank > static_cast<size_t>((std::max)(1, linear_step))
? rank - static_cast<size_t>((std::max)(1, linear_step))
: 0;
else if (!force_top && mode == "promote")
target = rank > 4 ? 4 : rank - 1;
const std::int64_t maximum_weight =
std::accumulate(ordered_candidates.begin(), ordered_candidates.end(), std::int64_t{0},
[](std::int64_t maximum, const WordItem &item) { return (std::max)(maximum, item.weight); });
const std::int64_t new_weight =
target == 0 ? (std::max)(maximum_weight, selected->weight) + 1000 : ordered_candidates[target - 1].weight + 1;
auto english_db = open_database(english_db_path, SQLITE_OPEN_READWRITE);
auto update = english_db
? prepare(english_db.get(), "UPDATE english_words SET weight=?1 WHERE word=?2 AND display=?3")
: Stmt{};
auto existing =
english_db ? prepare(english_db.get(), "SELECT 1 FROM english_words WHERE word=?1 AND display=?2") : Stmt{};
// Journal the new weight before english.db carries it. The journal upsert is idempotent and replay reapplies it, so
// a journal row one step ahead of the dictionary is recoverable, while a boosted weight that never reached the
// journal is silently reverted at the next dictionary upgrade. The row must already exist, otherwise replay would
// insert a word the user never learned.
if (!update || !existing || !bind_text(existing.get(), 1, entry_key) || !bind_text(existing.get(), 2, value) ||
sqlite3_step(existing.get()) != SQLITE_ROW ||
!write_upsert_journal(journal.get(), DictionaryKind::English, entry_key, value, new_weight, value))
{
rollback_user();
return false;
}
existing.reset();
auto reset = prepare(user_db.get(),
"DELETE FROM candidate_selection_state WHERE context_key=?1 AND entry_key=?2 AND value=?3");
const bool reset_ok = reset && bind_text(reset.get(), 1, context_key) && bind_text(reset.get(), 2, entry_key) &&
bind_text(reset.get(), 3, value) && sqlite3_step(reset.get()) == SQLITE_DONE;
reset.reset();
if (!reset_ok || !execute_sql(user_db.get(), "COMMIT"))
{
rollback_user();
return false;
}
const bool ok = sqlite3_bind_int64(update.get(), 1, new_weight) == SQLITE_OK &&
bind_text(update.get(), 2, entry_key) && bind_text(update.get(), 3, value) &&
sqlite3_step(update.get()) == SQLITE_DONE && sqlite3_changes(english_db.get()) > 0;
if (ok && ranking_changed)
*ranking_changed = true;
return ok;
}
bool delete_dictionary_candidate(const std::string &dictionary_db_path, const std::string &user_db_path,
DictionaryKind kind, const std::string &entry_key, const std::string &value)
{
if (entry_key.empty() || value.empty())
return false;
std::string table;
switch (kind)
{
case DictionaryKind::Pinyin:
table = pinyin_table(entry_key);
break;
case DictionaryKind::Wubi:
table = "wubi86";
break;
case DictionaryKind::English:
table = "english_words";
break;
default:
return false;
}
if (table.empty())
return false;
// Initialize the journal before attaching it; every operation owns its connections.
if (!ensure_user_database(user_db_path))
return false;
auto database = open_database(dictionary_db_path, SQLITE_OPEN_READWRITE);
auto attach = database ? prepare(database.get(), "ATTACH DATABASE ?1 AS candidate_journal") : Stmt{};
if (!attach || !bind_text(attach.get(), 1, user_db_path) || sqlite3_step(attach.get()) != SQLITE_DONE)
return false;
attach.reset();
if (!execute_sql(database.get(), "BEGIN IMMEDIATE"))
return false;
const auto rollback = [&]() {
(void)execute_sql(database.get(), "ROLLBACK");
return false;
};
const std::string columns = kind == DictionaryKind::English ? "word=?1 AND display=?2" : "key=?1 AND value=?2";
auto remove = prepare(database.get(), "DELETE FROM main.\"" + table + "\" WHERE " + columns);
if (!remove || !bind_text(remove.get(), 1, entry_key) || !bind_text(remove.get(), 2, value) ||
sqlite3_step(remove.get()) != SQLITE_DONE || sqlite3_changes(database.get()) == 0)
return rollback();
remove.reset();
auto journal = prepare(database.get(),
"INSERT INTO candidate_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()");
if (!journal || !bind_text(journal.get(), 1, kind_name(kind)) || !bind_text(journal.get(), 2, entry_key) ||
!bind_text(journal.get(), 3, value) || sqlite3_step(journal.get()) != SQLITE_DONE)
return rollback();
journal.reset();
if (!execute_sql(database.get(), "COMMIT"))
return rollback();
return true;
}
bool delete_english_candidate(const std::string &english_db_path, const std::string &user_db_path,
const std::string &entry_key, const std::string &value)
{
return delete_dictionary_candidate(english_db_path, user_db_path, DictionaryKind::English, entry_key, value);
}
bool learn_entered_english_word(const std::string &english_db_path, const std::string &user_db_path,
const std::string &display, std::int64_t weight)
{
constexpr size_t kMaximumLearnedEnglishWordLength = 64;
if (display.empty() || display.size() > kMaximumLearnedEnglishWordLength ||
!std::all_of(display.begin(), display.end(),
[](unsigned char ch) { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); }))
return false;
std::string word = display;
std::transform(word.begin(), word.end(), word.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
weight = (std::max)(std::int64_t{0}, weight);
if (!EnglishDictionary::ensure_schema(english_db_path))
return false;
auto database = open_database(english_db_path, SQLITE_OPEN_READWRITE);
auto insert =
database ? prepare(database.get(), "INSERT OR IGNORE INTO english_words(word,display,weight) VALUES(?1,?2,?3)")
: Stmt{};
if (!insert || !bind_text(insert.get(), 1, word) || !bind_text(insert.get(), 2, display) ||
sqlite3_bind_int64(insert.get(), 3, weight) != SQLITE_OK || sqlite3_step(insert.get()) != SQLITE_DONE)
return false;
if (sqlite3_changes(database.get()) == 0)
return true;
if (record_user_insert(user_db_path, DictionaryKind::English, word, display, weight, display))
return true;
// Do not leave an entry that cannot survive a dictionary upgrade.
auto rollback_insert = prepare(database.get(), "DELETE FROM english_words WHERE word=?1 AND display=?2");
if (rollback_insert && bind_text(rollback_insert.get(), 1, word) && bind_text(rollback_insert.get(), 2, display))
(void)sqlite3_step(rollback_insert.get());
return false;
}
bool clear_fixed_position(const std::string &user_db_path, const std::string &context_key, const std::string &entry_key,
const std::string &value)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt =
prepare(db.get(), "DELETE FROM fixed_candidate_positions WHERE context_key=?1 AND entry_key=?2 AND value=?3");
return stmt && bind_text(stmt.get(), 1, context_key) && bind_text(stmt.get(), 2, entry_key) &&
bind_text(stmt.get(), 3, value) && sqlite3_step(stmt.get()) == SQLITE_DONE;
}
bool is_fixed(const std::string &user_db_path, const std::string &context_key, const std::string &entry_key,
const std::string &value)
{
UserDatabase db(user_db_path);
if (!db)
return false;
auto stmt =
prepare(db.get(), "SELECT 1 FROM fixed_candidate_positions WHERE context_key=?1 AND entry_key=?2 AND value=?3");
return stmt && bind_text(stmt.get(), 1, context_key) && bind_text(stmt.get(), 2, entry_key) &&
bind_text(stmt.get(), 3, value) && sqlite3_step(stmt.get()) == SQLITE_ROW;
}
void apply_fixed_positions(
const std::string &user_db_path, const std::string &context_key, std::vector<WordItem> &candidates,
bool include_missing,
const std::function<std::optional<WordItem>(const std::string &, const std::string &)> &find_candidate,
bool keep_dynamic_candidate_positions)
{
if (context_key.empty() || candidates.empty())
return;
UserDatabase user_db(user_db_path);
if (!user_db)
return;
auto fixed = prepare(
user_db.get(),
"SELECT entry_key,value,position FROM fixed_candidate_positions WHERE context_key=?1 ORDER BY position");
if (!fixed || !bind_text(fixed.get(), 1, context_key))
return;
std::vector<WordItem> dynamic_candidates;
if (!keep_dynamic_candidate_positions)
{
candidates.erase(std::remove_if(candidates.begin(), candidates.end(),
[&](const WordItem &item) {
if (item.source != CandidateSource::CloudSuggestion &&
item.source != CandidateSource::AiSuggestion)
return false;
dynamic_candidates.push_back(item);
return true;
}),
candidates.end());
}
struct Fixed
{
WordItem item;
int position;
};
std::vector<Fixed> rows;
while (sqlite3_step(fixed.get()) == SQLITE_ROW)
{
const std::string key = reinterpret_cast<const char *>(sqlite3_column_text(fixed.get(), 0));
const std::string value = reinterpret_cast<const char *>(sqlite3_column_text(fixed.get(), 1));
const int position = sqlite3_column_int(fixed.get(), 2);
const auto existing = std::find_if(candidates.begin(), candidates.end(),
[&](const WordItem &item) { return item.word == value; });
if (existing != candidates.end())
{
WordItem item = *existing;
item.fixed_position = position;
rows.push_back({std::move(item), position});
}
else if (include_missing && find_candidate)
{
if (auto found = find_candidate(key, value))
{
WordItem item = std::move(*found);
item.fixed_position = position;
rows.push_back({std::move(item), position});
}
}
}
for (const auto &row : rows)
candidates.erase(std::remove_if(candidates.begin(), candidates.end(),
[&](const WordItem &item) { return item.word == row.item.word; }),
candidates.end());
for (const auto &row : rows)
{
const size_t index = (std::min)(static_cast<size_t>(row.position - 1), candidates.size());
candidates.insert(candidates.begin() + index, row.item);
}
for (const auto &item : dynamic_candidates)
{
const size_t preferred_index = item.source == CandidateSource::CloudSuggestion ? 1 : 2;
const size_t index = (std::min)(preferred_index, candidates.size());
candidates.insert(candidates.begin() + index, item);
}
}
bool adjust_candidate_ranking(const std::string &main_db_path, const std::string &user_db_path,
const std::string &context_key, const std::vector<WordItem> &ordered_candidates,
const std::string &entry_key, const std::string &value, const std::string &mode,
int linear_step, int trigger_count, bool force_top, bool *ranking_changed,
DictionaryKind kind)
{
if (ranking_changed)
*ranking_changed = false;
if (entry_key.empty() || value.empty() || ordered_candidates.empty() ||
is_fixed(user_db_path, context_key, entry_key, value))
return false;
if (!force_top && mode == "disabled")
return true;
UserDatabase user_db(user_db_path);
if (!user_db)
return false;
auto journal_upsert = prepare_upsert_journal(user_db.get());
if (!journal_upsert)
return false;
if (!execute_sql(user_db.get(), "BEGIN IMMEDIATE"))
return false;
const auto rollback_user = [&]() { (void)execute_sql(user_db.get(), "ROLLBACK"); };
trigger_count = (std::max)(1, (std::min)(10, trigger_count));
if (!force_top)
{
auto counter = prepare(
user_db.get(),
"INSERT INTO candidate_selection_state(context_key,entry_key,value,selection_count) VALUES(?1,?2,?3,1)"
" ON CONFLICT(context_key,entry_key,value) DO UPDATE SET selection_count=selection_count+1"
" RETURNING selection_count");
if (!counter || !bind_text(counter.get(), 1, context_key) || !bind_text(counter.get(), 2, entry_key) ||
!bind_text(counter.get(), 3, value) || sqlite3_step(counter.get()) != SQLITE_ROW)
{
rollback_user();
return false;
}
if (sqlite3_column_int(counter.get(), 0) < trigger_count)
{
counter.reset();
const bool committed = execute_sql(user_db.get(), "COMMIT");
if (!committed)
rollback_user();
return committed;
}
}
// Single-letter and jianpin contexts show several entry keys in one list
// (context "y" mixes 一/yi with 有/you). Rank against the list the user
// actually sees; ranking against only the rows sharing entry_key makes the
// selection permanently rank 0 and no weight is ever written. Writes still
// go to entry_key rows only, so a rebalance can never land on another key's
// row the way it did in #36.
std::vector<WordItem> database_candidates;
std::vector<bool> owns_entry_key;
for (const auto &item : ordered_candidates)
{
if (item.source != CandidateSource::Database && item.source != CandidateSource::UserDatabase)
continue;
const std::string item_key =
kind == DictionaryKind::Wubi ? item.pinyin : candidate_dictionary_key(item, context_key);
database_candidates.push_back(item);
owns_entry_key.push_back(item_key == entry_key);
}
const auto selected = std::find_if(database_candidates.begin(), database_candidates.end(),
[&](const WordItem &item) { return item.word == value; });
if (selected == database_candidates.end())
{
rollback_user();
return false;
}
const size_t rank = static_cast<size_t>(selected - database_candidates.begin());
if (rank == 0)
{
auto reset = prepare(
user_db.get(), "DELETE FROM candidate_selection_state WHERE context_key=?1 AND entry_key=?2 AND value=?3");
const bool reset_ok = reset && bind_text(reset.get(), 1, context_key) && bind_text(reset.get(), 2, entry_key) &&
bind_text(reset.get(), 3, value) && sqlite3_step(reset.get()) == SQLITE_DONE;
reset.reset();
const bool committed = reset_ok && execute_sql(user_db.get(), "COMMIT");
if (!committed)
rollback_user();
return committed;
}
const size_t target = ranking_target(rank, mode, linear_step, force_top);
auto main_db = open_database(main_db_path, SQLITE_OPEN_READWRITE);
if (!main_db || !execute_sql(main_db.get(), "BEGIN IMMEDIATE"))
{
rollback_user();
return false;
}
const auto rollback_main = [&]() { (void)execute_sql(main_db.get(), "ROLLBACK"); };
// Keep learned weights in the same general range as the shipped dictionary
// (currently below 77 million). Large spacing is unnecessary and previously
// caused local rebalances to create values around 10^12. A later bug used a
// tiny `upper` (weight=1 rare words) as the staircase base and wrote
// negative weights onto neighboring keys from query_series().
std::int64_t new_weight = 0;
const bool top_near_limit =
target == 0 && database_candidates[0].weight > (std::numeric_limits<std::int64_t>::max)() - 1000;
const std::int64_t upper = target == 0 && !top_near_limit ? database_candidates[0].weight + 1000
: (target == 0 ? database_candidates[0].weight
: database_candidates[target - 1].weight);
const std::int64_t lower = database_candidates[target].weight;
const bool oversized = upper > kManagedWeightCeiling || lower > kManagedWeightCeiling;
bool need_rebalance =
top_near_limit || lower == (std::numeric_limits<std::int64_t>::max)() || upper <= lower || oversized;
if (!need_rebalance)
{
if (upper == lower + 1)
{
// There is no integer midpoint. Move one point above the upper
// candidate and skip a short run of occupied weights. This may
// advance one extra position, but keeps ordering deterministic
// without paying for a rebalance in the common case.
new_weight = upper + 1;
size_t conflicts = 0;
for (size_t i = target; i-- > 0;)
{
const std::int64_t occupied = database_candidates[i].weight;
if (occupied < new_weight)
continue;
if (occupied > new_weight)
break;
if (++conflicts >= 16 || new_weight >= kManagedWeightCeiling)
{
need_rebalance = true;
break;
}
++new_weight;
}
}
else
{
new_weight = lower + (upper - lower) / 2;
}
}
if (need_rebalance && !oversized && !top_near_limit && target != 0)
{
const size_t rebalance_end = (std::min)(database_candidates.size(), target + kRebalanceCount);
const std::int64_t last_index = static_cast<std::int64_t>(rebalance_end - 1);
const std::int64_t last_weight =
upper - kRebalanceGap - (last_index - static_cast<std::int64_t>(target)) * kRebalanceGap;
if (last_weight < kManagedWeightFloor)
{
// Equal/low weights have no room for a 16-slot descending staircase.
// Promote only the selected row instead of writing negatives onto
// neighbors (and onto shorter-syllable singles mixed into the UI list).
const std::int64_t cluster = (std::max)(upper, lower);
if (cluster > (std::numeric_limits<std::int64_t>::max)() - kRebalanceGap)
{
// Fall through to the ceiling compact path below.
}
else
{
new_weight = clamp_managed_weight(cluster + kRebalanceGap);
need_rebalance = false;
}
}
}
if (need_rebalance)
{
const size_t rebalance_begin = target;
const size_t rebalance_end = (std::min)(database_candidates.size(), rebalance_begin + kRebalanceCount);
std::int64_t base = target == 0 ? kManagedWeightCeiling - kRebalanceGap : kManagedWeightCeiling;
if (target != 0 && !top_near_limit && !oversized)
{
const std::int64_t requested_base =
upper + static_cast<std::int64_t>(target) * kRebalanceGap - kRebalanceGap;
base = (std::min)(requested_base, kManagedWeightCeiling);
}
bool rebalance_ok = true;
for (size_t i = rebalance_begin; i < rebalance_end && rebalance_ok; ++i)
{
if (!owns_entry_key[i])
continue;
const std::int64_t weight = clamp_managed_weight(base - static_cast<std::int64_t>(i) * kRebalanceGap);
rebalance_ok = update_ranked_weight(main_db.get(), journal_upsert.get(), kind, entry_key,
database_candidates[i].word, weight);
}
if (!rebalance_ok)
{
rollback_main();
rollback_user();
return false;
}
new_weight = target == 0 ? base + kRebalanceGap
: base - static_cast<std::int64_t>(target) * kRebalanceGap + kRebalanceGap / 2;
}
new_weight = clamp_managed_weight(new_weight);
const bool ok = update_ranked_weight(main_db.get(), journal_upsert.get(), kind, entry_key, value, new_weight);
if (!ok)
{
rollback_main();
rollback_user();
return false;
}
auto reset = prepare(user_db.get(),
"DELETE FROM candidate_selection_state WHERE context_key=?1 AND entry_key=?2 AND value=?3");
const bool reset_ok = reset && bind_text(reset.get(), 1, context_key) && bind_text(reset.get(), 2, entry_key) &&
bind_text(reset.get(), 3, value) && sqlite3_step(reset.get()) == SQLITE_DONE;
reset.reset();
if (!reset_ok || !execute_sql(user_db.get(), "COMMIT"))
{
rollback_main();
rollback_user();
return false;
}
if (!execute_sql(main_db.get(), "COMMIT"))
{
rollback_main();
return false;
}
if (ranking_changed)
*ranking_changed = true;
return true;
}
ReplayResult replay(const std::string &user_db_path, const std::string &main_db_path,
const std::string &english_db_path)
{
ReplayResult result;
if (!std::filesystem::exists(metasequoia::path_from_utf8(user_db_path.c_str())))
return result;
auto journal = open_database(user_db_path, SQLITE_OPEN_READONLY);
if (!journal)
{
result.error = "cannot open user dictionary database";
return result;
}
if (!EnglishDictionary::ensure_schema(english_db_path))
{
result.error = "cannot migrate English dictionary database";
return result;
}
auto main_db = open_database(main_db_path, SQLITE_OPEN_READWRITE);
if (!main_db)
{
result.error = "cannot open target dictionary database";
return result;
}
auto attach_english = prepare(main_db.get(), "ATTACH DATABASE ?1 AS replay_english");
if (!attach_english || !bind_text(attach_english.get(), 1, english_db_path) ||
sqlite3_step(attach_english.get()) != SQLITE_DONE)
{
result.error = "cannot attach English dictionary database";
return result;
}
attach_english.reset();
auto rows = prepare(journal.get(), "SELECT dictionary,key,value,operation,weight,display"
" FROM user_dictionary_operations ORDER BY updated_at,rowid");
if (!rows)
{
result.error = "invalid user dictionary database";
return result;
}
if (!execute_sql(main_db.get(), "BEGIN IMMEDIATE"))
{
result.error = "cannot start replay transaction";
return result;
}
int row_result = SQLITE_ROW;
while ((row_result = sqlite3_step(rows.get())) == SQLITE_ROW)
{
const std::string kind = reinterpret_cast<const char *>(sqlite3_column_text(rows.get(), 0));
const std::string key = reinterpret_cast<const char *>(sqlite3_column_text(rows.get(), 1));
const std::string value = reinterpret_cast<const char *>(sqlite3_column_text(rows.get(), 2));
const std::string operation = reinterpret_cast<const char *>(sqlite3_column_text(rows.get(), 3));
const std::int64_t weight = sqlite3_column_int64(rows.get(), 4);
const std::string display = reinterpret_cast<const char *>(sqlite3_column_text(rows.get(), 5));
if (kind == "pinyin" && operation == "upsert" && weight < 1)
{
++result.skipped;
continue;
}
bool ok = false;
if (kind == "pinyin")
ok = apply_pinyin(main_db.get(), key, value, operation, weight);
else if (kind == "wubi")
ok = apply_simple(main_db.get(), "wubi86", "key", "value", key, value, operation, weight);
else if (kind == "quick")
ok = apply_simple(main_db.get(), "quick_parases", "key", "value", key, value, operation, weight);
else if (kind == "english")
ok = apply_english(main_db.get(), key, value, operation, weight, display);
ok ? ++result.applied : ++result.failed;
}
rows.reset();
if (row_result != SQLITE_DONE)
{
result.error = execute_sql(main_db.get(), "ROLLBACK")
? "cannot read the complete user dictionary journal; changes were rolled back"
: "cannot read the complete user dictionary journal and rollback also failed";
return result;
}
if (result.failed == 0)
{
if (!execute_sql(main_db.get(), "COMMIT"))
{
result.error = execute_sql(main_db.get(), "ROLLBACK")
? "cannot commit replay transaction; changes were rolled back"
: "cannot commit or roll back replay transaction";
}
}
else
{
result.error = execute_sql(main_db.get(), "ROLLBACK")
? "one or more operations failed; changes were rolled back"
: "one or more operations failed and rollback also failed";
}
return result;
}
} // namespace user_dictionary
namespace metasequoia
{
namespace
{
user_dictionary::DictionaryKind journal_kind(PersonalDictionaryKind kind)
{
switch (kind)
{
case PersonalDictionaryKind::Pinyin:
return user_dictionary::DictionaryKind::Pinyin;
case PersonalDictionaryKind::Wubi:
return user_dictionary::DictionaryKind::Wubi;
case PersonalDictionaryKind::QuickPhrase:
return user_dictionary::DictionaryKind::QuickPhrase;
case PersonalDictionaryKind::English:
return user_dictionary::DictionaryKind::English;
}
return user_dictionary::DictionaryKind::Pinyin;
}
bool apply_personal_edit(sqlite3 *db, const PersonalDictionaryEntry &entry, bool remove)
{
using namespace user_dictionary;
const std::string operation = remove ? "delete" : "upsert";
bool applied = false;
switch (entry.kind)
{
case PersonalDictionaryKind::Pinyin:
applied = apply_pinyin(db, entry.key, entry.value, operation, entry.weight);
break;
case PersonalDictionaryKind::Wubi:
applied = apply_simple(db, "wubi86", "key", "value", entry.key, entry.value, operation, entry.weight);
break;
case PersonalDictionaryKind::QuickPhrase:
applied = apply_simple(db, "quick_parases", "key", "value", entry.key, entry.value, operation, entry.weight);
break;
case PersonalDictionaryKind::English:
applied = apply_english(db, entry.key, entry.value, operation, entry.weight, entry.value);
break;
}
if (!applied)
return false;
auto journal = prepare(
db, "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()");
return journal && bind_text(journal.get(), 1, kind_name(journal_kind(entry.kind))) &&
bind_text(journal.get(), 2, entry.key) && bind_text(journal.get(), 3, entry.value) &&
bind_text(journal.get(), 4, operation) &&
sqlite3_bind_int64(journal.get(), 5, remove ? 0 : entry.weight) == SQLITE_OK &&
bind_text(journal.get(), 6, entry.kind == PersonalDictionaryKind::English ? entry.value : "") &&
sqlite3_step(journal.get()) == SQLITE_DONE;
}
} // namespace
PersonalDictionaryEditResult edit_personal_dictionary(const RuntimePaths &paths,
const std::optional<PersonalDictionaryEntry> &previous,
const std::optional<PersonalDictionaryEntry> &replacement,
const std::string &request_id)
{
using namespace user_dictionary;
if (request_id.size() > 128 || !std::all_of(request_id.begin(), request_id.end(), [](unsigned char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' ||
ch == '_';
}))
return {false, "Invalid personal dictionary request ID"};
if (!previous && !replacement)
return {false, "An entry is required"};
std::optional<PersonalDictionaryEntry> old_entry, new_entry;
if (previous)
{
auto checked = validate_personal_dictionary_entry(*previous);
if (!checked.entry)
return {false, checked.error};
old_entry = std::move(checked.entry);
}
if (replacement)
{
auto checked = validate_personal_dictionary_entry(*replacement);
if (!checked.entry)
return {false, checked.error};
new_entry = std::move(checked.entry);
}
try
{
paths.validate();
const auto journal_path = path_to_utf8(paths.user(assets::user_journal));
const auto english_path = path_to_utf8(paths.dictionary(assets::english_dictionary));
if (!ensure_user_database(journal_path) || !EnglishDictionary::ensure_schema(english_path))
return {false, "Cannot prepare personal dictionary storage"};
auto db = open_database(path_to_utf8(paths.dictionary(assets::main_dictionary)), SQLITE_OPEN_READWRITE);
if (!db)
return {false, "Cannot open dictionary"};
for (const auto &attachment :
{std::make_pair("personal_journal", journal_path), std::make_pair("replay_english", english_path)})
{
auto attach = prepare(db.get(), std::string("ATTACH DATABASE ?1 AS ") + attachment.first);
if (!attach || !bind_text(attach.get(), 1, attachment.second) || sqlite3_step(attach.get()) != SQLITE_DONE)
return {false, "Cannot attach personal dictionary storage"};
}
if (!execute_sql(db.get(), "BEGIN IMMEDIATE"))
return {false, "Cannot begin personal dictionary edit"};
const auto fail = [&](const char *message) {
execute_sql(db.get(), "ROLLBACK");
return PersonalDictionaryEditResult{false, message};
};
std::string payload;
const auto append_payload = [&](const std::optional<PersonalDictionaryEntry> &entry) {
if (!entry)
{
payload += "none;";
return;
}
payload += std::to_string(static_cast<int>(entry->kind)) + ";" + std::to_string(entry->weight) + ";";
for (const auto &value : {entry->key, entry->value})
payload += std::to_string(value.size()) + ":" + value;
};
if (!request_id.empty())
{
append_payload(old_entry);
append_payload(new_entry);
auto receipt = prepare(
db.get(), "SELECT payload FROM personal_journal.personal_dictionary_receipts WHERE request_id=?1");
if (!receipt || !bind_text(receipt.get(), 1, request_id))
return fail("Cannot read edit receipt");
const int step = sqlite3_step(receipt.get());
if (step == SQLITE_ROW)
{
const auto *stored = sqlite3_column_text(receipt.get(), 0);
const bool same = stored && payload == reinterpret_cast<const char *>(stored);
receipt.reset();
if (!same)
return fail("Request ID was already used for another edit");
if (!execute_sql(db.get(), "COMMIT"))
return fail("Cannot finish edit retry");
return {true, {}};
}
if (step != SQLITE_DONE)
return fail("Cannot finish reading edit receipt");
}
if (old_entry)
{
auto match = prepare(db.get(), "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");
if (!match || !bind_text(match.get(), 1, kind_name(journal_kind(old_entry->kind))) ||
!bind_text(match.get(), 2, old_entry->key) || !bind_text(match.get(), 3, old_entry->value) ||
sqlite3_bind_int64(match.get(), 4, old_entry->weight) != SQLITE_OK ||
sqlite3_step(match.get()) != SQLITE_ROW)
return fail("The entry changed; reload it before editing");
match.reset();
if (!apply_personal_edit(db.get(), *old_entry, true))
return fail("Cannot remove previous personal entry");
}
if (new_entry && !apply_personal_edit(db.get(), *new_entry, false))
return fail("Cannot save personal entry");
if (!request_id.empty())
{
auto receipt =
prepare(db.get(),
"INSERT INTO personal_journal.personal_dictionary_receipts(request_id,payload) VALUES(?1,?2)");
if (!receipt || !bind_text(receipt.get(), 1, request_id) || !bind_text(receipt.get(), 2, payload) ||
sqlite3_step(receipt.get()) != SQLITE_DONE)
return fail("Cannot save edit receipt");
}
if (!execute_sql(db.get(), "COMMIT"))
return fail("Cannot commit personal dictionary edit");
return {true, {}};
}
catch (const std::exception &)
{
return {false, "Cannot access personal dictionary storage"};
}
}
PersonalDictionaryPage personal_dictionary_entries(const RuntimePaths &paths, std::size_t offset, std::size_t limit)
{
using namespace user_dictionary;
PersonalDictionaryPage result;
if (limit == 0 || limit > 1000 || offset > 1000000)
{
result.error = "Invalid personal dictionary page";
return result;
}
try
{
paths.validate();
const auto journal_path = paths.user(assets::user_journal);
if (!std::filesystem::exists(journal_path))
return result;
auto db = open_database(path_to_utf8(journal_path), SQLITE_OPEN_READONLY);
auto rows =
db ? prepare(
db.get(),
"SELECT dictionary,key,value,weight FROM user_dictionary_operations"
" WHERE user_inserted=1 AND operation='upsert' ORDER BY dictionary,key,value LIMIT ?1 OFFSET ?2")
: Stmt{};
if (!rows || sqlite3_bind_int64(rows.get(), 1, static_cast<sqlite3_int64>(limit + 1)) != SQLITE_OK ||
sqlite3_bind_int64(rows.get(), 2, static_cast<sqlite3_int64>(offset)) != SQLITE_OK)
{
result.error = "Cannot read personal dictionary";
return result;
}
int step;
while ((step = sqlite3_step(rows.get())) == SQLITE_ROW)
{
if (result.entries.size() == limit)
{
result.has_more = true;
break;
}
auto text = [&](int index) {
const auto *value = sqlite3_column_text(rows.get(), index);
return value
? std::string(reinterpret_cast<const char *>(value), sqlite3_column_bytes(rows.get(), index))
: std::string{};
};
const auto kind = text(0);
PersonalDictionaryKind entry_kind;
if (kind == "pinyin")
entry_kind = PersonalDictionaryKind::Pinyin;
else if (kind == "wubi")
entry_kind = PersonalDictionaryKind::Wubi;
else if (kind == "quick")
entry_kind = PersonalDictionaryKind::QuickPhrase;
else if (kind == "english")
entry_kind = PersonalDictionaryKind::English;
else
continue;
result.entries.push_back({entry_kind, text(1), text(2), sqlite3_column_int64(rows.get(), 3)});
}
if (step != SQLITE_DONE && !result.has_more)
{
result.entries.clear();
result.error = "Cannot finish reading personal dictionary";
}
}
catch (const std::exception &)
{
result.entries.clear();
result.error = "Cannot access personal dictionary storage";
}
return result;
}
} // namespace metasequoia