Synopsis : normalisation SÛRE des text ajouté par formulaire

- fins de ligne \r\n/\r → \n,
- blocs de lignes vides → \n\n, è
- espaces/tabulations/NBSP → espace unique,
- trim.
Les retours à la ligne simples intra-paragraphe sont LAISSÉS INTACTS :
une coupure de ligne peut être une vraie frontière de mot (→ espace)
ou une coupure du mot ("dyna\nmiques" → "dynamiques"),
indistinguables sans dictionnaire — automatiser corromprait l'entrée
("poursuivantsur").

Migration batch 045 (backfill des données existantes) RETIRÉE : le
nettoyage automatique fiable des synopsis existants est impossible.
Chaque modification est testée (277 tests PHPUnit).
This commit is contained in:
Pontoporeia committed 2026-09-18 16:26:36 +02:00
1 parent 6e1fc6a781
commit 0896c4b8c8
4 files changed
+122 -5

No files matched your search

-1
View File
@@ -1 +0,0 @@
{"version":2,"defects":{"CryptoTest::testEncryptDecryptEmptyString":7,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":7,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":7,"SystemControllerHelpersTest::testHumanBytesOneMB":7,"SystemControllerHelpersTest::testHumanBytesOneGB":7,"CryptoTest::testEncryptEmptyStringProducesCiphertext":7,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":7,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":7,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":7,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":7,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":7,"RateLimitExtendedTest::testGetRemainingDecrements":7,"RateLimitExtendedTest::testGetRemainingAtLimit":7,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":7,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":7,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":8,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexId":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeVideo":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeAudio":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForMixedIds":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexInCoverQueue":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForNumericIdsOnly":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyInput":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyStrings":8,"PureLogicTest::testHasFilePondQueueDataHandlesScalarNotArray":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0,"CryptoTest::testIsEncryptedRejectsPlaintext":0,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0,"EmailObfuscatorTest::testMultipleEmailsInOneString":0,"EmailObfuscatorTest::testEmailWithPlusSign":0,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0,"SystemControllerHelpersTest::testDiskColorBelowWarning":0,"SystemControllerHelpersTest::testDiskColorWarning":0,"SystemControllerHelpersTest::testDiskColorCritical":0,"SystemControllerHelpersTest::testLogLineClassCrit":0,"SystemControllerHelpersTest::testLogLineClassError":0,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0,"SystemControllerHelpersTest::testLogLineClassHttp500":0,"SystemControllerHelpersTest::testLogLineClassHttp300":0,"SystemControllerHelpersTest::testLogLineClassDefault":0,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemCoLine truncated
+2 -1
View File
@@ -1,13 +1,14 @@
# TODO
> Last updated: 2026-08-26
> Context: Docs refactor: verify docs/ accuracy against current xamxam codebase; update/refactor stale docs
> Context: Fix end-user admin report: unexpected line breaks in some synopsis — resolved as undetectable/unfixable for existing data; only safe normalization on new inputs
## In Progress
## Pending
## Completed
- [x] #normalize-synopsis-whitespace & Passages à la ligne inopinés dans les synopsis : jugés INDÉTECTABLES/non-corrigeables automatiquement (une coupure de ligne interne peut être une vraie frontière de mot → espace, ou une coupure du mot → à réjoindre ; indistinguables sans dictionnaire, toute tentative corrompt l'entrée p.ex. "poursuivantsur"). Migration 045 SUPPRIMÉE ; dites à l'admin qu'il n'y a pas de nettoyage fiable des données existantes. Conservation de la NORMALISATION SÛRE sur les nouvelles saisies : fin de ligne \r\n/\r → \n, pertes de lignes vides multiples → \n\n, espaces/tabulations/NBSP → espace unique, trim — retour à la ligne simple intra-paragraphe laissé intact.
- [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale
- [x] #rewrite-development-md-to-match [!high] Rewrite development.md to match current just dev / app/ layout / PHPUnit
- [x] #rewrite-deployment-md-to-match [!high] Rewrite deployment.md to match just deploy / /var/www/xamxam/ / backup
+30 -3
View File
@@ -609,12 +609,39 @@ class ThesisCreateController
// ── Private: input helpers ────────────────────────────────────────────────
/**
* Trim and strip HTML tags from a string value.
* htmlspecialchars is applied at render time, not here.
* Normalise a submitted string value.
*
* Only the UNAMBIGUOUS cleanups are applied here. We deliberately do NOT
* auto-collapse or rejoin single newlines inside a paragraph: a line break
* can be either a real word boundary (should become a space) or a word split
* across the line ("dyna\nmiques", should be rejoined to "dynamiques"),
* and these are indistinguishable without a dictionary. Trying to guess
* corrupts valid input (e.g. "se poursuivant\nsur" → "se poursuivantsur"),
* so such breaks are left untouched and must be fixed by the author.
*
* Responsibilities:
* - strip HTML tags (htmlspecialchars is applied at render time, not here)
* - normalise all line endings to \n (handles \r\n and bare \r)
* - collapse runs of horizontal whitespace (spaces, tabs, NBSP) to one space
* - collapse multiple blank lines into a single paragraph separator (\n\n)
* - trim leading/trailing whitespace and blank lines
*/
private function sanitiseString(string $input): string
{
return strip_tags(trim($input));
$input = strip_tags($input);
// Normalise all line endings to \n (handles \r\n and bare \r).
$input = preg_replace('/\r\n|\r|\n/', "\n", $input);
// Collapse multiple blank lines into a single paragraph separator.
$input = preg_replace("/\n{2,}/u", "\n\n", $input);
// Collapse runs of horizontal whitespace (spaces, tabs, NBSP gaps, …)
// into a single space. Single newlines inside a paragraph are NOT
// touched, for the reason explained above.
$input = preg_replace('/[ \t\x0B\x00\xC2\xA0]+/u', ' ', $input);
return trim($input, " \t\n\r\0\x0B\xC2\xA0");
}
/**
@@ -263,6 +263,96 @@ class ThesisCreateValidationTest extends TestCase
$this->assertStringContainsString('Bold synopsis', $data['synopsis']);
}
// ── Whitespace normalisation (safe, unambiguous subset only) ────────────────
// Single newlines inside a paragraph are deliberately LEFT untouched: they can
// be a word boundary (→ space) or a word split across a line ("dyna\nmiques"
// → "dynamiques") and are indistinguishable, so we never guess and risk
// corrupting valid input (e.g. "se poursuivant\nsur" → "poursuivantsur").
public function testSingleIntraParagraphNewlineIsLeftUntouched(): void
{
// A mid-word line break is ambiguous (could be a real split or a wrap),
// so we do not auto-alter it — the author is responsible for cleaning it.
$post = $this->validPost();
$post['synopsis'] = "…les dyn\namiques complexes…";
$data = $this->validate($post);
$this->assertSame("…les dyn\namiques complexes…", $data['synopsis']);
}
public function testWordWrapSingleNewlineNotMerged(): void
{
// We must NOT merge two real words across a line break ("poursuivantsur").
$post = $this->validPost();
$post['synopsis'] = "se poursuivant\nsur une seconde ligne";
$data = $this->validate($post);
$this->assertSame(
"se poursuivant\nsur une seconde ligne",
$data['synopsis']
);
}
public function testLegitimateParagraphBreakPreserved(): void
{
$post = $this->validPost();
$post['synopsis'] = "Premier paragraphe\n\nSecond paragraphe";
$data = $this->validate($post);
$this->assertSame(
"Premier paragraphe\n\nSecond paragraphe",
$data['synopsis']
);
}
public function testMultipleBlankLinesCollapseToSingleParagraphBreak(): void
{
$post = $this->validPost();
$post['synopsis'] = "A\n\n\n\nB";
$data = $this->validate($post);
$this->assertSame("A\n\nB", $data['synopsis']);
}
public function testMixedWindowsAndUnixLineEndingsNormalised(): void
{
$post = $this->validPost();
$post['synopsis'] = "une pause\r\n\r\ner une future";
$data = $this->validate($post);
// \r\n and \r line-endings all collapse to \n, and blank lines mark
// a single paragraph separator.
$this->assertSame("une pause\n\ner une future", $data['synopsis']);
}
public function testConsecutiveSpacesAndTabsCollapse(): void
{
$post = $this->validPost();
$post['synopsis'] = "Très espacé\t\ttab";
$data = $this->validate($post);
$this->assertSame('Très espacé tab', $data['synopsis']);
}
public function testNonBreakingSpacesNormalised(): void
{
$post = $this->validPost();
// Word pasted NBSP (U+00A0) between words must become a regular space.
$post['synopsis'] = "Premier\u{00A0}mot second mot";
$data = $this->validate($post);
$this->assertSame('Premier mot second mot', $data['synopsis']);
}
public function testSynopsisSingleLineFieldTrimmed(): void
{
$post = $this->validPost();
$post['synopsis'] = " texte qui flotte ";
$data = $this->validate($post);
$this->assertSame('texte qui flotte', $data['synopsis']);
}
// ── Author processing ────────────────────────────────────────────────────
public function testMultipleAuthorsAreSorted(): void