mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Fix migrations and deploy issues + errors + linting
- scan both pending/ and applied/ dirs so remote catch-up works - fix remote 500s: run.php handles per-statement errors so VIEW rebuilds run after duplicate columns; replace mb_strimwidth with substr (no mbstring extension on server) - add missing migration: 015_license_custom.sql (column existed in schema.sql but was never migrated) - remote: fgetcsv enclosure single-char + AdminLogger permission-denied guard + deploy always migrates - fix admin-filters wrapping: restore flex-wrap, flex-basis on inputs/selects, shrink-protect buttons - fix phpstan: remove redundant ?? [] after isset guard in ThesisEditController - biome: exclude vendored min.js via includes patterns; lint whole js dir; modernise beforeunload-guard.js
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"php":"8.5.5","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\/AdminAuth.php":"9281ad7ee564c8f0e321ba5f709c9a5a","app\/src\/ShareLink.php":"29e258f6344bff7d8d4db5e626d4ac51","app\/src\/StudentEmail.php":"4b522bf8921999866b2b56326e49d255","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/run-tests.php":"89ccc4a9cff85fb8f2c92903eb866b79","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Database.php":"cc2b6873cfafab02dcb13931ff301753","app\/src\/Controllers\/ExportController.php":"12e717b606f36c3a3d49ad58f7120898","app\/src\/Controllers\/TfeController.php":"46f092d0ef7ee3d0bc609cb70d15ef97","app\/src\/Controllers\/SystemController.php":"aaf106e4c73fa846344513346a1236a6","app\/src\/Controllers\/SearchController.php":"cc2ecf70d496f4e05448f4ec9670a3bc","app\/src\/Controllers\/LicenceController.php":"be382174f80b1dbcda03450cabc531a7","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/MediaController.php":"2cadef1d1d249ceaaf33abca2f79a8ad","app\/src\/Controllers\/HomeController.php":"d3a36adcdb969448c23cea34fb6d7896","app\/src\/Controllers\/AboutController.php":"79eac4bf3404a7c8d3ba736e3d2f32d6","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Controllers\/ThesisEditController.php":"e02f5350f34949840d398d53423e7f18","app\/src\/Controllers\/ThesisCreateController.php":"37f2c13b34d49093289bae80d44104c9","app\/src\/Dispatcher.php":"d1d693bdacbe0006cb806b15d067cbbb","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/App.php":"abed36a5403738f071a5cdc3451b1e24","app\/src\/AppLogger.php":"8522037460732198e9e68d13ade7b13b","app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/SmtpRelay.php":"22c9b92eab1575c14c393917138a6e3b"}}
|
{"php":"8.5.6","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\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/SmtpRelay.php":"22c9b92eab1575c14c393917138a6e3b","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/src\/ShareLink.php":"84f06935f015221c39c517c1072795c9","app\/src\/StudentEmail.php":"84f7b438defaadf9d5d8a7821b3a4e78","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/run-tests.php":"89ccc4a9cff85fb8f2c92903eb866b79","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Database.php":"57bcebbb33c8a7ba9e6c7b6f60a5ec23","app\/src\/Controllers\/ExportController.php":"8872052f3e9e171955df9c9198cfc708","app\/src\/Controllers\/TfeController.php":"7e67f11af8ae38ac683d2246717c4ecf","app\/src\/Controllers\/SystemController.php":"aaf106e4c73fa846344513346a1236a6","app\/src\/Controllers\/SearchController.php":"cc2ecf70d496f4e05448f4ec9670a3bc","app\/src\/Controllers\/LicenceController.php":"be382174f80b1dbcda03450cabc531a7","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/MediaController.php":"2cadef1d1d249ceaaf33abca2f79a8ad","app\/src\/Controllers\/HomeController.php":"d3a36adcdb969448c23cea34fb6d7896","app\/src\/Controllers\/AboutController.php":"0be7251ba310770cb9702320026a8562","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Controllers\/ThesisEditController.php":"34cd474e80c63acb99ded2bd46ff1dab","app\/src\/Controllers\/ThesisCreateController.php":"74aa55467df95e0efe5e30784baa2ac8","app\/src\/Dispatcher.php":"d1d693bdacbe0006cb806b15d067cbbb","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/App.php":"2fa0253736fdd6bfd28135e0c0ecb3f2","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/AdminLogger.php":"ba9ac9d222771b952ea6451b66ca6821"}}
|
||||||
15
TODO.md
15
TODO.md
@@ -195,6 +195,21 @@
|
|||||||
- [x] All three form pages (admin add, admin edit, partage) updated
|
- [x] All three form pages (admin add, admin edit, partage) updated
|
||||||
- [x] Controllers updated: `collectJuryMembers`, `validateAndSanitise`, `buildFileSizeInfo`, `license_custom`, `cc2r`→`cc4r` mapping
|
- [x] Controllers updated: `collectJuryMembers`, `validateAndSanitise`, `buildFileSizeInfo`, `license_custom`, `cc2r`→`cc4r` mapping
|
||||||
|
|
||||||
|
## Fix biome lint config + beforeunload-guard.js
|
||||||
|
- [x] `biome.json` — use `files.includes` negation patterns to exclude `htmx/overtype/sortable.min.js`
|
||||||
|
- [x] `justfile lint-biome` — lint entire `app/public/assets/js/` dir (no hardcoded file list)
|
||||||
|
- [x] `beforeunload-guard.js` — modernise: `var`→`const`/`let`, `function()`→arrow functions, `for` loop→`for…of`
|
||||||
|
|
||||||
|
## Fix admin-filters wrapping
|
||||||
|
- [x] `.admin-list-toolbar .admin-filters` — restore `flex-wrap: wrap`, add `min-width: 0`
|
||||||
|
- [x] Search input: `flex: 1 1 10rem; min-width: 10rem` so it grows but doesn't collapse
|
||||||
|
- [x] Selects: `flex: 1 1 7rem; min-width: 7rem` for graceful wrap
|
||||||
|
- [x] Buttons: `flex-shrink: 0` so they never compress
|
||||||
|
|
||||||
|
## Fix remote errors (2026-05-08)
|
||||||
|
- [x] `admin/index.php` — `fgetcsv()` enclosure `'\"'` (two chars) → `'"'` (single char); fatal on PHP 8.4
|
||||||
|
- [x] `AdminLogger::write()` — guard `error_log()` with `is_writable()` check; silently skips file logging when `/var/log/xamxam.log` is not writable (DB mirror still runs)
|
||||||
|
|
||||||
## Fix form field required states & missing fields per spec
|
## Fix form field required states & missing fields per spec
|
||||||
- [x] Admin add: add `contact_public` checkbox (matching edit form)
|
- [x] Admin add: add `contact_public` checkbox (matching edit form)
|
||||||
- [x] Admin add + partage + admin edit: formats checkbox-list `$required = true`
|
- [x] Admin add + partage + admin edit: formats checkbox-list `$required = true`
|
||||||
|
|||||||
1
app/migrations/applied/015_license_custom.sql
Normal file
1
app/migrations/applied/015_license_custom.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE theses ADD COLUMN license_custom TEXT;
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
*
|
*
|
||||||
* If no DB_PATH is given, defaults to storage/xamxam.db.
|
* If no DB_PATH is given, defaults to storage/xamxam.db.
|
||||||
*
|
*
|
||||||
* Each migration in migrations/pending/ is applied in alphabetical order.
|
* Scans both migrations/pending/ and migrations/applied/ for .sql files.
|
||||||
* After success, the file is moved to migrations/applied/.
|
* Each is applied in alphabetical order if not already tracked in _migrations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$root = dirname(__DIR__);
|
$root = dirname(__DIR__);
|
||||||
@@ -34,17 +34,19 @@ $applied = $pdo->query("SELECT name FROM _migrations")->fetchAll(PDO::FETCH_COLU
|
|||||||
$pendingDir = $root . '/migrations/pending';
|
$pendingDir = $root . '/migrations/pending';
|
||||||
$appliedDir = $root . '/migrations/applied';
|
$appliedDir = $root . '/migrations/applied';
|
||||||
|
|
||||||
if (!is_dir($pendingDir)) {
|
|
||||||
echo "No pending migrations directory.\n";
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_dir($appliedDir)) {
|
if (!is_dir($appliedDir)) {
|
||||||
mkdir($appliedDir, 0755, true);
|
mkdir($appliedDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = glob($pendingDir . '/*.sql');
|
// Collect .sql files from both pending and applied dirs
|
||||||
sort($files);
|
$files = [];
|
||||||
|
foreach ([$pendingDir, $appliedDir] as $dir) {
|
||||||
|
if (!is_dir($dir)) continue;
|
||||||
|
foreach (glob($dir . '/*.sql') as $f) {
|
||||||
|
$files[basename($f)] = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($files);
|
||||||
|
|
||||||
if (empty($files)) {
|
if (empty($files)) {
|
||||||
echo "No pending migration files.\n";
|
echo "No pending migration files.\n";
|
||||||
@@ -52,8 +54,7 @@ if (empty($files)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($files as $file) {
|
foreach ($files as $name => $file) {
|
||||||
$name = basename($file);
|
|
||||||
|
|
||||||
if (in_array($name, $applied, true)) {
|
if (in_array($name, $applied, true)) {
|
||||||
echo "Skip (already applied): $name\n";
|
echo "Skip (already applied): $name\n";
|
||||||
@@ -63,21 +64,36 @@ foreach ($files as $file) {
|
|||||||
echo "Applying: $name\n";
|
echo "Applying: $name\n";
|
||||||
$sql = file_get_contents($file);
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
// Split into individual statements to handle partial failures gracefully
|
||||||
|
// (e.g. ALTER TABLE may fail with "duplicate column" but DROP VIEW must still run)
|
||||||
|
$statements = array_filter(
|
||||||
|
array_map('trim', explode(';', $sql)),
|
||||||
|
fn($s) => $s !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
foreach ($statements as $stmt) {
|
||||||
try {
|
try {
|
||||||
$pdo->exec($sql);
|
$pdo->exec($stmt . ';');
|
||||||
$pdo->prepare("INSERT INTO _migrations (name) VALUES (?)")->execute([$name]);
|
|
||||||
rename($file, $appliedDir . '/' . $name);
|
|
||||||
$count++;
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$msg = $e->getMessage();
|
$msg = $e->getMessage();
|
||||||
// SQLite: skip if column already exists
|
|
||||||
if (stripos($msg, 'duplicate column name') !== false) {
|
if (stripos($msg, 'duplicate column name') !== false) {
|
||||||
echo " Already exists (skipping): $name\n";
|
echo " Skipping (column exists): " . substr($stmt, 0, 60) . "...\n";
|
||||||
$pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]);
|
|
||||||
rename($file, $appliedDir . '/' . $name);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw $e;
|
$errors[] = $msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($errors)) {
|
||||||
|
$pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]);
|
||||||
|
if (str_starts_with($file, $pendingDir)) {
|
||||||
|
rename($file, $appliedDir . '/' . $name);
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
} else {
|
||||||
|
echo " FAILED: " . implode(' | ', $errors) . "\n";
|
||||||
|
throw new PDOException(implode(' | ', $errors));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
'autorisation', 'licence', 'license', 'taille', 'points', 'lien baiu',
|
'autorisation', 'licence', 'license', 'taille', 'points', 'lien baiu',
|
||||||
];
|
];
|
||||||
for ($scan = 0; $scan < 8; $scan++) {
|
for ($scan = 0; $scan < 8; $scan++) {
|
||||||
$hrow = fgetcsv($handle, 0, ',', '\"', '');
|
$hrow = fgetcsv($handle, 0, ',', '"', '');
|
||||||
if ($hrow === false) break;
|
if ($hrow === false) break;
|
||||||
$headerRowNum++;
|
$headerRowNum++;
|
||||||
$normRow = array_map(fn($s) => strtolower(trim((string)$s)), $hrow);
|
$normRow = array_map(fn($s) => strtolower(trim((string)$s)), $hrow);
|
||||||
@@ -84,7 +84,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
$idPos = $colIdx['identifiant'] ?? 0;
|
$idPos = $colIdx['identifiant'] ?? 0;
|
||||||
$peekRow = null;
|
$peekRow = null;
|
||||||
while (true) {
|
while (true) {
|
||||||
$peek = fgetcsv($handle, 0, ',', '\"', '');
|
$peek = fgetcsv($handle, 0, ',', '"', '');
|
||||||
if ($peek === false) break;
|
if ($peek === false) break;
|
||||||
$headerRowNum++;
|
$headerRowNum++;
|
||||||
$val = trim((string)($peek[$idPos] ?? ''));
|
$val = trim((string)($peek[$idPos] ?? ''));
|
||||||
@@ -216,7 +216,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
$row = $peekRow;
|
$row = $peekRow;
|
||||||
$usePeek = false;
|
$usePeek = false;
|
||||||
} else {
|
} else {
|
||||||
$row = fgetcsv($handle, 0, ',', '\"', '');
|
$row = fgetcsv($handle, 0, ',', '"', '');
|
||||||
if ($row === false) break;
|
if ($row === false) break;
|
||||||
}
|
}
|
||||||
$lineNumber++;
|
$lineNumber++;
|
||||||
|
|||||||
@@ -737,7 +737,22 @@
|
|||||||
.admin-list-toolbar .admin-filters {
|
.admin-list-toolbar .admin-filters {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-toolbar .admin-filters input[type="text"] {
|
||||||
|
min-width: 10rem;
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-toolbar .admin-filters select {
|
||||||
|
min-width: 7rem;
|
||||||
|
flex: 1 1 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-toolbar .admin-filters .btn {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-list-toolbar__right {
|
.admin-list-toolbar__right {
|
||||||
|
|||||||
@@ -4,20 +4,19 @@
|
|||||||
* Attach to any form with a data-beforeunload-guard attribute.
|
* Attach to any form with a data-beforeunload-guard attribute.
|
||||||
* No effect when JavaScript is unavailable (form posts normally).
|
* No effect when JavaScript is unavailable (form posts normally).
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
var forms = document.querySelectorAll('form[data-beforeunload-guard]');
|
const forms = document.querySelectorAll('form[data-beforeunload-guard]');
|
||||||
if (!forms.length) return;
|
if (!forms.length) return;
|
||||||
|
|
||||||
var dirty = false;
|
let dirty = false;
|
||||||
|
|
||||||
for (var i = 0; i < forms.length; i++) {
|
for (const form of forms) {
|
||||||
var form = forms[i];
|
form.addEventListener('input', () => { dirty = true; });
|
||||||
form.addEventListener('input', function () { dirty = true; });
|
form.addEventListener('change', () => { dirty = true; });
|
||||||
form.addEventListener('change', function () { dirty = true; });
|
form.addEventListener('submit', () => { dirty = false; });
|
||||||
form.addEventListener('submit', function () { dirty = false; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('beforeunload', function (e) {
|
window.addEventListener('beforeunload', (e) => {
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,9 @@ class AdminLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
if (is_writable($this->logFile) || (!file_exists($this->logFile) && is_writable(dirname($this->logFile)))) {
|
||||||
error_log($line, 3, $this->logFile);
|
error_log($line, 3, $this->logFile);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->db !== null) {
|
if ($this->db !== null) {
|
||||||
$this->insertDb($resource, $action, $status, $context);
|
$this->insertDb($resource, $action, $status, $context);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class ExportController
|
|||||||
*/
|
*/
|
||||||
public function createExportZip(?string $baseDir = null): string
|
public function createExportZip(?string $baseDir = null): string
|
||||||
{
|
{
|
||||||
$baseDir = $baseDir ?? 'files';
|
$baseDir ??= 'files';
|
||||||
$storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : APP_ROOT . '/storage';
|
$storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : APP_ROOT . '/storage';
|
||||||
$files = $this->getAllThesisFiles();
|
$files = $this->getAllThesisFiles();
|
||||||
$manifest = $this->buildExportManifest();
|
$manifest = $this->buildExportManifest();
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class ThesisCreateController
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||||||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . " author(s)");
|
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . ' author(s)');
|
||||||
|
|
||||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||||
$this->db->setThesisJury($thesisId, $data['juryMembers']);
|
$this->db->setThesisJury($thesisId, $data['juryMembers']);
|
||||||
@@ -298,7 +298,7 @@ class ThesisCreateController
|
|||||||
$authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
|
$authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
|
||||||
$authorNames = [];
|
$authorNames = [];
|
||||||
if ($authorRaw !== '') {
|
if ($authorRaw !== '') {
|
||||||
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn($n) => $n !== '');
|
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn ($n) => $n !== '');
|
||||||
$authorNames = array_values($authorNames);
|
$authorNames = array_values($authorNames);
|
||||||
sort($authorNames, SORT_NATURAL);
|
sort($authorNames, SORT_NATURAL);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,46 +165,78 @@ class ThesisEditController
|
|||||||
// ── Basic validation (same required fields as create) ──────────────────
|
// ── Basic validation (same required fields as create) ──────────────────
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$titre = trim($post['titre'] ?? '');
|
$titre = trim($post['titre'] ?? '');
|
||||||
if ($titre === '') $errors[] = 'Le titre est requis.';
|
if ($titre === '') {
|
||||||
|
$errors[] = 'Le titre est requis.';
|
||||||
|
}
|
||||||
$auteurice = trim($post['auteurice'] ?? '');
|
$auteurice = trim($post['auteurice'] ?? '');
|
||||||
if ($auteurice === '') $errors[] = "L'auteur·ice est requis.";
|
if ($auteurice === '') {
|
||||||
|
$errors[] = "L'auteur·ice est requis.";
|
||||||
|
}
|
||||||
$synopsis = trim($post['synopsis'] ?? '');
|
$synopsis = trim($post['synopsis'] ?? '');
|
||||||
if ($synopsis === '') $errors[] = 'Le synopsis est requis.';
|
if ($synopsis === '') {
|
||||||
|
$errors[] = 'Le synopsis est requis.';
|
||||||
|
}
|
||||||
$annee = intval($post['année'] ?? 0);
|
$annee = intval($post['année'] ?? 0);
|
||||||
if ($annee < 2000 || $annee > ((int)date('Y') + 1)) $errors[] = "L'année est invalide.";
|
if ($annee < 2000 || $annee > ((int)date('Y') + 1)) {
|
||||||
|
$errors[] = "L'année est invalide.";
|
||||||
|
}
|
||||||
$orientationId = intval($post['orientation'] ?? 0);
|
$orientationId = intval($post['orientation'] ?? 0);
|
||||||
if ($orientationId <= 0) $errors[] = "L'orientation est requise.";
|
if ($orientationId <= 0) {
|
||||||
|
$errors[] = "L'orientation est requise.";
|
||||||
|
}
|
||||||
$apProgramId = intval($post['ap'] ?? 0);
|
$apProgramId = intval($post['ap'] ?? 0);
|
||||||
if ($apProgramId <= 0) $errors[] = "L'atelier pluridisciplinaire est requis.";
|
if ($apProgramId <= 0) {
|
||||||
|
$errors[] = "L'atelier pluridisciplinaire est requis.";
|
||||||
|
}
|
||||||
$finalityId = intval($post['finality'] ?? 0);
|
$finalityId = intval($post['finality'] ?? 0);
|
||||||
if ($finalityId <= 0) $errors[] = 'La finalité est requise.';
|
if ($finalityId <= 0) {
|
||||||
|
$errors[] = 'La finalité est requise.';
|
||||||
|
}
|
||||||
|
|
||||||
// Languages
|
// Languages
|
||||||
$langIds = isset($post['languages']) && is_array($post['languages']) ? $post['languages'] : [];
|
$langIds = isset($post['languages']) && is_array($post['languages']) ? $post['languages'] : [];
|
||||||
if (empty($langIds)) $errors[] = 'Au moins une langue est requise.';
|
if (empty($langIds)) {
|
||||||
|
$errors[] = 'Au moins une langue est requise.';
|
||||||
|
}
|
||||||
|
|
||||||
// Formats
|
// Formats
|
||||||
$fmtIds = isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : [];
|
$fmtIds = isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : [];
|
||||||
if (empty($fmtIds)) $errors[] = 'Au moins un format est requis.';
|
if (empty($fmtIds)) {
|
||||||
|
$errors[] = 'Au moins un format est requis.';
|
||||||
|
}
|
||||||
|
|
||||||
// Licence
|
// Licence
|
||||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||||
$licenseCustom = trim($post['license_custom'] ?? '');
|
$licenseCustom = trim($post['license_custom'] ?? '');
|
||||||
if (!$licenseId && $licenseCustom === '') $errors[] = 'Une licence est requise.';
|
if (!$licenseId && $licenseCustom === '') {
|
||||||
|
$errors[] = 'Une licence est requise.';
|
||||||
|
}
|
||||||
|
|
||||||
// Jury
|
// Jury
|
||||||
$hasPromoteur = !empty(trim($post['jury_promoteur'] ?? ''));
|
$hasPromoteur = !empty(trim($post['jury_promoteur'] ?? ''));
|
||||||
$hasLecteurInt = false;
|
$hasLecteurInt = false;
|
||||||
$hasLecteurExt = false;
|
$hasLecteurExt = false;
|
||||||
foreach ($post['jury_lecteur_interne'] ?? [] as $n) {
|
foreach ($post['jury_lecteur_interne'] ?? [] as $n) {
|
||||||
if (trim((string)$n) !== '') { $hasLecteurInt = true; break; }
|
if (trim((string)$n) !== '') {
|
||||||
|
$hasLecteurInt = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
foreach ($post['jury_lecteur_externe'] ?? [] as $n) {
|
foreach ($post['jury_lecteur_externe'] ?? [] as $n) {
|
||||||
if (trim((string)$n) !== '') { $hasLecteurExt = true; break; }
|
if (trim((string)$n) !== '') {
|
||||||
|
$hasLecteurExt = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasPromoteur) {
|
||||||
|
$errors[] = 'Un·e promoteur·ice interne est requis.';
|
||||||
|
}
|
||||||
|
if (!$hasLecteurInt) {
|
||||||
|
$errors[] = 'Au moins un·e lecteur·ice interne est requis.';
|
||||||
|
}
|
||||||
|
if (!$hasLecteurExt) {
|
||||||
|
$errors[] = 'Au moins un·e lecteur·ice externe est requis.';
|
||||||
}
|
}
|
||||||
if (!$hasPromoteur) $errors[] = 'Un·e promoteur·ice interne est requis.';
|
|
||||||
if (!$hasLecteurInt) $errors[] = 'Au moins un·e lecteur·ice interne est requis.';
|
|
||||||
if (!$hasLecteurExt) $errors[] = 'Au moins un·e lecteur·ice externe est requis.';
|
|
||||||
|
|
||||||
if (!empty($errors)) {
|
if (!empty($errors)) {
|
||||||
throw new RuntimeException(implode(' ', $errors));
|
throw new RuntimeException(implode(' ', $errors));
|
||||||
@@ -241,7 +273,7 @@ class ThesisEditController
|
|||||||
$showContact = !empty($post['contact_public']);
|
$showContact = !empty($post['contact_public']);
|
||||||
$authorNames = [];
|
$authorNames = [];
|
||||||
if ($authorsRaw !== '') {
|
if ($authorsRaw !== '') {
|
||||||
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn($n) => $n !== ''));
|
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== ''));
|
||||||
sort($authorNames, SORT_NATURAL);
|
sort($authorNames, SORT_NATURAL);
|
||||||
}
|
}
|
||||||
$authorEntries = [];
|
$authorEntries = [];
|
||||||
@@ -402,7 +434,7 @@ class ThesisEditController
|
|||||||
$authorName = trim($post['auteurice'] ?? 'unknown');
|
$authorName = trim($post['auteurice'] ?? 'unknown');
|
||||||
|
|
||||||
// Sort the raw comma-separated string alphabetically, then slugify.
|
// Sort the raw comma-separated string alphabetically, then slugify.
|
||||||
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn($n) => $n !== ''));
|
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn ($n) => $n !== ''));
|
||||||
sort($names, SORT_NATURAL);
|
sort($names, SORT_NATURAL);
|
||||||
$authorSlug = $this->generateAuthorSlug(implode(', ', $names));
|
$authorSlug = $this->generateAuthorSlug(implode(', ', $names));
|
||||||
|
|
||||||
@@ -674,7 +706,7 @@ class ThesisEditController
|
|||||||
|
|
||||||
// Backwards compat: old jury_lecteurs[]
|
// Backwards compat: old jury_lecteurs[]
|
||||||
if (isset($post['jury_lecteurs'])) {
|
if (isset($post['jury_lecteurs'])) {
|
||||||
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
|
foreach ($post['jury_lecteurs'] as $i => $name) {
|
||||||
$name = trim($name);
|
$name = trim($name);
|
||||||
if ($name !== '') {
|
if ($name !== '') {
|
||||||
$members[] = [
|
$members[] = [
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
<div class="fhb-block-info">
|
<div class="fhb-block-info">
|
||||||
<span class="fhb-block-label"><?= htmlspecialchars($b['label']) ?></span>
|
<span class="fhb-block-label"><?= htmlspecialchars($b['label']) ?></span>
|
||||||
<?php if (trim($b['content']) !== ''): ?>
|
<?php if (trim($b['content']) !== ''): ?>
|
||||||
<span class="fhb-block-preview"><?= htmlspecialchars(mb_strimwidth(trim($b['content']), 0, 60, '…')) ?></span>
|
<span class="fhb-block-preview"><?= htmlspecialchars(strlen($preview = trim($b['content'])) > 60 ? substr($preview, 0, 60) . '…' : $preview) ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="fhb-block-empty">— vide —</span>
|
<span class="fhb-block-empty">— vide —</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
10
biome.json
10
biome.json
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true
|
"ignoreUnknown": true,
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!app/public/assets/js/htmx.min.js",
|
||||||
|
"!app/public/assets/js/overtype.min.js",
|
||||||
|
"!app/public/assets/js/sortable.min.js"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -10,6 +16,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": false
|
"enabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
justfile
8
justfile
@@ -38,7 +38,7 @@ logs:
|
|||||||
|
|
||||||
[group('deploy')]
|
[group('deploy')]
|
||||||
deploy:
|
deploy:
|
||||||
# Main deploy (code + assets)
|
# Main deploy (code + assets) then run any pending DB migrations
|
||||||
rsync -vur --progress --delete \
|
rsync -vur --progress --delete \
|
||||||
--chown="www-data:xamxam" \
|
--chown="www-data:xamxam" \
|
||||||
--exclude 'vendor' \
|
--exclude 'vendor' \
|
||||||
@@ -61,10 +61,6 @@ deploy:
|
|||||||
--exclude 'var/logs/*' \
|
--exclude 'var/logs/*' \
|
||||||
app/ xamxam:/var/www/xamxam/
|
app/ xamxam:/var/www/xamxam/
|
||||||
ssh xamxam "mkdir -p /var/www/xamxam/var/{cache,logs,tmp}"
|
ssh xamxam "mkdir -p /var/www/xamxam/var/{cache,logs,tmp}"
|
||||||
|
|
||||||
[group('deploy')]
|
|
||||||
deploy-migrate:
|
|
||||||
# Run pending DB migrations on the remote production database
|
|
||||||
ssh xamxam "cd /var/www/xamxam && php migrations/run.php /var/www/xamxam/storage/xamxam.db"
|
ssh xamxam "cd /var/www/xamxam && php migrations/run.php /var/www/xamxam/storage/xamxam.db"
|
||||||
|
|
||||||
[group('deploy')]
|
[group('deploy')]
|
||||||
@@ -96,7 +92,7 @@ test:
|
|||||||
|
|
||||||
[group('test')]
|
[group('test')]
|
||||||
lint-biome:
|
lint-biome:
|
||||||
@biome lint app/public/assets/js/file-preview.js app/public/assets/js/file-upload-queue.js
|
@biome lint app/public/assets/js/
|
||||||
|
|
||||||
[group('test')]
|
[group('test')]
|
||||||
phpstan:
|
phpstan:
|
||||||
|
|||||||
Reference in New Issue
Block a user