diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache deleted file mode 100644 index eff080a..0000000 --- a/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.5.7","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/Controllers\/CharteController.php":"d976ebd52306d2bc3f6a9797848a299f","app\/src\/PeerTubeService.php":"e3d8346ff21d0291643466825371a6a0","app\/src\/Dispatcher.php":"e233ceb92678bffe6bb343174c7c58b5","app\/src\/App.php":"b15f5c07a0be5b9bae32a9f140561409","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/AppLogger.php":"fe8435f658a74e88d4ffa6bbc83895d6","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/ErrorHandler.php":"e9407d408af265ae3d043f63a00d3724","app\/src\/Form\/FormBootstrap.php":"105969d20430e85b0776c57df92e3ae7","app\/src\/AdminLogger.php":"5d5078ebaba66c1492cbcb36ac57ea9d","app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/SmtpRelay.php":"58b659727976e5bafc99830b0ca6cb33","app\/src\/AdminAuth.php":"824f497dfc2fcd360b6d82100fa98707","app\/src\/icon.php":"f46d6a51fb44870a6a8a0cd094ddbfa0","app\/src\/ShareLink.php":"90a99787b3bb776f4355a01b9db4bf3b","app\/src\/Audit.php":"aec9fbbe8d8eefa8fc657884e8df9b42","app\/src\/StudentEmail.php":"d55456bb2fb170dbd05fbca8e7ae0f9e","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","tests\/phpunit\/ThesisCreateValidationTest.php":"533fa660b77c6b2d616481d1b66c8967","tests\/phpunit\/ErrorHandlerTest.php":"2005e176a105511fc19e853fb4799456","tests\/phpunit\/SearchControllerTest.php":"4b3de46664ede0d065f93d6d7fdee145","tests\/phpunit\/RateLimitExtendedTest.php":"a5e76ed126b2d760436bffb6f1fad0a0","tests\/phpunit\/TfeControllerOgTest.php":"53eefeea18c8d25aec47d59a9c7565a3","tests\/phpunit\/PureLogicTest.php":"2aeaf72d083345356c3308760b355463","tests\/phpunit\/ThesisEditValidationTest.php":"06761c5109cc12fb0f17046b4bcd0814","tests\/phpunit\/ShareLinkExtendedTest.php":"778d8cb9052714f36466d17ed8bf40e9","tests\/phpunit\/EmailObfuscatorTest.php":"ba6fb4078a7e5459749b1402e5558cfb","tests\/phpunit\/DatabaseExtendedTest.php":"f1717a8b1a98bb899d3e1bb275569320","tests\/phpunit\/SystemControllerHelpersTest.php":"d3fc100e6576fbf98517be3a55cad4d6","tests\/phpunit\/CryptoTest.php":"4b4882a940fca3a2a169ef7941f1f413","tests\/phpunit\/AutofocusFieldForErrorTest.php":"402a1258c9adafca7622eb2715162c84","tests\/phpunit\/StudentEmailTest.php":"0fa0d2b1abd68b645e3287b234efb043","tests\/bootstrap.php":"f7b19885b338519f4439e9153ee008aa","tests\/TestDatabase.php":"7b8301cbfadb1a3c02ccfcef8edc1115","app\/src\/DatabaseMigrations.php":"1d9b6a4acddc4fba345c3307577a3111","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/Database.php":"f7562296469a832750bffac16e75756a","app\/src\/MarkdownHelper.php":"b01edc10e964f80bfe6672a8872576eb","app\/src\/Logger.php":"97e8d68cbddef1c99e447952a59fb8b0","app\/src\/FilepondHandler.php":"3b66165d8a173f28070867b04cd11ba6","app\/src\/Controllers\/ExportController.php":"349f73b4015a25da5a44c93fc3d2e2f6","app\/src\/Controllers\/TfeController.php":"8046fec4af163d2e48e8e82ca9602f2d","app\/src\/Controllers\/SystemController.php":"a68816afc71e34004df7103c73e17596","app\/src\/Controllers\/validate-file-fragment-shared.php":"305369d1078d4ac3c1923ef85b6d45bf","app\/src\/Controllers\/SearchController.php":"a61be84fd40ea61fac946a4cdaebb26f","app\/src\/Controllers\/ThesisFileHandler.php":"d798452badd99e3abd68074989f9caa8","app\/src\/Controllers\/LicenceController.php":"773724ab6d49af09c3733551686d9342","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/MediaController.php":"6906cf552bb2cb6944ff0fa63bdf85be","app\/src\/Controllers\/HomeController.php":"058644d92281d3f560c6e9cc46148996","app\/src\/Controllers\/AboutController.php":"2cfccb831b82024b94b2149204aef53a","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Controllers\/ThesisEditController.php":"24b48103292f8252c7b4ce3af4bbad55","app\/src\/Controllers\/ThesisCreateController.php":"ad73710fa00775df0c5f65fe85d06018"}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index bc43e6b..c9403a3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # TODO > Last updated: 2026-08-26 -> 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 +> Context: Recherche insensible aux accents sur toutes les barres de recherche (publique + backoffice) ## In Progress @@ -9,6 +9,8 @@ ## 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] #recherche-insensible-aux-accents Recherche insensible aux accents sur toutes les barres de recherche : "Théophile" et "Theophile" donnent maintenant les mêmes résultats. Implémentation via une fonction SQLite UDF `accfold()` (app/src/AccentFolding.php, UDF enregistré dans Database::registerSqliteFunctions) qui plie les caractères accentués latin (é→e, ç→c, …) vers leur base ASCII ; chaque condition de recherche compare la colonne repliée au terme replié (`accfold(column) LIKE accfold(:term)`). Appliqué à la recherche publique (searchTheses/countSearchResults sur titre/sous-titre/synopsis/auteurs/promoteurs/tags), au popover étudiant (getThesesByAuthorName/getThesesForAuthors) et à la recherche backoffice par auteur (getThesesList/getThesesListCount). NULL-safe, enregistré sur les connexions prod et de test (TestDatabase). Pas d'intl/iconv dispo → map de translitération manuelle (Western Latin-1 + Latin Extended-A). Évite aussi la dépréciation PHP 8.5 de PDO::sqliteCreateFunction() : création de la connexion via \Pdo\Sqlite (createFunction()) quand dispo, repli sur PDO (sqliteCreateFunction()) sur anciens runtimes ; tests passent de `@dataProvider` docblock à l'attribut #[DataProvider] (fin de la dépréciation PHPUnit). 294 tests verts sans dépréciation ; phpstan OK. +- [x] #untrack-build-caches Détrache .phpunit.result.cache et .php-cs-fixer.cache (déjà gitignorés) pour qu'ils ne réapparaissent plus comme modifications dans le working copy. - [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 diff --git a/app/src/AccentFolding.php b/app/src/AccentFolding.php new file mode 100644 index 0000000..e746210 --- /dev/null +++ b/app/src/AccentFolding.php @@ -0,0 +1,105 @@ + 'a','á' => 'a','â' => 'a','ã' => 'a','ä' => 'a','å' => 'a','ā' => 'a','ă' => 'a','ą' => 'a', + 'À' => 'a','Á' => 'a','Â' => 'a','Ã' => 'a','Ä' => 'a','Å' => 'a','Ā' => 'a','Ă' => 'a','Ą' => 'a', + // c + 'ç' => 'c','ć' => 'c','č' => 'c','ĉ' => 'c','ċ' => 'c', + 'Ç' => 'c','Ć' => 'c','Č' => 'c','Ĉ' => 'c','Ċ' => 'c', + // d + 'ď' => 'd','đ' => 'd','Ď' => 'd','Đ' => 'd', + // e + 'è' => 'e','é' => 'e','ê' => 'e','ë' => 'e','ē' => 'e','ĕ' => 'e','ė' => 'e','ę' => 'e','ě' => 'e', + 'È' => 'e','É' => 'e','Ê' => 'e','Ë' => 'e','Ē' => 'e','Ĕ' => 'e','Ė' => 'e','Ę' => 'e','Ě' => 'e', + // g + 'ğ' => 'g','ĝ' => 'g','ģ' => 'g','ġ' => 'g','Ğ' => 'g','Ĝ' => 'g','Ģ' => 'g','Ġ' => 'g', + // h + 'ĥ' => 'h','Ħ' => 'h','Ĥ' => 'h', + // i + 'ì' => 'i','í' => 'i','î' => 'i','ï' => 'i','ī' => 'i','ĭ' => 'i','į' => 'i','ı' => 'i', + 'Ì' => 'i','Í' => 'i','Î' => 'i','Ï' => 'i','Ī' => 'i','Ĭ' => 'i','Į' => 'i','İ' => 'i', + // j + 'ĵ' => 'j','Ĵ' => 'j', + // k + 'ķ' => 'k','Ķ' => 'k', + // l + 'ĺ' => 'l','ļ' => 'l','ľ' => 'l','ł' => 'l','Ŀ' => 'l','Ĺ' => 'l','Ļ' => 'l','Ľ' => 'l','Ł' => 'l', + // n + 'ñ' => 'n','ń' => 'n','ň' => 'n','ņ' => 'n','ʼn' => 'n','Ñ' => 'n','Ń' => 'n','Ň' => 'n','Ņ' => 'n', + // o + 'ò' => 'o','ó' => 'o','ô' => 'o','õ' => 'o','ö' => 'o','ø' => 'o','ō' => 'o','ŏ' => 'o','ő' => 'o', + 'Ò' => 'o','Ó' => 'o','Ô' => 'o','Õ' => 'o','Ö' => 'o','Ø' => 'o','Ō' => 'o','Ŏ' => 'o','Ő' => 'o', + // r + 'ŕ' => 'r','ř' => 'r','ŗ' => 'r','Ŕ' => 'r','Ř' => 'r','Ŗ' => 'r', + // s + 'ś' => 's','š' => 's','ŝ' => 's','ş' => 's','Ś' => 's','Š' => 's','Ŝ' => 's','Ş' => 's', + // t + 'ţ' => 't','ť' => 't','ŧ' => 't','Ţ' => 't','Ť' => 't','Ŧ' => 't', + // u + 'ù' => 'u','ú' => 'u','û' => 'u','ü' => 'u','ū' => 'u','ŭ' => 'u','ů' => 'u','ű' => 'u','ų' => 'u', + 'Ù' => 'u','Ú' => 'u','Û' => 'u','Ü' => 'u','Ū' => 'u','Ŭ' => 'u','Ů' => 'u','Ű' => 'u','Ų' => 'u', + // w + 'ŵ' => 'w','ẁ' => 'w','ẃ' => 'w','ẅ' => 'w','Ŵ' => 'w','Ẁ' => 'w','Ẃ' => 'w','Ẅ' => 'w', + // y + 'ý' => 'y','ÿ' => 'y','ŷ' => 'y','ỳ' => 'y','ỵ' => 'y','ỷ' => 'y','ỹ' => 'y', + 'Ý' => 'y','Ÿ' => 'y','Ŷ' => 'y','Ỳ' => 'y','Ỵ' => 'y','Ỷ' => 'y','Ỹ' => 'y', + // z + 'ź' => 'z','ž' => 'z','ż' => 'z','Ź' => 'z','Ž' => 'z','Ż' => 'z', + ]; + + /** @var array|null */ + private static ?array $map = null; + + /** + * Fold diacritics out of a UTF-8 string (lowercasing is NOT performed — + * callers opt-in if they want case-insensitivity). + * + * @param string|null $value + * @return string|null NULL passes through unchanged (mirrors SQL NULL). + */ + public static function fold(?string $value): ?string + { + if ($value === null || $value === '') { + return $value; + } + + self::$map ??= self::MAP; + + // Any non-NFC bytes that happen to be combining marks separated from + // their base letter are also dropped, so decomposed input works too. + $decomposed = preg_replace('/[\x{0300}-\x{036f}]/u', '', $value); + if ($decomposed === null) { + // Not valid UTF-8 — fall back to the raw value. + return $value; + } + + return strtr($decomposed, self::$map); + } +} diff --git a/app/src/Database.php b/app/src/Database.php index a33c14d..f357a57 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -20,9 +20,10 @@ class Database $this->dbPath = $this->determineDatabasePath($dbPath); try { - $this->pdo = new PDO('sqlite:' . $this->dbPath); + $this->pdo = self::newConnection($this->dbPath); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $this->registerSqliteFunctions($this->pdo); // Enable foreign key constraints + performance pragmas $this->pdo->exec('PRAGMA foreign_keys = ON'); @@ -36,6 +37,50 @@ class Database } } + /** + * Create a new SQLite PDO connection. + * + * Since PHP 8.5, `PDO::sqliteCreateFunction()` (used to register the + * `accfold` UDF) is deprecated in favour of the `Pdo\Sqlite::createFunction()` + * method. `Pdo\Sqlite` extends `PDO` and keeps the full query API, so we use + * it directly when available and fall back to a plain `PDO` otherwise + * (older runtimes rely on the named argument there). + */ + private static function newConnection(string $dbPath): PDO + { + // `Pdo\Sqlite` exists since PHP 8.5; on older versions class_exists() is false. + if (class_exists('Pdo\Sqlite')) { + return new Pdo\Sqlite('sqlite:' . $dbPath); + } + + return new PDO('sqlite:' . $dbPath); + } + + /** + * Register user-defined SQLite functions used by search queries. + * + * Currently exposes `accfold()` — accent folding — so SQL can match + * diacritic-insensitively (accfold(column) LIKE accfold(term)). + * + * Also called for the in-memory test connection (see TestDatabase). + */ + public static function registerSqliteFunctions( + PDO $pdo + ): void { + require_once __DIR__ . '/AccentFolding.php'; + + $callback = static fn (?string $value): ?string => AccentFolding::fold($value); + + // Since PHP 8.5, `PDO::sqliteCreateFunction()` is deprecated: use the + // `Pdo\Sqlite::createFunction()` method instead. `PDO::sqliteCreateFunction()` + // remains the non-deprecated API on older versions where Pdo\Sqlite doesn't exist. + if ($pdo instanceof \Pdo\Sqlite) { + $pdo->createFunction('accfold', $callback, 1); + } else { + $pdo->sqliteCreateFunction('accfold', $callback, 1); + } + } + /** * Run one-off schema migrations. */ @@ -336,23 +381,31 @@ class Database */ private function buildSearchConditions(array $params): array { + require_once __DIR__ . '/AccentFolding.php'; + $conditions = ['vp.is_published = 1']; $bindings = []; + // Fold diacritics off a validated (already LIKE-escaped) term so both + // sides of the comparison are accent-insensitive. Escape characters + // (\, %, _) introduced by escapeLikeString are untouched by folding. + $fold = static fn (string $term): string => AccentFolding::fold($term) ?? $term; + if (!empty($params['query'])) { $conditions[] = "( - vp.title LIKE :query ESCAPE '\\' OR - vp.subtitle LIKE :query ESCAPE '\\' OR - vp.synopsis LIKE :query ESCAPE '\\' OR - vp.authors LIKE :query ESCAPE '\\' OR - vp.supervisors LIKE :query ESCAPE '\\' OR + accfold(vp.title) LIKE :query ESCAPE '\\' OR + accfold(vp.subtitle) LIKE :query ESCAPE '\\' OR + accfold(vp.synopsis) LIKE :query ESCAPE '\\' OR + accfold(vp.authors) LIKE :query ESCAPE '\\' OR + accfold(vp.supervisors) LIKE :query ESCAPE '\\' OR EXISTS ( SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id = tt2.tag_id - WHERE tt2.thesis_id = vp.id AND tg2.name LIKE :query ESCAPE '\\' + WHERE tt2.thesis_id = vp.id + AND accfold(tg2.name) LIKE :query ESCAPE '\\' ) )"; - $bindings[':query'] = '%' . $params['query'] . '%'; + $bindings[':query'] = '%' . $fold($params['query']) . '%'; } if (!empty($params['year'])) { @@ -361,37 +414,38 @@ class Database } if (!empty($params['orientation'])) { - $conditions[] = "vp.orientation LIKE :orientation ESCAPE '\\'"; - $bindings[':orientation'] = '%' . $params['orientation'] . '%'; + $conditions[] = "accfold(vp.orientation) LIKE :orientation ESCAPE '\\'"; + $bindings[':orientation'] = '%' . $fold($params['orientation']) . '%'; } if (!empty($params['ap_program'])) { - $conditions[] = "vp.ap_program LIKE :ap_program ESCAPE '\\'"; - $bindings[':ap_program'] = '%' . $params['ap_program'] . '%'; + $conditions[] = "accfold(vp.ap_program) LIKE :ap_program ESCAPE '\\'"; + $bindings[':ap_program'] = '%' . $fold($params['ap_program']) . '%'; } if (!empty($params['finality'])) { - $conditions[] = "vp.finality_type LIKE :finality ESCAPE '\\'"; - $bindings[':finality'] = '%' . $params['finality'] . '%'; + $conditions[] = "accfold(vp.finality_type) LIKE :finality ESCAPE '\\'"; + $bindings[':finality'] = '%' . $fold($params['finality']) . '%'; } if (!empty($params['keyword'])) { $conditions[] = "EXISTS ( SELECT 1 FROM thesis_tags tt_kw JOIN tags tg_kw ON tg_kw.id = tt_kw.tag_id - WHERE tt_kw.thesis_id = vp.id AND tg_kw.name LIKE :keyword ESCAPE '\\' + WHERE tt_kw.thesis_id = vp.id + AND accfold(tg_kw.name) LIKE :keyword ESCAPE '\\' )"; - $bindings[':keyword'] = '%' . $params['keyword'] . '%'; + $bindings[':keyword'] = '%' . $fold($params['keyword']) . '%'; } if (!empty($params['format'])) { - $conditions[] = "vp.formats LIKE :format ESCAPE '\\'"; - $bindings[':format'] = '%' . $params['format'] . '%'; + $conditions[] = "accfold(vp.formats) LIKE :format ESCAPE '\\'"; + $bindings[':format'] = '%' . $fold($params['format']) . '%'; } if (!empty($params['language'])) { - $conditions[] = "vp.languages LIKE :language ESCAPE '\\'"; - $bindings[':language'] = '%' . $params['language'] . '%'; + $conditions[] = "accfold(vp.languages) LIKE :language ESCAPE '\\'"; + $bindings[':language'] = '%' . $fold($params['language']) . '%'; } if (isset($params['is_doctoral'])) { @@ -498,16 +552,18 @@ class Database */ public function getThesesByAuthorName(string $name): array { + require_once __DIR__ . '/AccentFolding.php'; + $stmt = $this->pdo->prepare( 'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis, vp.orientation, vp.finality_type, vp.authors FROM v_theses_public vp JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN authors a ON a.id = ta.author_id - WHERE a.name = ? + WHERE accfold(a.name) = accfold(?) ORDER BY vp.year DESC, vp.title ASC' ); - $stmt->execute([$name]); + $stmt->execute([AccentFolding::fold($name)]); return $stmt->fetchAll(); } @@ -524,6 +580,9 @@ class Database return []; } + require_once __DIR__ . '/AccentFolding.php'; + $folded = array_map(static fn (string $n) => AccentFolding::fold($n) ?? $n, $names); + $placeholders = implode(',', array_fill(0, count($names), '?')); $stmt = $this->pdo->prepare( "SELECT a.name AS author_name, @@ -532,10 +591,10 @@ class Database FROM v_theses_public vp JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN authors a ON a.id = ta.author_id - WHERE a.name IN ($placeholders) + WHERE accfold(a.name) IN ($placeholders) ORDER BY a.name ASC, vp.year DESC, vp.title ASC" ); - $stmt->execute($names); + $stmt->execute($folded); $rows = $stmt->fetchAll(); $grouped = []; @@ -856,6 +915,8 @@ class Database */ public function getThesesListCount(array $filters = []): int { + require_once __DIR__ . '/AccentFolding.php'; + $sql = 'SELECT COUNT(DISTINCT t.id) FROM theses t LEFT JOIN orientations o ON t.orientation_id = o.id @@ -867,8 +928,8 @@ class Database $params = []; if (!empty($filters['search'])) { - $sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR t.identifier LIKE ? OR a.name LIKE ?)'; - $searchParam = '%' . $filters['search'] . '%'; + $sql .= ' AND (accfold(t.title) LIKE ? ESCAPE \'\\\' OR accfold(t.subtitle) LIKE ? ESCAPE \'\\\' OR accfold(t.identifier) LIKE ? ESCAPE \'\\\' OR accfold(a.name) LIKE ? ESCAPE \'\\\')'; + $searchParam = '%' . AccentFolding::fold($filters['search']) . '%'; $params[] = $searchParam; $params[] = $searchParam; $params[] = $searchParam; @@ -897,6 +958,8 @@ class Database public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array { + require_once __DIR__ . '/AccentFolding.php'; + $sql = 'SELECT t.id, t.identifier, t.title, t.subtitle, t.year, o.name as orientation, @@ -916,8 +979,8 @@ class Database $params = []; if (!empty($filters['search'])) { - $sql .= ' AND (t.title LIKE ? OR t.subtitle LIKE ? OR t.identifier LIKE ? OR a.name LIKE ?)'; - $searchParam = '%' . $filters['search'] . '%'; + $sql .= ' AND (accfold(t.title) LIKE ? ESCAPE \'\\\' OR accfold(t.subtitle) LIKE ? ESCAPE \'\\\' OR accfold(t.identifier) LIKE ? ESCAPE \'\\\' OR accfold(a.name) LIKE ? ESCAPE \'\\\')'; + $searchParam = '%' . AccentFolding::fold($filters['search']) . '%'; $params[] = $searchParam; $params[] = $searchParam; $params[] = $searchParam; diff --git a/docs/search.md b/docs/search.md index 6a01fb3..6af696f 100644 --- a/docs/search.md +++ b/docs/search.md @@ -77,6 +77,26 @@ Queries operate on `v_theses_public`; keyword matching joins the `thesis_tags` / All queries use PDO prepared statements and escape `%`/`_` for `LIKE` (`Database::escapeLikeString`) to prevent wildcard injection. +### Accent-insensitive matching + +Text searches on the public **search bar**, the **student-preview** popover, and +the **admin list** search are all **diacritic-insensitive**: `Théophile` and +`Theophile`, `sévère` and `severe` return the same results. + +This is implemented via a SQLite user-defined function `accfold()` +(`app/src/AccentFolding.php`, registered in `Database::registerSqliteFunctions()`), which +strips precomposed accented Latin characters to their ASCII base (`é` → `e`, +`ç` → `c`, …). Search conditions compare the folded column against the folded +term (`accfold(column) LIKE accfold(:term)`), so both sides are normalised the +same way. The function is NULL-safe and registered on the production and test +connections alike; the public path uses `searchTheses()`/`countSearchResults()`, +the student popover `getThesesByAuthorName()`, and the admin list +`getThesesList()`/`getThesesListCount()`. + +Note: `intl`/`Normalizer` and `iconv` are **not** available on the runtime, so +folding uses a hand-maintained transliteration map (Western Latin-1 + +Latin Extended-A). + --- ## Performance notes diff --git a/tests/TestDatabase.php b/tests/TestDatabase.php index 4b24452..4482a01 100644 --- a/tests/TestDatabase.php +++ b/tests/TestDatabase.php @@ -15,7 +15,11 @@ class TestDatabaseInstance extends Database { public function __construct(PDO $pdo) { - // Inject PDO directly via reflection, then flag as ready + // Inject PDO directly via reflection, then flag as ready. + // Because we bypass the parent constructor, we must also register the + // custom SQLite functions (e.g. accfold) it would otherwise install. + Database::registerSqliteFunctions($pdo); + $ref = new ReflectionProperty(Database::class, 'pdo'); $ref->setValue($this, $pdo); @@ -36,7 +40,13 @@ class TestDatabase public static function getInstance(): Database { if (self::$db === null) { - self::$pdo = new PDO('sqlite::memory:'); + // Since PHP 8.5, `PDO::sqliteCreateFunction()` is deprecated and the + // registration in Database::registerSqliteFunctions() uses + // `Pdo\Sqlite::createFunction()` when the connection is a `Pdo\Sqlite`. + // Build the same connection type here so the new API path is exercised. + self::$pdo = class_exists('Pdo\Sqlite') + ? new Pdo\Sqlite('sqlite::memory:') + : new PDO('sqlite::memory:'); self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); self::$pdo->exec('PRAGMA foreign_keys = ON'); diff --git a/tests/phpunit/AccentInsensitiveSearchTest.php b/tests/phpunit/AccentInsensitiveSearchTest.php new file mode 100644 index 0000000..5e20938 --- /dev/null +++ b/tests/phpunit/AccentInsensitiveSearchTest.php @@ -0,0 +1,110 @@ +assertSame($expected, AccentFolding::fold($input)); + } + + public static function foldProvider(): array + { + return [ + 'french name' => ['Théophile', 'Theophile'], + 'lowercase + mixed' => ['sévère où ça', 'severe ou ca'], + // Upper diacritics fold to lower base letters (LIKE is case-insensitive in SQLite, + // so this is fine for matching; the point is É and é both fold to e). + 'upper diacritics' => ['ÉCOLE À ö', 'eCOLE a o'], + 'cedilla' => ['garçon', 'garcon'], + 'n tilde' => ['señor', 'senor'], + 'umlauts' => ['üöä', 'uoa'], + 'apex/apostrophe' => ['l\'erg', "l'erg"], + 'empty' => ['', ''], + 'no diacritics' => ['plain text', 'plain text'], + ]; + } + + public function testFoldReturnsNullForNull(): void + { + $this->assertNull(AccentFolding::fold(null)); + } + + public function testPublicSearchMatchedWithoutAccent(): void + { + TestDatabase::seedBasicThesis('TFE ac', 'Théophile Dupont', 2024); + $db = TestDatabase::getInstance(); + $res = $db->searchTheses(['query' => 'theophile']); + $this->assertCount(1, $res); + $this->assertStringContainsString('Théophile', $res[0]['authors']); + } + + public function testPublicSearchMatchedWithAccent(): void + { + TestDatabase::seedBasicThesis('TFE ac', 'Théophile Dupont', 2024); + $db = TestDatabase::getInstance(); + $res = $db->searchTheses(['query' => 'Théophile']); + $this->assertCount(1, $res); + } + + public function testTitleSearchIgnoresAccent(): void + { + TestDatabase::seedBasicThesis('Sévère étude', 'Auteur', 2024); + $db = TestDatabase::getInstance(); + $res = $db->searchTheses(['query' => 'severe']); + $this->assertCount(1, $res); + } + + public function testSearchReturnsNothingForMismatch(): void + { + TestDatabase::seedBasicThesis('Distinct Title', 'Autre Auteur', 2024); + $db = TestDatabase::getInstance(); + $this->assertCount(0, $db->searchTheses(['query' => 'xyzzy-no-match'])); + } + + public function testAuthorNamePreviewIgnoresAccent(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('P', 'Théophile Dupont', 2024); + $db = TestDatabase::getInstance(); + $this->assertCount(1, $db->getThesesByAuthorName('Theophile Dupont')); + $this->assertCount(1, $db->getThesesByAuthorName('Théophile Dupont')); + $this->assertCount(0, $db->getThesesByAuthorName('Jean-Claude')); + } + + public function testAdminListIgnoresAccent(): void + { + TestDatabase::seedBasicThesis('Étude de cas', 'Théophile Martin', 2025); + $db = TestDatabase::getInstance(); + + $accented = $db->getThesesList(['search' => 'Théophile'], 0, 0); + $plain = $db->getThesesList(['search' => 'theophile'], 0, 0); + + $this->assertCount(1, $accented); + $this->assertEquals($accented, $plain); + $this->assertSame(0, $db->getThesesListCount(['search' => 'zz-nope'])); + } + + public function testAdminIdentifierSearchStillMatchesAscii(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('X', 'Auteur', 2024); + $db = TestDatabase::getInstance(); + // seedBasicThesis uses identifier "$year-001" → "2024-001" + $pdo = TestDatabase::getPDO(); + $id = $pdo->query("SELECT identifier FROM theses WHERE id = $thesisId")->fetchColumn(); + $this->assertCount(1, $db->getThesesList(['search' => $id], 0, 0)); + } +}