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>
This commit is contained in:
Yvv
2026-10-31 02:08:44 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 3831ddb1bf
21 changed files with 2249 additions and 115 deletions
+28 -41
View File
@@ -15,8 +15,9 @@ use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::ballots;
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
use crate::error::{AppError, AppResult};
use crate::modules;
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
@@ -162,6 +163,9 @@ pub async fn assist(
pub struct CreateBody {
kind: Option<String>,
unit_id: Option<String>,
/// The section a new article goes in, at its end (ADR 0009).
section: Option<String>,
/// Or an article of that section (an older client).
after_unit_id: Option<String>,
title: String,
#[serde(default)]
@@ -170,27 +174,16 @@ pub struct CreateBody {
submit: bool,
}
/// Position right after `after` (or at the end), between it and the next unit.
fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 {
let mut positions: Vec<f64> = units.iter().map(|u| u.position).collect();
positions.sort_by(f64::total_cmp);
let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else {
return positions.last().copied().unwrap_or(0.0) + 1.0;
};
match positions.iter().find(|p| **p > anchor) {
Some(next) => (anchor + next) / 2.0,
None => anchor + 1.0,
}
}
async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult<String> {
/// A new article goes at the end of its section (ADR 0009): the order of the
/// section is a module of its own, voted apart.
async fn new_unit(state: &AppState, st: &DocState, section: Option<&str>) -> AppResult<String> {
let id = util::id();
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
.bind(&st.doc.id)
.fetch_one(&state.db)
.await?;
// A new article belongs to the section of the one it follows.
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
let places: Vec<(Option<&str>, f64)> = st.articles().map(|u| (u.section.as_deref(), u.position)).collect();
let position = ld_engine::order::place_at_end(&places, section);
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
@@ -199,7 +192,7 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(section)
.bind(position_after(&st.units, after))
.bind(position)
.bind(InertiaLevel::DEFAULT.as_db())
.bind(util::now())
.execute(&state.db)
@@ -260,12 +253,22 @@ pub async fn create(
let unit_id = match b.unit_id.as_deref() {
Some(u) => {
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
modules::require_article(&st, &unit.id)?;
if kind == "remove" && unit.in_force_id.is_none() {
return Err(AppError::Invalid("nothing_to_remove"));
}
unit.id.clone()
}
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
None if kind == "text" => {
// A new article belongs to the section asked for, or to that of the article named.
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
let section = match b.section.as_deref() {
Some(s) if st.sections().iter().any(|x| x == s) => Some(s),
Some(_) => return Err(AppError::Invalid("section")),
None => after.and_then(|u| u.section.as_deref()),
};
new_unit(&state, &st, section).await?
}
None => return Err(AppError::Invalid("unit")),
};
let id = insert_proposal(
@@ -297,6 +300,7 @@ pub async fn edit(
Json(b): Json<EditBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
modules::require_text(&p)?;
check_text(&b.title, &b.body, p.kind == "text")?;
let (title, body) = (b.title.trim(), b.body.trim());
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
@@ -477,7 +481,8 @@ pub struct SplitBody {
}
/// One draft becomes several: the first part stays, each other part becomes a
/// draft on a new unit placed right after. Drafts only — nobody voted yet.
/// draft on a new unit at the end of its section, in turn. Drafts only — nobody
/// voted yet.
pub async fn split(
State(state): State<AppState>,
User(user): User,
@@ -505,10 +510,10 @@ pub async fn split(
.execute(&state.db)
.await?;
let mut created = vec![id.clone()];
let mut after = p.unit_id.clone();
let section = st.articles().find(|u| u.id == p.unit_id).and_then(|u| u.section.clone());
for (n, part) in parts.iter().enumerate().skip(1) {
let fresh = docs::load_state(&state, &st.doc.id).await?;
let unit = new_unit(&state, &fresh, Some(&after)).await?;
let unit = new_unit(&state, &fresh, section.as_deref()).await?;
let title = format!("{} ({})", p.title, n + 1);
let title: String = title.chars().take(MAX_TITLE).collect();
created.push(
@@ -526,7 +531,6 @@ pub async fn split(
)
.await?,
);
after = unit;
}
Ok(Json(json!({ "drafts": created })))
}
@@ -835,6 +839,7 @@ pub async fn translate(
Json(b): Json<EditBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
modules::require_text(&p)?;
if !util::is_lang(&lang) || lang == st.doc.source_lang {
return Err(AppError::Invalid("lang"));
}
@@ -860,21 +865,3 @@ pub async fn translate(
}
Ok(Json(json!({ "ok": true })))
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(id: &str, position: f64) -> UnitRow {
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None }
}
#[test]
fn new_units_slot_between_neighbours() {
let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)];
assert_eq!(position_after(&units, Some("a")), 1.5);
assert_eq!(position_after(&units, Some("c")), 5.0);
assert_eq!(position_after(&units, None), 5.0);
assert_eq!(position_after(&[], None), 1.0);
}
}