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
+240
View File
@@ -0,0 +1,240 @@
//! Modules — what a document decides besides its articles (ADR 0009).
//!
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
//! changer l'ordre des "articles". Lui-même à (2) » ; « Le titre du doc à (4) »
//! — and, asked whether the title of an article is voted apart: « Non, titre et
//! texte ensemble ». So two modules, each a unit of its own kind:
//!
//! - the **title of the document** (`title`, level 4): its version in force is
//! the title in the source language (`doc_title`);
//! - the **order of the articles of each section** (`order`, level 2): its
//! version in force is the order the section reads in now (`order`).
//!
//! A module is voted like an article — unfolded, in the basket, on the Ğ1
//! chain — because its versions are proposals like any other: the same
//! threshold, the same margin over the version in force, the same seed a
//! minima at the start. What differs is only what entering into force does.
//!
//! A new document gets its modules from the seed (`modules` in the seed file);
//! an existing one, once, from what it holds (`module_amend`). Both go through
//! `create`, which reads the document as it is and touches no article and no
//! vote.
use crate::docs::{DocState, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::seed::SeedModules;
use crate::util;
use crate::{doc_title, order};
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// The code of the title module of a document.
pub const TITLE_CODE: &str = "titre";
/// The code of the order module of a section.
pub fn order_code(section: &str) -> String {
format!("ordre.{section}")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModuleKind {
Title,
Order,
}
impl ModuleKind {
/// `units.kind`; an article is no module.
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"title" => Some(Self::Title),
"order" => Some(Self::Order),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Title => "title",
Self::Order => "order",
}
}
}
/// The levels a document's modules start at; `None`: no such module.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Levels {
pub title: Option<InertiaLevel>,
pub order: Option<InertiaLevel>,
}
impl From<&SeedModules> for Levels {
fn from(m: &SeedModules) -> Self {
Self { title: m.title.as_ref().map(|t| t.inertia), order: m.order.as_ref().map(|o| o.inertia) }
}
}
/// A version of a module, written directly in force (a seed a minima) or open.
pub(crate) struct NewVersion<'a> {
pub unit_id: &'a str,
pub kind: ModuleKind,
pub title: &'a str,
pub body: &'a str,
pub author: Option<&'a str>,
pub in_force: bool,
pub now: &'a str,
}
pub(crate) async fn insert_version(conn: &mut SqliteConnection, v: NewVersion<'_>) -> sqlx::Result<String> {
let id = util::id();
let (status, origin) = if v.in_force { ("in_force", "seed") } else { ("open", "member") };
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, ?, ?, ?)",
)
.bind(&id)
.bind(v.unit_id)
.bind(v.kind.as_str())
.bind(v.title)
.bind(v.body)
.bind(status)
.bind(origin)
.bind(v.author)
.bind(v.now)
.bind(v.now)
.bind(v.now)
.execute(&mut *conn)
.await?;
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
VALUES (?, 1, 'initial', ?, ?, ?, ?)",
)
.bind(&id)
.bind(v.title)
.bind(v.body)
.bind(v.author)
.bind(v.now)
.execute(&mut *conn)
.await?;
if v.in_force {
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
.bind(&id)
.bind(v.unit_id)
.execute(&mut *conn)
.await?;
}
Ok(id)
}
async fn insert_unit(
conn: &mut SqliteConnection,
doc_id: &str,
kind: ModuleKind,
code: &str,
section: Option<&str>,
level: InertiaLevel,
now: &str,
) -> sqlx::Result<String> {
let id = util::id();
// Its place is not among the articles: the reader puts each module where it belongs.
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at, kind)
VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?)",
)
.bind(&id)
.bind(doc_id)
.bind(code)
.bind(section)
.bind(level.as_db())
.bind(now)
.bind(kind.as_str())
.execute(&mut *conn)
.await?;
Ok(id)
}
/// Creates the modules of a document from what it holds now — its title, and
/// the order each of its sections reads in — each in force as a seed a minima.
/// No article, no vote is touched. Returns the codes created.
pub async fn create(conn: &mut SqliteConnection, doc_id: &str, levels: Levels, now: &str) -> sqlx::Result<Vec<String>> {
let mut created = Vec::new();
if let Some(level) = levels.title {
let (raw, src): (String, String) = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
.bind(doc_id)
.fetch_one(&mut *conn)
.await?;
let titles = util::parse_lang(&raw);
let title = titles.get(&src).cloned().unwrap_or_default();
let unit = insert_unit(conn, doc_id, ModuleKind::Title, TITLE_CODE, None, level, now).await?;
let pid = insert_version(
conn,
NewVersion {
unit_id: &unit,
kind: ModuleKind::Title,
title: &title,
body: "",
author: None,
in_force: true,
now,
},
)
.await?;
doc_title::seed_translations(conn, &pid, &title, &titles, &src, now).await?;
created.push(TITLE_CODE.to_owned());
}
if let Some(level) = levels.order {
for (section, members) in order::sections_now(conn, doc_id).await? {
let code = order_code(&section);
let unit = insert_unit(conn, doc_id, ModuleKind::Order, &code, Some(&section), level, now).await?;
let body = ld_engine::order::canonical(&members);
insert_version(
conn,
NewVersion {
unit_id: &unit,
kind: ModuleKind::Order,
title: &code,
body: &body,
author: None,
in_force: true,
now,
},
)
.await?;
created.push(code);
}
}
Ok(created)
}
/// What entering into force does, besides the version taking its place: the
/// order of a section moves its articles, the title of a document becomes its
/// title. Returns the kind kept in `adoptions` (`text` for an article).
pub async fn on_entry(
conn: &mut SqliteConnection,
st: &DocState,
unit: &UnitRow,
p: &ProposalRow,
) -> AppResult<&'static str> {
match st.module_of(&unit.id) {
None => Ok("text"),
Some(ModuleKind::Order) => {
order::enter(conn, st, unit, p).await?;
Ok("order")
}
Some(ModuleKind::Title) => {
doc_title::enter(conn, &st.doc.id, unit, p).await?;
Ok("title")
}
}
}
/// The generic writing of a version stops at a module: one proposes a module
/// through its own door (`order::propose`, `doc_title::propose`).
pub fn require_article(st: &DocState, unit_id: &str) -> AppResult<()> {
if st.module_of(unit_id).is_some() { Err(AppError::Invalid("module")) } else { Ok(()) }
}
/// An order is never corrected nor translated: another order is another proposal.
pub fn require_text(p: &ProposalRow) -> AppResult<()> {
if p.kind == ModuleKind::Order.as_str() { Err(AppError::Conflict("order_fixed")) } else { Ok(()) }
}