v3 : troisième relecture — une coquille n'inverse plus un sens ; retour à la version précédente

- Mot mal tapé = un seul glissement (une lettre ajoutée, retirée, ou deux
  voisines inversées), jamais au début du mot (préfixes : valide/invalide,
  limitée/illimitée), jamais une lettre remplacée (deux/dix, majorité/minorité),
  jamais sur un mot de négation ou de quantité, mots de 4 lettres au moins.
- Parenthèses, crochets, « / » et tout ce qui est entre accents graves comptent
  comme le sens (ceil(N^(1/5)) → ceil(N^1/5) n'est plus une coquille).
- Caractères invisibles (inversion du sens d'écriture, largeur nulle, BOM)
  refusés dans les textes, titres, traductions et motifs de signalement.
- Déploiement : la version en service est gardée (image « previous ») ; un
  témoin marque le déploiement en cours ; « retablir » relance la maquette au
  premier déploiement, la version précédente ensuite ; la liste des conteneurs
  retirés n'est plus vidée par un pipeline annulé.
- Tests : 59 Rust (dont 14 scénarios d'API, avec deux → dix sur E2 en vigueur
  et l'inversion bidi), e2e 12 / 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 05:21:08 +02:00
co-authored by Claude Opus 5.5
parent ac9c904e7e
commit 9203fd2501
10 changed files with 331 additions and 47 deletions
+32 -17
View File
@@ -34,6 +34,8 @@ steps:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
# La version en service est gardée : « retablir » la relance si ce déploiement échoue.
- docker image inspect libredecision-app:latest >/dev/null 2>&1 && docker tag libredecision-app:latest libredecision-app:previous || true
- docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD .
- echo "Image app construite ($BUILD)"
@@ -99,6 +101,8 @@ steps:
- /opt/libredecision-app:/opt/libredecision-app
commands:
- DOMAIN=decision.librodrome.org
# Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface.
- touch /opt/libredecision-app/deploying
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
@@ -106,11 +110,11 @@ steps:
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
# recréée par son propre workflow, sous son nouveau domaine.
# Ceux qui tournaient sont notés dans /opt/libredecision-app/retires : si la suite
# échoue, l'étape « retablir » les relance (et seulement eux).
# Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
# un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
- |
RETIRES=/opt/libredecision-app/retires
: > "$RETIRES"
touch "$RETIRES"
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
NAME=$(docker inspect --format '{{.Name}}' "$C")
@@ -182,6 +186,7 @@ steps:
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
echo "PASS: $BUILD repond sur $TARGET"
: > /opt/libredecision-app/retires
rm -f /opt/libredecision-app/deploying
exit 0 ;;
esac
i=$((i+1))
@@ -190,9 +195,10 @@ steps:
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
exit 1
# Échec après le retrait de la racine : le produit s'arrête, ce qu'il avait retiré
# repart (restart: always rendu). Échec avant (construction, tests) : rien à faire,
# la liste est vide depuis le dernier déploiement réussi.
# Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
# Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
# racine repart (restart: always rendu). Échec après, plus tard : la version précédente du
# produit (image « previous ») est relancée.
# NOTE: volumes + pas de from_secret : compatible
- name: retablir
image: docker:27-cli
@@ -205,17 +211,26 @@ steps:
- /opt/libredecision-app:/opt/libredecision-app
commands:
- |
RETIRES=/opt/libredecision-app/retires
if [ ! -s "$RETIRES" ]; then echo "Rien a retablir"; exit 0; fi
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
for C in $(cat "$RETIRES"); do
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
done
: > "$RETIRES"
D=/opt/libredecision-app
if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi
if [ -s "$D/retires" ]; then
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
for C in $(cat "$D/retires"); do
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
done
: > "$D/retires"
elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then
echo "Retour a la version precedente du produit"
docker tag libredecision-app:previous libredecision-app:latest
cd "$D" && docker compose up -d --force-recreate
else
echo "Aucune version precedente a relancer"
fi
rm -f "$D/deploying"
when:
- status: failure
+8 -4
View File
@@ -7,7 +7,7 @@
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 57 tests dont 14 scénarios d'API et la
- Boucle : Rust (fmt, clippy `-D warnings`, 59 tests dont 14 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement :
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
@@ -15,11 +15,15 @@
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la
racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
- Relecture indépendante avant mise en ligne (`1f9c241 : ok`) ; ses points « à corriger
ensuite » ont été traités avant de livrer : ordre des workflows, anciens conteneurs en
- Trois relectures indépendantes avant mise en ligne (`1f9c241 : ok`, `94907af : ok`,
`ac9c904 : à reprendre`), chacune traitée avant de livrer. La troisième a montré qu'une
« coquille » pouvait encore inverser un sens (« valide » → « invalide », « deux » → « dix »,
caractères invisibles) : la règle a été resserrée (voir CHANTIER.md). Les autres points : ordre des workflows, anciens conteneurs en
`restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une personne,
une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant usage,
un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement.
un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement,
cookies préfixés `__Host-`, retour arrière automatique du déploiement (maquette au premier,
version précédente ensuite), syft épinglé.
## Ce qui attend Yvv
+245 -17
View File
@@ -4,9 +4,11 @@
//! tells two kinds of corrections apart:
//!
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and
//! never a figure, a sign, a separator inside a number or a relation
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff.
//! two misspelt words fixed by a single slip each (one letter added, removed
//! or swapped, never at the start of the word, never on a negation or a
//! quantity) — and never a figure, a sign, a separator inside a number, a
//! relation, a bracket or anything between backticks. The votes stay; the
//! correction is shown with its diff.
//! - **substantive**: anything else. It opens a new revision and every vote
//! cast on the previous one waits for its author to reconfirm it.
//!
@@ -32,8 +34,154 @@ const NEGATIONS: &[&str] = &[
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada",
];
/// Words that carry a quantity or a scope (folded); changing one is never a typo.
const QUANTITIES: &[&str] = &[
// fr
"un",
"une",
"deux",
"trois",
"quatre",
"cinq",
"six",
"sept",
"huit",
"neuf",
"dix",
"onze",
"douze",
"treize",
"quatorze",
"quinze",
"seize",
"vingt",
"trente",
"quarante",
"cinquante",
"soixante",
"septante",
"huitante",
"octante",
"nonante",
"cent",
"cents",
"mille",
"million",
"millions",
"milliard",
"milliards",
"premier",
"premiere",
"premiers",
"premieres",
"second",
"seconde",
"deuxieme",
"troisieme",
"dernier",
"derniere",
"double",
"triple",
"moitie",
"tiers",
"quart",
"moins",
"minimum",
"maximum",
"majorite",
"minorite",
"unanimite",
"tous",
"toutes",
"tout",
"toute",
"chaque",
"seul",
"seule",
"seuls",
"seules", // en
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"twenty",
"thirty",
"forty",
"fifty",
"sixty",
"hundred",
"thousand",
"million",
"first",
"second",
"third",
"last",
"half",
"twice",
"less",
"more",
"least",
"most",
"majority",
"minority",
"all",
"every",
"each",
"only", // es
"uno",
"dos",
"tres",
"cuatro",
"cinco",
"seis",
"siete",
"ocho",
"nueve",
"diez",
"once",
"doce",
"veinte",
"treinta",
"cuarenta",
"cincuenta",
"cien",
"ciento",
"mil",
"millon",
"primero",
"primera",
"segundo",
"segunda",
"ultimo",
"ultima",
"mitad",
"doble",
"menos",
"mas",
"minimo",
"maximo",
"mayoria",
"minoria",
"todos",
"todas",
"todo",
"toda",
"cada",
"solo",
"sola",
];
const MAX_TYPO_WORDS: usize = 2;
const MAX_TYPO_DISTANCE: usize = 2;
/// Shorter words have too many neighbours one letter away ("can" / "cant").
const MIN_TYPO_WORD: usize = 4;
/// Lowercase, strip diacritics.
fn fold(s: &str) -> String {
@@ -49,14 +197,56 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
}
/// Symbols that carry a value or a relation, wherever they stand.
const RELATIONS: &[char] =
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√'];
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10").
const SIGNS: &[char] = &['-', '−', '–', '/'];
/// Symbols that carry a value, a relation or a grouping, wherever they stand.
const RELATIONS: &[char] = &[
'+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√', '/', '(', ')', '[',
']', '{', '}', '`',
];
/// Signs: meaningful next to a figure ("-5", "5-10").
const SIGNS: &[char] = &['-', '−', '–'];
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
/// Source"). A text of reference never needs them; they are refused.
pub fn has_hidden_controls(s: &str) -> bool {
s.chars().any(
|c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF),
)
}
/// What stands between backticks: formulas and identifiers, compared exactly.
fn code_spans(s: &str) -> Vec<&str> {
s.split('`').skip(1).step_by(2).collect()
}
/// A single slip of the keyboard in a word: one letter added, removed, or two
/// neighbours swapped — never at its start, where prefixes turn a meaning
/// around ("valide" / "invalide", "limitée" / "illimitée"), and never a letter
/// replaced by another ("deux" / "dix", "majorité" / "minorité").
fn one_slip(x: &str, y: &str) -> bool {
let (a, b): (Vec<char>, Vec<char>) = (x.chars().collect(), y.chars().collect());
if a.len().min(b.len()) < MIN_TYPO_WORD || a[..2] != b[..2] {
return false;
}
match a.len().abs_diff(b.len()) {
1 => {
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
(2..long.len()).any(|i| {
let mut l = long.clone();
l.remove(i);
&l == short
})
}
0 => {
let at: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
at.len() == 2 && at[1] == at[0] + 1 && a[at[0]] == b[at[1]] && a[at[1]] == b[at[0]]
}
_ => false,
}
}
/// What the text says in figures and symbols, as written. Two texts with the
/// same letters but a different figure, sign, separator inside a number or
/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not
@@ -87,7 +277,7 @@ pub fn classify(old: &str, new: &str) -> EditKind {
return EditKind::Unchanged;
}
// A figure, a sign or a relation is never a typo: it changes what the text says.
if figures(old) != figures(new) {
if figures(old) != figures(new) || code_spans(old) != code_spans(new) {
return EditKind::Substantive;
}
if skeleton(old) == skeleton(new) {
@@ -104,12 +294,10 @@ pub fn classify(old: &str, new: &str) -> EditKind {
}
changed += 1;
let touches_meaning = changed > MAX_TYPO_WORDS
|| x.chars().any(|c| c.is_ascii_digit())
|| y.chars().any(|c| c.is_ascii_digit())
|| NEGATIONS.contains(&x.as_str())
|| NEGATIONS.contains(&y.as_str())
|| x.chars().count().min(y.chars().count()) < 3
|| levenshtein(x, y) > MAX_TYPO_DISTANCE;
|| x.chars().any(char::is_numeric)
|| y.chars().any(char::is_numeric)
|| [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|| !one_slip(x, y);
if touches_meaning {
return EditKind::Substantive;
}
@@ -251,14 +439,54 @@ mod tests {
// Punctuation around figures, list markers, dashes between words stay typographic.
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Typographic);
// A bracket always counts, even as a list marker: conservative.
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
assert_eq!(classify("1. premier point", "1. premier point."), EditKind::Typographic);
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
}
#[test]
fn a_word_turned_around_is_never_a_typo() {
// Third independent review, 2026-10-21: one or two letters that invert a meaning.
for (old, new) in [
("ces deux présents engagements", "ces dix présents engagements"),
("Nouveau compte valide si", "Nouveau compte invalide si"),
("100 certifications possibles", "100 certifications impossibles"),
("à la majorité des voix", "à la minorité des voix"),
("un minimum de garanties", "un maximum de garanties"),
("les membres inclus", "les membres exclus"),
("une personne majeure", "une personne mineure"),
("une durée limitée", "une durée illimitée"),
("un mandat révocable", "un mandat irrévocable"),
("des comptes indépendants", "des comptes dépendants"),
("pendant six mois", "pendant dix mois"),
("sept certifications", "cent certifications"),
("seize jours", "treize jours"),
("five members", "nine members"),
("you can certify", "you cant certify"),
("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"),
("M(n)/N(n)", "M(n)N(n)"),
("DU(n) - c", "DU(n) / c"),
("la formule `a - b`", "la formule `a * b`"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
}
#[test]
fn hidden_controls_are_spotted() {
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
assert!(has_hidden_controls("5\u{200b}0"));
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
}
#[test]
fn misspelt_words() {
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic);
// A letter replaced by another is not a slip: the author revises.
assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive);
// « personne » is also a negation (ne … personne): conservative.
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
}
+4 -1
View File
@@ -59,6 +59,9 @@ async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> App
}
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
return Err(AppError::Invalid("hidden_characters"));
}
if title.trim().is_empty() || title.chars().count() > MAX_TITLE {
return Err(AppError::Invalid("title"));
}
@@ -647,7 +650,7 @@ pub async fn signal(
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON {
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason"));
}
if b.target_id == id {
+21
View File
@@ -230,6 +230,27 @@ async fn editing_is_not_voting() {
.await;
assert_eq!(s, StatusCode::CONFLICT, "{v}");
assert_eq!(v["error"], "substantive_not_author");
// One word turned around in a text in force, and an invisible bidi override: refused.
let e2 = unit(&doc, "E2")["inForce"].clone();
let e2_id = e2["id"].as_str().expect("id").to_owned();
let body = e2["body"].as_str().expect("body").to_owned();
assert!(body.contains("ces deux présents"));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
// A member proposal: typo keeps votes, substance asks to reconfirm.
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
+8 -4
View File
@@ -4,11 +4,15 @@
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté.
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) :
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre ou une relation
change (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - ») ;
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre, une relation ou
une parenthèse change, ni si quoi que ce soit change entre accents graves (formules) ;
- **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si
au plus 2 mots diffèrent de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas,
jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ;
au plus 2 mots diffèrent chacun d'un seul glissement de clavier — une lettre ajoutée, retirée
ou deux voisines inversées, jamais au début du mot (les préfixes inversent le sens), jamais une
lettre remplacée, jamais sur un mot de négation ou de quantité ; elle s'applique sans vote,
garde les votes et les traductions ;
- les caractères invisibles de mise en forme (inversion du sens d'écriture, largeur nulle) sont
refusés partout où l'on écrit un texte ;
- **de fond** sinon : nouvelle révision d'une proposition ouverte (par son auteur), votes
antérieurs « à reconfirmer », signalements levés ; refusée sur un texte en vigueur.
L'éditeur montre le verdict avant d'enregistrer. Les brouillons s'éditent librement.
+10 -4
View File
@@ -76,13 +76,19 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p
| Geste | Qui | Effet sur les votes |
|---|---|---|
| Corriger une **coquille** (accents, casse, ponctuation, espaces ; ou jusqu'à 2 mots à 2 lettres près) | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour |
| Corriger une **coquille** (accents, casse, ponctuation, espaces ; ou jusqu'à 2 mots mal tapés, une lettre ajoutée, retirée ou inversée chacun) | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour |
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
Le classement est prudent : un chiffre, un signe, un séparateur dans un nombre ou une relation
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », « % »)
ou un mot de négation touché (ne, pas, jamais, personne…) rendent la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`.
Le classement est prudent — trois relectures indépendantes l'ont éprouvé. Sont toujours « de
fond » : un chiffre, un signe, un séparateur dans un nombre, une relation, une parenthèse
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - »,
`ceil(N^(1/5))` → `ceil(N^1/5)`) ; tout ce qui est entre accents graves (formules) ; un mot de
négation ou de quantité touché (ne, pas, jamais, deux, dix, majorité, tous…) ; une lettre
remplacée par une autre (« majorité » → « minorité ») ; le début d'un mot touché (« valide » →
« invalide », « limitée » → « illimitée »). Les caractères invisibles (inversion du sens
d'écriture, espaces de largeur nulle) sont refusés : ils peuvent faire lire « 21 » là où le texte
dit « 12 ». Moteur : `crates/ld-engine/src/edit.rs`.
### Deux articles qui se contredisent
+1
View File
@@ -311,6 +311,7 @@ export const en: Messages = {
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
'error.invitation_invalid': 'This invitation link is no longer valid.',
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): remove them.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.retry': 'Try again',
+1
View File
@@ -311,6 +311,7 @@ export const es: Messages = {
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): quítalos.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.retry': 'Reintentar',
+1
View File
@@ -314,6 +314,7 @@ export const fr = {
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : retire-les.',
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.retry': 'Réessayer',