Files
decision/crates/ld-server/src/doc_title.rs
T
YvvandClaude Opus 5.5 3831ddb1bf v3 : modules d'ordre et de titre — moteur, données, serveur (L11)
L'ordre des articles de chaque section (cran 2) et le titre du document
(cran 4) deviennent des modules : des unités à part, dont les versions
sont des propositions votées comme les articles (dépliées, panier, chaîne).

- moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé,
  complété à la fin quand un article entre ou sort, places redistribuées,
  nouvel article en fin de section) ; pending : un ordre de moins de deux
  articles n'est jamais à voter ;
- données : migration 0007 (units.kind, module_amendments) ; le seed
  déclare ses modules ; module_amend.rs les ajoute une fois, gardé et
  journalisé, aux documents nés sans eux, sans toucher articles ni votes ;
- serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer,
  entrée en vigueur : les articles changent de place, le document change
  de titre, en et es passent « à revoir ») ; publication des modules ;
- reste mineur : la médiane compte les statuts open, in_force, applied,
  et eux seuls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:08:44 +01:00

172 lines
6.5 KiB
Rust

//! 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<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<TitleBody>,
) -> AppResult<Json<Value>> {
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 })))
}