mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 14:14:53 +08:00
feat(glossary): 实现 Rust Glossary V1
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
//! Project-level Glossary V1 SQLite repository.
|
||||
|
||||
use crate::path_security::{
|
||||
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::{
|
||||
evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord,
|
||||
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm,
|
||||
GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext,
|
||||
};
|
||||
use bat_core::repositories::GlossaryRepository;
|
||||
use bat_core::{Error, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Glossary SQLite schema version.
|
||||
pub const GLOSSARY_SCHEMA_VERSION: u32 = 1;
|
||||
/// Schema migration component.
|
||||
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
|
||||
/// Default project-level glossary file.
|
||||
pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite";
|
||||
|
||||
/// SQLite-backed project Glossary repository.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqliteGlossaryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteGlossaryRepository {
|
||||
/// Creates or opens a glossary database.
|
||||
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_with(path.as_ref(), true).await
|
||||
}
|
||||
|
||||
/// Opens an existing glossary database without creating it.
|
||||
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_with(path.as_ref(), false).await
|
||||
}
|
||||
|
||||
/// Returns the project-level glossary path for an official release root.
|
||||
pub fn repository_path(resource_root: &Path) -> PathBuf {
|
||||
if resource_root
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.is_some_and(|name| name == "versions")
|
||||
{
|
||||
if let Some(output_root) = resource_root.parent().and_then(Path::parent) {
|
||||
return output_root.join(GLOSSARY_REPOSITORY_FILE);
|
||||
}
|
||||
}
|
||||
resource_root.join(GLOSSARY_REPOSITORY_FILE)
|
||||
}
|
||||
|
||||
async fn open_with(path: &Path, create_if_missing: bool) -> Result<Self> {
|
||||
let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?;
|
||||
let parent = absolute.parent().ok_or_else(|| {
|
||||
Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display()))
|
||||
})?;
|
||||
ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?;
|
||||
if create_if_missing {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
ensure_safe_directory_path(parent, "Glossary 数据库")
|
||||
.map_err(Error::InvalidArgument)?;
|
||||
} else if !absolute.is_file() {
|
||||
return Err(Error::NotFound(absolute.display().to_string()));
|
||||
}
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&absolute) {
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"Glossary 数据库必须是普通文件:{}",
|
||||
absolute.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display()))
|
||||
.map_err(|error| Error::Other(error.into()))?
|
||||
.create_if_missing(create_if_missing)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(30));
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if create_if_missing {
|
||||
set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库")
|
||||
.map_err(Error::InvalidArgument)?;
|
||||
}
|
||||
let repository = Self { pool };
|
||||
repository.init_schema().await?;
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
component TEXT PRIMARY KEY NOT NULL,
|
||||
version INTEGER NOT NULL CHECK(version >= 1)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_terms (
|
||||
term_id TEXT PRIMARY KEY NOT NULL,
|
||||
source_term TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL,
|
||||
recommended_translation TEXT NOT NULL,
|
||||
allowed_translations_json TEXT NOT NULL,
|
||||
source_language TEXT,
|
||||
target_language TEXT,
|
||||
category TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
scope_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
source_author TEXT,
|
||||
source_note TEXT,
|
||||
source_observed_unix_seconds INTEGER NOT NULL,
|
||||
created_unix_seconds INTEGER NOT NULL,
|
||||
updated_unix_seconds INTEGER NOT NULL,
|
||||
CHECK(length(term_id) > 0),
|
||||
CHECK(length(source_term) > 0),
|
||||
CHECK(length(recommended_translation) > 0),
|
||||
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
|
||||
CHECK(source_kind IN ('manual', 'imported'))
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
ensure_column(
|
||||
&self.pool,
|
||||
"glossary_terms",
|
||||
"source_observed_unix_seconds",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_term_history (
|
||||
history_id TEXT PRIMARY KEY NOT NULL,
|
||||
term_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reviewer TEXT,
|
||||
reason TEXT,
|
||||
source_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
observed_unix_seconds INTEGER NOT NULL,
|
||||
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_glossary_status
|
||||
ON glossary_terms(review_status, priority DESC, term_id)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
|
||||
ON glossary_terms(source_term)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let current: Option<i64> =
|
||||
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"不支持的 Glossary schema 版本:{}",
|
||||
current.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
|
||||
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
|
||||
)
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns one term with complete history.
|
||||
pub async fn find(&self, term_id: &str) -> Result<GlossaryTerm> {
|
||||
let row = sqlx::query(
|
||||
"SELECT term_id, source_term, aliases_json, recommended_translation,
|
||||
allowed_translations_json, source_language, target_language, category,
|
||||
priority, scope_json, review_status, source_kind, source_ref,
|
||||
source_author, source_note, source_observed_unix_seconds,
|
||||
created_unix_seconds, updated_unix_seconds
|
||||
FROM glossary_terms WHERE term_id = ?1",
|
||||
)
|
||||
.bind(term_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.ok_or_else(|| Error::NotFound(term_id.to_string()))?;
|
||||
let mut term = row_to_term(row)?;
|
||||
let history_rows = sqlx::query(
|
||||
"SELECT history_id, action, reviewer, reason, source_json, review_status,
|
||||
snapshot_json, observed_unix_seconds
|
||||
FROM glossary_term_history WHERE term_id = ?1
|
||||
ORDER BY observed_unix_seconds ASC, history_id ASC",
|
||||
)
|
||||
.bind(term_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
term.history = history_rows
|
||||
.into_iter()
|
||||
.map(row_to_history)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
/// Queries terms, including non-approved terms for review.
|
||||
pub async fn query(
|
||||
&self,
|
||||
source_text: Option<&str>,
|
||||
category: Option<&str>,
|
||||
review_status: Option<GlossaryReviewStatus>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<GlossaryTerm>> {
|
||||
if !(1..=1000).contains(&limit) {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
|
||||
));
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"SELECT term_id FROM glossary_terms
|
||||
WHERE (?1 IS NULL OR category = ?1)
|
||||
AND (?2 IS NULL OR review_status = ?2)
|
||||
ORDER BY priority DESC, term_id ASC",
|
||||
)
|
||||
.bind(category)
|
||||
.bind(review_status.map(|value| value.as_str()))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let mut terms = Vec::new();
|
||||
for row in rows {
|
||||
let term_id: String = row.try_get("term_id").map_err(db_error)?;
|
||||
let term = self.find(&term_id).await?;
|
||||
if source_text.is_none_or(|source| {
|
||||
let mut spellings = vec![term.definition.source_term.as_str()];
|
||||
spellings.extend(term.definition.aliases.iter().map(String::as_str));
|
||||
spellings
|
||||
.into_iter()
|
||||
.any(|spelling| source.contains(spelling))
|
||||
}) {
|
||||
terms.push(term);
|
||||
if terms.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// Returns review-state counts.
|
||||
pub async fn summary(&self) -> Result<GlossarySummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) AS term_count,
|
||||
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count,
|
||||
SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count,
|
||||
SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count,
|
||||
SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count
|
||||
FROM glossary_terms",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(GlossarySummary {
|
||||
schema_version: GLOSSARY_SCHEMA_VERSION,
|
||||
term_count: row.try_get::<i64, _>("term_count").map_err(db_error)? as u64,
|
||||
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
|
||||
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
|
||||
deprecated_count: row
|
||||
.try_get::<i64, _>("deprecated_count")
|
||||
.map_err(db_error)? as u64,
|
||||
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a term and records its source snapshot.
|
||||
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
|
||||
validate_glossary_draft(&draft)?;
|
||||
let now = draft.source.observed_unix_seconds;
|
||||
let term = term_from_draft(&draft, now, now);
|
||||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||||
let existing: Option<String> =
|
||||
sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1")
|
||||
.bind(&draft.term_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if existing.is_some() {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"Glossary term_id 已存在:{}",
|
||||
draft.term_id
|
||||
)));
|
||||
}
|
||||
insert_term(&mut transaction, &term).await?;
|
||||
insert_history(&mut transaction, &term, "created", None, None, now).await?;
|
||||
transaction.commit().await.map_err(db_error)?;
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
/// Replaces a term definition and records the previous source history.
|
||||
pub async fn update(
|
||||
&self,
|
||||
draft: GlossaryTermDraft,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> Result<GlossaryTerm> {
|
||||
validate_glossary_draft(&draft)?;
|
||||
if reviewer.trim().is_empty() {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary update reviewer 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let current = self.find(&draft.term_id).await?;
|
||||
let now = draft.source.observed_unix_seconds;
|
||||
let term = term_from_draft(&draft, current.created_unix_seconds, now);
|
||||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||||
update_term(&mut transaction, &term).await?;
|
||||
insert_history(
|
||||
&mut transaction,
|
||||
&term,
|
||||
"updated",
|
||||
Some(reviewer.trim()),
|
||||
reason.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(db_error)?;
|
||||
self.find(&draft.term_id).await
|
||||
}
|
||||
|
||||
/// Changes review state and records a source/review history entry.
|
||||
pub async fn review(
|
||||
&self,
|
||||
term_id: &str,
|
||||
status: GlossaryReviewStatus,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> Result<GlossaryTerm> {
|
||||
if reviewer.trim().is_empty() {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary reviewer 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
status,
|
||||
GlossaryReviewStatus::Approved
|
||||
| GlossaryReviewStatus::Deprecated
|
||||
| GlossaryReviewStatus::Rejected
|
||||
) {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary review 只允许 approved、deprecated 或 rejected".to_string(),
|
||||
));
|
||||
}
|
||||
let mut term = self.find(term_id).await?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
term.review_status = status;
|
||||
term.updated_unix_seconds = now;
|
||||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3
|
||||
WHERE term_id = ?1",
|
||||
)
|
||||
.bind(term_id)
|
||||
.bind(status.as_str())
|
||||
.bind(i64::try_from(now).unwrap_or(i64::MAX))
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
insert_history(
|
||||
&mut transaction,
|
||||
&term,
|
||||
status.as_str(),
|
||||
Some(reviewer.trim()),
|
||||
reason.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(db_error)?;
|
||||
self.find(term_id).await
|
||||
}
|
||||
|
||||
/// Evaluates approved terms for one TextUnit.
|
||||
pub async fn diagnose(
|
||||
&self,
|
||||
source_text: &str,
|
||||
context: &TranslationMemoryContext,
|
||||
) -> Result<GlossaryEvaluation> {
|
||||
let terms = self
|
||||
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000)
|
||||
.await?;
|
||||
Ok(evaluate_glossary(&terms, source_text, context))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GlossaryRepository for SqliteGlossaryRepository {
|
||||
async fn evaluate(
|
||||
&self,
|
||||
source_text: &str,
|
||||
context: &TranslationMemoryContext,
|
||||
) -> Result<GlossaryEvaluation> {
|
||||
self.diagnose(source_text, context).await
|
||||
}
|
||||
}
|
||||
|
||||
fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm {
|
||||
GlossaryTerm {
|
||||
term_id: draft.term_id.clone(),
|
||||
definition: draft.definition.clone(),
|
||||
review_status: draft.review_status,
|
||||
source: draft.source.clone(),
|
||||
history: Vec::new(),
|
||||
created_unix_seconds: created,
|
||||
updated_unix_seconds: updated,
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_term(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
term: &GlossaryTerm,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO glossary_terms (
|
||||
term_id, source_term, aliases_json, recommended_translation,
|
||||
allowed_translations_json, source_language, target_language, category,
|
||||
priority, scope_json, review_status, source_kind, source_ref,
|
||||
source_author, source_note, source_observed_unix_seconds,
|
||||
created_unix_seconds, updated_unix_seconds
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
)
|
||||
.bind(&term.term_id)
|
||||
.bind(&term.definition.source_term)
|
||||
.bind(json(&term.definition.aliases)?)
|
||||
.bind(&term.definition.recommended_translation)
|
||||
.bind(json(&term.definition.allowed_translations)?)
|
||||
.bind(&term.definition.source_language)
|
||||
.bind(&term.definition.target_language)
|
||||
.bind(&term.definition.category)
|
||||
.bind(term.definition.priority)
|
||||
.bind(json(&term.definition.scope)?)
|
||||
.bind(term.review_status.as_str())
|
||||
.bind(term.source.source_kind.as_str())
|
||||
.bind(&term.source.source_ref)
|
||||
.bind(&term.source.source_author)
|
||||
.bind(&term.source.source_note)
|
||||
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
|
||||
.bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX))
|
||||
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_term(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
term: &GlossaryTerm,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3,
|
||||
recommended_translation = ?4, allowed_translations_json = ?5,
|
||||
source_language = ?6, target_language = ?7, category = ?8,
|
||||
priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12,
|
||||
source_ref = ?13, source_author = ?14, source_note = ?15,
|
||||
source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1",
|
||||
)
|
||||
.bind(&term.term_id)
|
||||
.bind(&term.definition.source_term)
|
||||
.bind(json(&term.definition.aliases)?)
|
||||
.bind(&term.definition.recommended_translation)
|
||||
.bind(json(&term.definition.allowed_translations)?)
|
||||
.bind(&term.definition.source_language)
|
||||
.bind(&term.definition.target_language)
|
||||
.bind(&term.definition.category)
|
||||
.bind(term.definition.priority)
|
||||
.bind(json(&term.definition.scope)?)
|
||||
.bind(term.review_status.as_str())
|
||||
.bind(term.source.source_kind.as_str())
|
||||
.bind(&term.source.source_ref)
|
||||
.bind(&term.source.source_author)
|
||||
.bind(&term.source.source_note)
|
||||
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
|
||||
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_history(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
term: &GlossaryTerm,
|
||||
action: &str,
|
||||
reviewer: Option<&str>,
|
||||
reason: Option<&str>,
|
||||
observed: u64,
|
||||
) -> Result<()> {
|
||||
let source_json = json(&term.source)?;
|
||||
let snapshot_json = json(&term.definition)?;
|
||||
let mut history_hasher = blake3::Hasher::new();
|
||||
for value in [
|
||||
term.term_id.as_str(),
|
||||
action,
|
||||
reviewer.unwrap_or_default(),
|
||||
reason.unwrap_or_default(),
|
||||
source_json.as_str(),
|
||||
snapshot_json.as_str(),
|
||||
] {
|
||||
history_hasher.update(value.as_bytes());
|
||||
history_hasher.update(&[0]);
|
||||
}
|
||||
history_hasher.update(&observed.to_le_bytes());
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
history_hasher.update(&nonce.to_le_bytes());
|
||||
let history_id = format!("glh-{}", history_hasher.finalize().to_hex());
|
||||
sqlx::query(
|
||||
"INSERT INTO glossary_term_history (
|
||||
history_id, term_id, action, reviewer, reason, source_json,
|
||||
review_status, snapshot_json, observed_unix_seconds
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)
|
||||
.bind(history_id)
|
||||
.bind(&term.term_id)
|
||||
.bind(action)
|
||||
.bind(reviewer)
|
||||
.bind(reason.filter(|value| !value.trim().is_empty()))
|
||||
.bind(source_json)
|
||||
.bind(term.review_status.as_str())
|
||||
.bind(snapshot_json)
|
||||
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryTerm> {
|
||||
Ok(GlossaryTerm {
|
||||
term_id: row.try_get("term_id").map_err(db_error)?,
|
||||
definition: GlossaryTermSnapshot {
|
||||
source_term: row.try_get("source_term").map_err(db_error)?,
|
||||
aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?,
|
||||
recommended_translation: row.try_get("recommended_translation").map_err(db_error)?,
|
||||
allowed_translations: parse_json(
|
||||
row.try_get("allowed_translations_json").map_err(db_error)?,
|
||||
)?,
|
||||
source_language: row.try_get("source_language").map_err(db_error)?,
|
||||
target_language: row.try_get("target_language").map_err(db_error)?,
|
||||
category: row.try_get("category").map_err(db_error)?,
|
||||
priority: row.try_get("priority").map_err(db_error)?,
|
||||
scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?,
|
||||
},
|
||||
review_status: parse_review_status(
|
||||
row.try_get::<String, _>("review_status")
|
||||
.map_err(db_error)?
|
||||
.as_str(),
|
||||
)?,
|
||||
source: GlossarySourceRecord {
|
||||
source_kind: parse_source_kind(
|
||||
row.try_get::<String, _>("source_kind")
|
||||
.map_err(db_error)?
|
||||
.as_str(),
|
||||
)?,
|
||||
source_ref: row.try_get("source_ref").map_err(db_error)?,
|
||||
source_author: row.try_get("source_author").map_err(db_error)?,
|
||||
source_note: row.try_get("source_note").map_err(db_error)?,
|
||||
observed_unix_seconds: to_u64(
|
||||
row.try_get("source_observed_unix_seconds")
|
||||
.map_err(db_error)?,
|
||||
"source",
|
||||
)?,
|
||||
},
|
||||
history: Vec::new(),
|
||||
created_unix_seconds: to_u64(
|
||||
row.try_get("created_unix_seconds").map_err(db_error)?,
|
||||
"created",
|
||||
)?,
|
||||
updated_unix_seconds: to_u64(
|
||||
row.try_get("updated_unix_seconds").map_err(db_error)?,
|
||||
"updated",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord> {
|
||||
Ok(GlossaryHistoryRecord {
|
||||
history_id: row.try_get("history_id").map_err(db_error)?,
|
||||
action: row.try_get("action").map_err(db_error)?,
|
||||
reviewer: row.try_get("reviewer").map_err(db_error)?,
|
||||
reason: row.try_get("reason").map_err(db_error)?,
|
||||
source: parse_json(row.try_get("source_json").map_err(db_error)?)?,
|
||||
review_status: parse_review_status(
|
||||
row.try_get::<String, _>("review_status")
|
||||
.map_err(db_error)?
|
||||
.as_str(),
|
||||
)?,
|
||||
snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?,
|
||||
observed_unix_seconds: to_u64(
|
||||
row.try_get("observed_unix_seconds").map_err(db_error)?,
|
||||
"history",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_json<T: DeserializeOwned>(value: String) -> Result<T> {
|
||||
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
|
||||
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
|
||||
GlossaryReviewStatus::parse(value)
|
||||
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status:{value}")))
|
||||
}
|
||||
|
||||
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
|
||||
GlossarySourceKind::parse(value)
|
||||
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind:{value}")))
|
||||
}
|
||||
|
||||
fn to_u64(value: i64, label: &str) -> Result<u64> {
|
||||
u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效")))
|
||||
}
|
||||
|
||||
fn db_error(error: sqlx::Error) -> Error {
|
||||
Error::Other(error.into())
|
||||
}
|
||||
|
||||
async fn ensure_column(
|
||||
pool: &SqlitePool,
|
||||
table: &str,
|
||||
column: &str,
|
||||
definition: &str,
|
||||
) -> Result<()> {
|
||||
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let exists = columns.iter().any(|row| {
|
||||
row.try_get::<String, _>("name")
|
||||
.map(|name| name == column)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !exists {
|
||||
sqlx::query(&format!(
|
||||
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
||||
))
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
|
||||
GlossaryTermDraft {
|
||||
term_id: "term-sensei".to_string(),
|
||||
definition: GlossaryTermSnapshot {
|
||||
source_term: "Sensei".to_string(),
|
||||
aliases: vec!["Teacher".to_string()],
|
||||
recommended_translation: "老师".to_string(),
|
||||
allowed_translations: vec!["老师大人".to_string()],
|
||||
source_language: Some("en".to_string()),
|
||||
target_language: Some("zh-Hans".to_string()),
|
||||
category: Some("person".to_string()),
|
||||
priority: 10,
|
||||
scope: BTreeMap::new(),
|
||||
},
|
||||
review_status: status,
|
||||
source: GlossarySourceRecord {
|
||||
source_kind: GlossarySourceKind::Manual,
|
||||
source_ref: Some("test".to_string()),
|
||||
source_author: Some("tester".to_string()),
|
||||
source_note: None,
|
||||
observed_unix_seconds: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
|
||||
.await
|
||||
.unwrap();
|
||||
repository
|
||||
.add(draft(GlossaryReviewStatus::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
let before = repository
|
||||
.diagnose("Sensei", &BTreeMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(before.constraints.is_empty());
|
||||
|
||||
repository
|
||||
.review(
|
||||
"term-sensei",
|
||||
GlossaryReviewStatus::Approved,
|
||||
"reviewer",
|
||||
Some("ok".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = repository
|
||||
.diagnose("Sensei", &BTreeMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(after.constraints.len(), 1);
|
||||
|
||||
let term = repository.find("term-sensei").await.unwrap();
|
||||
assert_eq!(term.history.len(), 2);
|
||||
assert_eq!(term.history[1].action, "approved");
|
||||
let summary = repository.summary().await.unwrap();
|
||||
assert_eq!(summary.approved_count, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user