//! The title of a document, a module (ADR 0009, level 4). //! //! Its version in force is the title in the source language, the one that //! holds. `documents.title` keeps the title as every list and the publication //! read it: the source language from the version in force, the other languages //! from its editorial translations (`sync`). //! //! When another title enters into force, the document changes title in the //! source language. Its translations stay editorial (Yvv, 2026-10-21: the //! French is voted, the translations are not): the new version takes those of //! the title it replaces, pinned to that title's text, so they read « à //! revoir » until a member translates the new one. use crate::AppState; use crate::auth::User; use crate::docs::{self, ProposalRow, UnitRow}; use crate::error::{AppError, AppResult}; use crate::modules::{self, ModuleKind, NewVersion}; use crate::util::{self, LangMap}; use axum::Json; use axum::extract::{Path, State}; use ld_engine::edit; use serde::Deserialize; use serde_json::{Value, json}; use sqlx::SqliteConnection; /// The longest title, as for an article. const MAX_TITLE: usize = 120; /// The translations of the title a document starts with, from its seed or its row. pub async fn seed_translations( conn: &mut SqliteConnection, proposal_id: &str, title: &str, titles: &LangMap, src: &str, now: &str, ) -> sqlx::Result<()> { let hash = util::source_hash(title, ""); for lang in util::LANGS.iter().filter(|l| **l != src) { let Some(t) = titles.get(*lang).filter(|t| !t.trim().is_empty()) else { continue }; sqlx::query( "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) VALUES (?, ?, ?, '', ?, 'seed', NULL, ?)", ) .bind(proposal_id) .bind(lang) .bind(t) .bind(&hash) .bind(now) .execute(&mut *conn) .await?; } Ok(()) } /// `documents.title` from the title in force and its translations; a language /// the version in force does not translate keeps the title it had. pub async fn sync_conn(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<()> { let row: Option<(String, String, String, String)> = sqlx::query_as( "SELECT p.id, p.title, d.title, d.source_lang FROM units u JOIN proposals p ON p.id = u.in_force_id JOIN documents d ON d.id = u.doc_id WHERE u.doc_id = ? AND u.kind = 'title'", ) .bind(doc_id) .fetch_optional(&mut *conn) .await?; let Some((pid, title, raw, src)) = row else { return Ok(()) }; let mut titles = util::parse_lang(&raw); titles.insert(src, title); let translated: Vec<(String, String)> = sqlx::query_as("SELECT lang, title FROM translations WHERE proposal_id = ?") .bind(&pid) .fetch_all(&mut *conn) .await?; for (lang, t) in translated { titles.insert(lang, t); } let next = util::lang_json(&titles); if next != raw { sqlx::query("UPDATE documents SET title = ? WHERE id = ?").bind(&next).bind(doc_id).execute(&mut *conn).await?; } Ok(()) } /// After any change to a text in force (a typo, a translation, an entry): the /// document's title follows its title in force. pub async fn sync(state: &AppState, doc_id: &str) -> sqlx::Result<()> { let mut conn = state.db.acquire().await?; sync_conn(&mut conn, doc_id).await } /// Another title enters into force: it takes the translations of the one it /// replaces where it has none — « à revoir », pinned to the former title — and /// becomes the document's title. pub async fn enter(conn: &mut SqliteConnection, doc_id: &str, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> { if let Some(before) = unit.in_force_id.as_deref().filter(|b| *b != p.id) { sqlx::query( "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) SELECT ?1, t.lang, t.title, t.body, t.source_hash, t.origin, t.updated_by, t.updated_at FROM translations t WHERE t.proposal_id = ?2 AND NOT EXISTS (SELECT 1 FROM translations n WHERE n.proposal_id = ?1 AND n.lang = t.lang)", ) .bind(&p.id) .bind(before) .execute(&mut *conn) .await?; } sync_conn(conn, doc_id).await?; tracing::info!(doc = %doc_id, proposal = %p.id, "title of the document in force"); Ok(()) } #[derive(Deserialize)] pub struct TitleBody { title: String, } /// Propose another title for the document. An open proposal of the same title /// is given back rather than doubled. pub async fn propose( State(state): State, User(user): User, Path(unit_id): Path, Json(b): Json, ) -> AppResult> { let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?") .bind(&unit_id) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound)?; let st = docs::load_state(&state, &doc_id).await?; docs::require_open(&state, &user, &st.group).await?; let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?; if st.module_of(&unit.id) != Some(ModuleKind::Title) { return Err(AppError::Invalid("not_a_title")); } if edit::has_hidden_controls(&b.title) { return Err(AppError::Invalid("hidden_characters")); } let title = b.title.trim(); if title.is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) { return Err(AppError::Invalid("title")); } let in_force = unit.in_force_id.as_deref().and_then(|id| st.proposals.iter().find(|p| p.id == id)); if in_force.is_some_and(|p| p.title == title) { return Err(AppError::Invalid("same_title")); } if let Some(existing) = st.proposals.iter().find(|p| { p.unit_id == unit.id && p.status == "open" && p.kind == ModuleKind::Title.as_str() && p.title == title }) { return Ok(Json(json!({ "id": existing.id, "existing": true }))); } let now = util::now(); let mut conn = state.db.acquire().await?; let id = modules::insert_version( &mut conn, NewVersion { unit_id: &unit.id, kind: ModuleKind::Title, title, body: "", author: Some(&user.id), in_force: false, now: &now, }, ) .await?; Ok(Json(json!({ "id": id, "existing": false }))) }