summaryrefslogtreecommitdiff
path: root/server/src/main.rs
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2025-06-12 09:11:18 +0200
committerJoel Klinghed <the_jk@spawned.biz>2025-06-19 00:19:37 +0200
commit2b54f5c51ff9a26d4077037631ed39d62ed2b3fb (patch)
tree8544278dba24645a063472a3005a3021879a4bf1 /server/src/main.rs
parentbaa7c85ff3db2366d67ac875fca48ad6dcabf212 (diff)
Initial support for translation reviews
Diffstat (limited to 'server/src/main.rs')
-rw-r--r--server/src/main.rs367
1 files changed, 353 insertions, 14 deletions
diff --git a/server/src/main.rs b/server/src/main.rs
index 66faec3..8c59b23 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -8,7 +8,7 @@ use rocket::http::Status;
use rocket::response::status::{Custom, NotFound};
use rocket::serde::json::Json;
use rocket::{futures, Build, Rocket, State};
-use rocket_db_pools::{sqlx, Connection, Database, Pool};
+use rocket_db_pools::{sqlx, Connection, Database};
use sqlx::Acquire;
use std::path::PathBuf;
use utoipa::OpenApi;
@@ -55,6 +55,9 @@ struct Db(sqlx::MySqlPool);
user_key_get,
user_key_del,
user_keys,
+ translation_review_new,
+ translation_review_strings,
+ translation_reviews,
),
modifiers(&AuthApiAddon),
)]
@@ -122,7 +125,7 @@ fn abbrivate_key(key: &str) -> String {
}
async fn get_project(
- db: &mut <<Db as Database>::Pool as Pool>::Connection,
+ mut db: Connection<Db>,
projectid: &str,
) -> Result<Json<api_model::Project>, NotFound<&'static str>> {
let users = sqlx::query!(
@@ -173,12 +176,11 @@ async fn get_project(
)]
#[get("/project/<projectid>")]
async fn project(
- db: &Db,
+ db: Connection<Db>,
_session: auth::Session,
projectid: &str,
) -> Result<Json<api_model::Project>, NotFound<&'static str>> {
- let mut conn = db.get().await.unwrap();
- get_project(&mut conn, projectid).await
+ get_project(db, projectid).await
}
// Remove linebreaks and potential openssh wrapper
@@ -206,6 +208,7 @@ fn cleanup_key(key: &str) -> String {
#[post("/project/<projectid>/new", data = "<data>")]
async fn project_new(
db: &Db,
+ conn: Connection<Db>,
git_roots_config: &State<git_root::Config<'_>>,
roots_state: &State<git_root::Roots>,
session: auth::Session,
@@ -219,9 +222,8 @@ async fn project_new(
};
let main_branch = data.main_branch.unwrap_or("main");
- let mut conn = db.get().await.unwrap();
{
- let mut tx = conn.begin().await.unwrap();
+ let mut tx = db.begin().await.unwrap();
sqlx::query!(
"INSERT INTO projects (id, title, description, remote, remote_key, main_branch) VALUES (?, ?, ?, ?, ?, ?)",
@@ -258,7 +260,7 @@ async fn project_new(
.map_err(|e| Custom(Status::InternalServerError, format!("{e}")))
.await?;
- Ok(get_project(&mut conn, projectid).await.unwrap())
+ Ok(get_project(conn, projectid).await.unwrap())
}
async fn project_check_maintainer(
@@ -536,15 +538,14 @@ async fn reviews(
}))
}
-async fn get_review_users(
- mut db: Connection<Db>,
+async fn get_project_default_review_users<'a>(
+ db: &mut Connection<Db>,
projectid: &str,
- reviewid: u64,
) -> Vec<api_model::ReviewUserEntry> {
- let mut users = sqlx::query!(
+ sqlx::query!(
"SELECT id,name,dn,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,id",
projectid)
- .fetch(&mut **db)
+ .fetch(&mut ***db)
.map_ok(|r| api_model::ReviewUserEntry {
user: api_model::User {
id: r.id,
@@ -555,7 +556,15 @@ async fn get_review_users(
})
.try_collect::<Vec<_>>()
.await
- .unwrap();
+ .unwrap()
+}
+
+async fn get_review_users(
+ mut db: Connection<Db>,
+ projectid: &str,
+ reviewid: u64,
+) -> Vec<api_model::ReviewUserEntry> {
+ let mut users = get_project_default_review_users(&mut db, projectid).await;
let override_users = sqlx::query!(
"SELECT id,name,dn,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,id",
@@ -1116,6 +1125,333 @@ fn healthcheck() -> &'static str {
""
}
+async fn get_translation_review(
+ mut db: Connection<Db>,
+ projectid: &str,
+ translation_reviewid: u64,
+) -> Result<Json<api_model::TranslationReview>, NotFound<&'static str>> {
+ let mut translation_review = sqlx::query!(
+ "SELECT title,description,state,progress,archived,base,head,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM translation_reviews JOIN users ON users.id=owner WHERE project=? AND translation_reviews.id=?",
+ projectid, translation_reviewid)
+ .fetch_one(&mut **db)
+ .map_ok(|r| {
+ api_model::TranslationReview {
+ id: translation_reviewid,
+ title: r.title,
+ description: r.description,
+ owner: api_model::User {
+ id: r.user_id,
+ name: r.name,
+ active: r.user_dn.is_some(),
+ },
+ users: Vec::new(),
+ state: api_model::ReviewState::try_from(r.state).unwrap(),
+ progress: r.progress,
+ archived: r.archived != 0,
+ base: r.base,
+ head: r.head,
+ }
+ })
+ .map_err(|_| NotFound("No such review"))
+ .await?;
+
+ translation_review.users =
+ get_translation_review_users(db, projectid, translation_reviewid).await;
+
+ Ok(Json(translation_review))
+}
+
+async fn get_translation_review_users(
+ mut db: Connection<Db>,
+ projectid: &str,
+ translation_reviewid: u64,
+) -> Vec<api_model::ReviewUserEntry> {
+ let mut users = get_project_default_review_users(&mut db, projectid).await;
+
+ let override_users = sqlx::query!(
+ "SELECT id,name,dn,translation_review_users.role AS role FROM users JOIN translation_review_users ON translation_review_users.user=id WHERE translation_review_users.translation_review=? ORDER BY role,id",
+ translation_reviewid)
+ .fetch(&mut **db)
+ .map_ok(|r| api_model::ReviewUserEntry {
+ user: api_model::User {
+ id: r.id,
+ name: r.name,
+ active: r.dn.is_some(),
+ },
+ role: api_model::UserReviewRole::try_from(r.role).unwrap(),
+ })
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ for override_user in override_users {
+ if let Some(user) = users
+ .iter_mut()
+ .find(|ue| ue.user.id == override_user.user.id)
+ {
+ user.role = override_user.role;
+ } else {
+ users.push(override_user);
+ }
+ }
+
+ users
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Translation review created", body = api_model::TranslationReview),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[post("/translation/<projectid>/new", data = "<data>")]
+async fn translation_review_new(
+ db: &Db,
+ mut conn: Connection<Db>,
+ roots_state: &State<git_root::Roots>,
+ session: auth::Session,
+ projectid: &str,
+ data: Json<api_model::TranslationReviewData<'_>>,
+) -> Result<Json<api_model::TranslationReview>, Custom<String>> {
+ let title = if data.title == "" {
+ "Unnamed"
+ } else {
+ data.title.as_str()
+ };
+ let mut base = data.base.unwrap_or("").to_string();
+ let translation_reviewid: u64;
+
+ {
+ let mut tx = conn.begin().await.unwrap();
+
+ if base == "" {
+ let main_branch =
+ sqlx::query!("SELECT main_branch FROM projects WHERE id=?", projectid)
+ .fetch_one(&mut *tx)
+ .map_ok(|r| r.main_branch)
+ .map_err(|_| Custom(Status::NotFound, "No such project".to_string()))
+ .await?;
+
+ base = roots_state
+ .fetch_branch(projectid, main_branch.as_str())
+ .map_err(|_| Custom(Status::InternalServerError, "git error".to_string()))
+ .await?;
+ }
+
+ let r = sqlx::query!(
+ "INSERT INTO translation_reviews (project, owner, title, description, base, head) VALUES (?, ?, ?, ?, ?, ?)",
+ projectid,
+ session.user_id,
+ title,
+ data.description,
+ base,
+ base,
+ )
+ .execute(&mut *tx)
+ .map_err(|e| Custom(Status::InternalServerError, format!("Database error: {e:?}")))
+ .await?;
+
+ translation_reviewid = r.last_insert_id();
+
+ tx.commit().await.unwrap();
+ }
+
+ roots_state
+ .new_translation_review(db, projectid, translation_reviewid, base.as_str())
+ .map_err(|e| Custom(Status::InternalServerError, format!("{e}")))
+ .await?;
+
+ Ok(
+ get_translation_review(conn, projectid, translation_reviewid)
+ .await
+ .unwrap(),
+ )
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Get all translation reviews for project", body = api_model::TranslationReviews),
+ (status = 404, description = "No such project"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[get("/project/<projectid>/translations?<limit>&<offset>")]
+async fn translation_reviews(
+ mut db: Connection<Db>,
+ _session: auth::Session,
+ projectid: &str,
+ limit: Option<u32>,
+ offset: Option<u32>,
+) -> Result<Json<api_model::TranslationReviews>, NotFound<&'static str>> {
+ let uw_offset = offset.unwrap_or(0);
+ let uw_limit = limit.unwrap_or(10);
+ let entries = sqlx::query!(
+ "SELECT translation_reviews.id AS id,title,state,progress,base,head,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM translation_reviews JOIN users ON users.id=owner WHERE project=? ORDER BY id DESC LIMIT ? OFFSET ?",
+ projectid, uw_limit, uw_offset)
+ .fetch(&mut **db)
+ .map_ok(|r| api_model::TranslationReviewEntry {
+ id: r.id,
+ title: r.title,
+ owner: api_model::User {
+ id: r.user_id,
+ name: r.name,
+ active: r.user_dn.is_some(),
+ },
+ state: api_model::ReviewState::try_from(r.state).unwrap(),
+ progress: r.progress,
+ base: r.base,
+ head: r.head,
+ })
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let count = sqlx::query!(
+ "SELECT COUNT(id) AS count FROM translation_reviews WHERE project=?",
+ projectid
+ )
+ .fetch_one(&mut **db)
+ .map_ok(|r| r.count)
+ .await
+ .unwrap();
+
+ if count == 0 {
+ let projects = sqlx::query!(
+ "SELECT COUNT(id) AS count FROM projects WHERE id=?",
+ projectid
+ )
+ .fetch_one(&mut **db)
+ .map_ok(|r| r.count)
+ .await
+ .unwrap();
+ if projects == 0 {
+ return Err(NotFound("No such project"));
+ }
+ }
+
+ let u32_count = u32::try_from(count).unwrap();
+
+ Ok(Json(api_model::TranslationReviews {
+ offset: uw_offset,
+ limit: uw_limit,
+ total_count: u32_count,
+ more: uw_offset + uw_limit < u32_count,
+ reviews: entries,
+ }))
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Get all strings for a translation review", body = api_model::LocalizationStrings),
+ (status = 404, description = "No such translation review"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[get("/translation/<translation_reviewid>/strings?<limit>&<offset>")]
+async fn translation_review_strings(
+ mut db: Connection<Db>,
+ _session: auth::Session,
+ translation_reviewid: u64,
+ limit: Option<u32>,
+ offset: Option<u32>,
+) -> Result<Json<api_model::LocalizationStrings>, NotFound<&'static str>> {
+ let uw_offset = offset.unwrap_or(0);
+ let uw_limit = limit.unwrap_or(10);
+ let (ids, mut entries) = sqlx::query!(
+ "SELECT id,name,file,description,meaning,source,placeholder_offsets FROM localization_strings WHERE translation_review=? ORDER BY id ASC LIMIT ? OFFSET ?",
+ translation_reviewid, uw_limit, uw_offset)
+ .fetch(&mut **db)
+ .try_fold((Vec::new(), Vec::new()), async move |mut vecs, r| {
+ vecs.0.push(r.id);
+ vecs.1.push(api_model::LocalizationString {
+ id: r.name,
+ file: r.file,
+ description: r.description,
+ meaning: r.meaning,
+ source: r.source,
+ placeholders: Vec::new(),
+ placeholder_offset: r.placeholder_offsets.split_terminator(',').map(|x| x.parse::<usize>().unwrap()).collect(),
+ translations: Vec::new(),
+ });
+ Ok(vecs)
+ })
+ .await
+ .unwrap();
+
+ for (i, entry) in entries.iter_mut().enumerate() {
+ if !entry.placeholder_offset.is_empty() {
+ entry.placeholders = sqlx::query!(
+ "SELECT name,content,example FROM localization_placeholders WHERE localization_string=? ORDER BY id ASC",
+ ids[i])
+ .fetch(&mut **db)
+ .map_ok(|r| api_model::LocalizationPlaceholder {
+ id: r.name,
+ content: r.content,
+ example: r.example,
+ })
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ }
+
+ entry.translations = sqlx::query!(
+ "SELECT language,head_translation,head_placeholder_offsets,state,comment FROM translation_strings WHERE localization_string=? ORDER BY language ASC",
+ ids[i])
+ .fetch(&mut **db)
+ .map_ok(|r| api_model::TranslationString {
+ language: r.language,
+ translation: r.head_translation,
+ placeholder_offset: r.head_placeholder_offsets.split_terminator(',').map(|x| x.parse::<usize>().unwrap()).collect(),
+ state: api_model::TranslationState::try_from(r.state).unwrap(),
+ comment: r.comment,
+ // TODO
+ reviewer: None,
+ })
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ }
+
+ let count = sqlx::query!(
+ "SELECT COUNT(id) AS count FROM localization_strings WHERE translation_review=?",
+ translation_reviewid
+ )
+ .fetch_one(&mut **db)
+ .map_ok(|r| r.count)
+ .await
+ .unwrap();
+
+ if count == 0 {
+ let reviews = sqlx::query!(
+ "SELECT COUNT(id) AS count FROM translation_reviews WHERE id=?",
+ translation_reviewid
+ )
+ .fetch_one(&mut **db)
+ .map_ok(|r| r.count)
+ .await
+ .unwrap();
+ if reviews == 0 {
+ return Err(NotFound("No such translation review"));
+ }
+ }
+
+ let u32_count = u32::try_from(count).unwrap();
+
+ Ok(Json(api_model::LocalizationStrings {
+ offset: uw_offset,
+ limit: uw_limit,
+ total_count: u32_count,
+ more: uw_offset + uw_limit < u32_count,
+ strings: entries,
+ }))
+}
+
async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
match Db::fetch(&rocket) {
Some(db) => match sqlx::migrate!().run(&**db).await {
@@ -1158,6 +1494,9 @@ fn rocket_from_config(figment: Figment) -> Rocket<Build> {
user_key_get,
user_key_del,
user_keys,
+ translation_review_new,
+ translation_review_strings,
+ translation_reviews,
],
)
.attach(auth::stage(basepath))