forked from yvv/decision
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>
172 lines
6.5 KiB
Rust
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 })))
|
|
}
|