mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
feat(admin): cleanup page — remove 'Fichiers temporaires' level, promote sections to h2 TOC entries
This commit is contained in:
@@ -16,7 +16,32 @@
|
||||
## Pending
|
||||
- [x] #home-htmx-lazy-cover-images [!medium] Add htmx lazyloading on home page cover images: `revealed`-triggered cover-fragment endpoint, swap-in `<img>`, settle fade CSS, load htmx on home
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-toc-fix-duplicate-id [+admin] Clear TOC links after cleanup restructure. Root cause: admin-toc.js assigned `sec.id = headingId` to sections, and on rebuild-because-of-htmx-swap the section + h2 ended up sharing the same id, so getElementById returned the <section> whose textContent dragged the whole data table into the TOC label. Fix: anchor links to the heading id and never give the section that id (no duplicate ids); verified via Playwright — TOC now lists exactly Tiléversements abandonnés / Corbeille / Vidéos PeerTube with no table text.
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-stats-under-headings [+admin] On the cleanup page, move each section's stat/count out of the heading to a line directly under it, and merge PeerTube into a single heading. cleanup-stats-fragment: "Téléversements abandonnés" / "Corbeille" h2 now bare, stat emitted as `<p class=n-meta>` under the h2. peertube-orphans-fragment: removed the separate "Vidéos orphelines" h3; the orphan count now appears as the stat line under the static "Vidéos PeerTube" h2. Verified via Playwright: TOC still lists exactly 3 clean entries; stats render under headings.
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-stat-card [+admin] Render the cleanup-page section stats as a proper card box (`cleanup-stat-card`, bigger than the old inline n-meta): padded panel (bg-secondary + border, radius 10px), step-1/700/accent text. Applied to the filepond, trash and PeerTube stat lines. Verified computed style via Playwright.
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-desc-and-tooltips [+admin] Cleanup page: add short French descriptions under each section heading (Téléversements abandonnés / Corbeille / Vidéos PeerTube) explaining what the data is and how safe deletion is (`.cleanup-section-desc`), and native title tooltips on the Supprimer and Restaurer buttons of each row. Verified via Playwright (descriptions + tooltips render on all rows).
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-auto-gc-uploads [+scripts/deploy] Abandoned FilePond uploads are now garbage-collected automatically (they are risk-free staging dirs never linked to a TFE). Added `scripts/cleanup-tmp-uploads.php` (dry-run by default, mirrors the admin cleanup filepond logic: missing-session via manifest.json + 2h fallback), hourly cron `deploy/xamxam-tmp-cleanup.cron`, `just deploy-tmp-cleanup-cron` + `just deploy-check-tmp-cleanup-log`, wired into `deploy-all-first`, and documented in docs/deployment.md. Tested dry-run + --no-dry-run + active-session protection locally.
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-remove-filepond-section [+admin] Since abandoned FilePond uploads are GC'd by cron, removed the "Téléversements abandonnés" section from the cleanup page and the index "Nettoyer" badge now counts trash only. cleanup-stats-fragment.php renders only the Corbeille section (and "Corbeille vide" when empty); index.php badge is trash-only; admin-cleanup-bulk.js simplified to trash_files[] only. Verified via Playwright: only Corbeille + Vidéos PeerTube sections/TOC, no filepond checkboxes, tooltips intact.
|
||||
|
||||
## In Progress
|
||||
- [x] #cleanup-page-title [+admin] Simplify cleanup page title from "Nettoyer les fichiers temporaires" to "Nettoyage" (page now mostly manages the Corbeille). Updated `app/public/admin/cleanup.php` pageTitle and `app/templates/admin/cleanup.php` h1.
|
||||
|
||||
## In Progress
|
||||
- [x] #docs-cleanup-changes [docs] Update docs for the cleanup-page + GC changes made: admin README cleanup.php entry -> "Cleanup page (Corbeille — restore/delete trashed files)"; deployment.md adds a paragraph explaining abandoned uploads are auto-GC'd hourly and the page (titled Nettoyage) now only shows the Corbeille; added `just cleanup-tmp-uploads` recipe and documented it in development.md alongside cleanup-drafts.
|
||||
|
||||
## Completed
|
||||
- [x] #cleanup-toc [+admin] Add sidebar TOC to admin cleanup page; drop the "Fichiers temporaires" section level and promote "Téléversements abandonnés" / "Corbeille" from h3 to h2 top-level sections (now TOC entries). Made admin-toc.js rebuild on htmx swap/settle so async-loaded fragment sections enter the TOC.
|
||||
- [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
|
||||
|
||||
@@ -11,7 +11,7 @@ the XAMXAM TFE database.
|
||||
| `add.php` | Add new thesis form |
|
||||
| `edit.php` | Existing thesis form |
|
||||
| `recapitulatif.php` | Post-submission recap |
|
||||
| `cleanup.php` | Orphaned-draft / storage cleanup |
|
||||
| `cleanup.php` | Cleanup page (Corbeille — restore/delete trashed files) |
|
||||
| `system.php` | System dashboard (logs, SMTP/PeerTube status) |
|
||||
| `contenus.php` | Editable content (pages, contacts) |
|
||||
| `contenus-edit.php` | Edit a content page |
|
||||
|
||||
@@ -13,34 +13,22 @@ AdminAuth::requireLogin();
|
||||
require_once __DIR__ . '/_cleanup-stats-data.php';
|
||||
$d = getCleanupStats();
|
||||
|
||||
$fpStale = $d['filepond_stale_count'] ?? 0;
|
||||
$fpActive = $d['filepond_active_count'] ?? 0;
|
||||
$trStale = $d['trash_stale_count'] ?? 0;
|
||||
$trActive = $d['trash_active_count'] ?? 0;
|
||||
// Abandoned FilePond uploads are purged automatically by a cron job, so the
|
||||
// cleanup page only surfaces the manually-managed trash (Corbeille).
|
||||
$hasTrash = (($d['trash_stale_count'] ?? 0) + ($d['trash_active_count'] ?? 0)) > 0;
|
||||
|
||||
$totalStale = $fpStale + $trStale;
|
||||
$totalFiles = $fpStale + $fpActive + $trStale + $trActive;
|
||||
|
||||
$staleSize = ($d['filepond_stale_size'] ?? 0) + ($d['trash_stale_size'] ?? 0);
|
||||
$staleHuman = '';
|
||||
if ($staleSize >= 1073741824) { $staleHuman = number_format($staleSize / 1073741824, 1) . ' GB'; }
|
||||
elseif ($staleSize >= 1048576) { $staleHuman = number_format($staleSize / 1048576, 1) . ' MB'; }
|
||||
elseif ($staleSize >= 1024) { $staleHuman = number_format($staleSize / 1024, 0) . ' KB'; }
|
||||
elseif ($staleSize > 0) { $staleHuman = $staleSize . ' B'; }
|
||||
|
||||
$fpMeta = $fpStale . ' dossier' . ($fpStale > 1 ? 's' : '');
|
||||
if ($fpStale > 0) {
|
||||
$fpMeta .= ' · ' . ($d['filepond_stale_human'] ?? '');
|
||||
}
|
||||
$trMeta = ($trStale + $trActive) . ' fichier' . (($trStale + $trActive) > 1 ? 's' : '');
|
||||
$trTotalSize = ($d['trash_stale_size'] ?? 0) + ($d['trash_active_size'] ?? 0);
|
||||
$trTotalHuman = humanBytes($trTotalSize);
|
||||
if (($trStale + $trActive) > 0) {
|
||||
$trMeta .= ' · ' . $trTotalHuman;
|
||||
$trMeta = '';
|
||||
if ($hasTrash) {
|
||||
$trCount = ($d['trash_stale_count'] ?? 0) + ($d['trash_active_count'] ?? 0);
|
||||
$trMeta = $trCount . ' fichier' . ($trCount > 1 ? 's' : '');
|
||||
$trTotalSize = ($d['trash_stale_size'] ?? 0) + ($d['trash_active_size'] ?? 0);
|
||||
if ($trTotalSize > 0) {
|
||||
$trMeta .= ' · ' . humanBytes($trTotalSize);
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if ($totalStale === 0 && $totalFiles === 0): ?>
|
||||
<p style="margin:0;color:var(--accent-green)">✓ Aucun fichier temporaire.</p>
|
||||
<?php if (!$hasTrash): ?>
|
||||
<p style="margin:0;color:var(--accent-green)">✓ Corbeille vide — aucun fichier à nettoyer.</p>
|
||||
<?php return; endif; ?>
|
||||
|
||||
<!-- Bulk actions bar -->
|
||||
@@ -78,35 +66,6 @@ if (($trStale + $trActive) > 0) {
|
||||
<div id="cleanup-bulk-restore-checkboxes"></div>
|
||||
</form>
|
||||
|
||||
<?php if ($fpStale > 0): ?>
|
||||
<h3 id="tmp-filepond-heading">Téléversements abandonnés <span class="n-meta"><?= htmlspecialchars($fpMeta) ?></span></h3>
|
||||
<table class="n-table" aria-labelledby="tmp-filepond-heading">
|
||||
<thead><tr><th width="1%"><input type="checkbox" onchange="cleanupToggleAll(this, 'filepond')" title="Tout sélectionner"></th><th>Nom</th><th>Taille</th><th>Âge</th><th width="1%"></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($d['filepond_stale_files'] as $f): ?>
|
||||
<tr>
|
||||
<td><input type="checkbox" name="filepond_dirs[]" value="<?= htmlspecialchars($f['name']) ?>" data-cleanup-group="filepond" onchange="cleanupUpdateBulk()"></td>
|
||||
<td><strong><?= htmlspecialchars($f['name']) ?></strong></td>
|
||||
<td style="white-space:nowrap"><?= htmlspecialchars($f['human']) ?></td>
|
||||
<td style="white-space:nowrap">~<?= (int)$f['age_minutes'] ?> min</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn btn--sm btn--danger" style="font-size:0.85em;padding:2px var(--space-xs)"
|
||||
hx-post="/admin/actions/cleanup-tmp.php"
|
||||
hx-confirm="Supprimer définitivement ce téléversement abandonné ?"
|
||||
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","filepond_dir":"<?= htmlspecialchars($f['name']) ?>"}'
|
||||
hx-target="#tmp-cleanup-stats-wrapper"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#tmp-cleanup-stats-wrapper">
|
||||
<?= icon('trash') ?>
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Merge stale + active trash files into a single list for display ─────
|
||||
// stale = orphaned (no sidecar, or too old); active = restorable (has sidecar)
|
||||
@@ -126,7 +85,10 @@ foreach (($d['trash_stale_files'] ?? []) as $f) {
|
||||
}
|
||||
$hasTrash = !empty($allTrashFiles);
|
||||
if ($hasTrash): ?>
|
||||
<h3 id="tmp-trash-heading">Corbeille <span class="n-meta"><?= htmlspecialchars($trMeta) ?></span></h3>
|
||||
<section aria-labelledby="tmp-trash-heading">
|
||||
<h2 id="tmp-trash-heading">Corbeille</h2>
|
||||
<p class="cleanup-section-desc">Fichiers retirés d'un TFE. Ceux marqués « Restaurable » peuvent être remis dans le TFE d'origine ; les autres sont orphelins (plus aucun TFE lié). La suppression est définitive et sans retour.</p>
|
||||
<p class="cleanup-stat-card"><?= htmlspecialchars($trMeta) ?></p>
|
||||
<table class="n-table" aria-labelledby="tmp-trash-heading">
|
||||
<thead><tr><th width="1%"><input type="checkbox" onchange="cleanupToggleAll(this, 'trash')" title="Tout sélectionner"></th><th>Nom</th><th>Taille</th><th>Âge</th><th>Statut</th><th width="1%"></th></tr></thead>
|
||||
<tbody>
|
||||
@@ -147,6 +109,7 @@ if ($hasTrash): ?>
|
||||
<?php if ($f['_restorable']): ?>
|
||||
<button type="button" class="btn btn--sm"
|
||||
style="font-size:0.85em;padding:2px var(--space-xs);margin-right:4px"
|
||||
title="Restaurer ce fichier vers le TFE associé"
|
||||
hx-post="/admin/actions/restore-trash.php"
|
||||
hx-confirm="Restaurer ce fichier vers le TFE associé ?"
|
||||
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","trash_file":"<?= htmlspecialchars($f['name']) ?>"}'
|
||||
@@ -158,6 +121,7 @@ if ($hasTrash): ?>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn--sm btn--danger"
|
||||
style="font-size:0.85em;padding:2px var(--space-xs)"
|
||||
title="Supprimer définitivement ce fichier de la corbeille (irréversible)"
|
||||
hx-post="/admin/actions/cleanup-tmp.php"
|
||||
hx-confirm="Supprimer définitivement ce fichier de la corbeille ?"
|
||||
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","trash_file":"<?= htmlspecialchars($f['name']) ?>"}'
|
||||
@@ -172,10 +136,6 @@ if ($hasTrash): ?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($fpActive > 0): ?>
|
||||
<p style="margin:var(--space-sm) 0 0 0;font-size:0.85em;color:var(--text-secondary)">Conservés :
|
||||
<?= $fpActive . ' téléversement(s) actif(s) (' . htmlspecialchars($d['filepond_active_human']) . ')' ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -22,8 +22,9 @@ if (!($d['configured'] ?? false)): ?>
|
||||
<?php return; endif; ?>
|
||||
|
||||
<?php if (($d['orphan_count'] ?? 0) > 0): ?>
|
||||
<h3 id="peertube-orphans-heading">Vidéos orphelines <span class="n-meta"><?= (int)$d['orphan_count'] ?> vidéos orphelines</span></h3>
|
||||
<table class="n-table" aria-labelledby="peertube-orphans-heading">
|
||||
<p class="cleanup-section-desc">Vidéos toujours présentes sur l'instance PeerTube mais plus rattachées à aucun TFE du site. Supprimer une vidéo est définitif et sans retour.</p>
|
||||
<p class="cleanup-stat-card"><?= (int)$d['orphan_count'] ?> vidéo<?= ($d['orphan_count'] > 1 ? 's' : '') ?> orpheline<?= ($d['orphan_count'] > 1 ? 's' : '') ?></p>
|
||||
<table class="n-table" aria-labelledby="cleanup-peertube-title">
|
||||
<thead><tr><th>Nom</th><th>Date</th><th width="1%"></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($d['orphans'] as $v): ?>
|
||||
@@ -35,6 +36,7 @@ if (!($d['configured'] ?? false)): ?>
|
||||
<td style="white-space:nowrap"><?= htmlspecialchars(substr($v['createdAt'] ?? '', 0, 10)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn btn--sm btn--danger" style="font-size:0.85em;padding:2px var(--space-xs)"
|
||||
title="Supprimer définitivement cette vidéo de PeerTube (irréversible)"
|
||||
hx-post="/admin/actions/peertube-delete.php"
|
||||
hx-confirm="Supprimer définitivement cette vidéo de PeerTube ?"
|
||||
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","uuid":"<?= htmlspecialchars($v['uuid']) ?>"}'
|
||||
|
||||
@@ -7,7 +7,7 @@ if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$pageTitle = 'Nettoyer les fichiers temporaires';
|
||||
$pageTitle = 'Nettoyage';
|
||||
|
||||
$isAdmin = true;
|
||||
$bodyClass = 'admin-body';
|
||||
|
||||
+155
-93
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . "/../../bootstrap.php";
|
||||
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
@@ -7,7 +8,7 @@ if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$pageTitle = "Liste des TFE";
|
||||
$pageTitle = 'Liste des TFE';
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
// ── CSV Import (inline, submitted to this same page) ─────────────────────────
|
||||
@@ -18,7 +19,7 @@ $importDone = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
$importErrors[] = "Erreur de sécurité : token invalide.";
|
||||
$importErrors[] = 'Erreur de sécurité : token invalide.';
|
||||
} else {
|
||||
$importedCount = 0;
|
||||
$skippedCount = 0;
|
||||
@@ -27,11 +28,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$importPdo = $importDb->getPDO();
|
||||
|
||||
if ($_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new Exception("Erreur lors du téléversement du fichier.");
|
||||
throw new Exception('Erreur lors du téléversement du fichier.');
|
||||
}
|
||||
|
||||
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
|
||||
if (!$handle) throw new Exception("Impossible d'ouvrir le fichier CSV.");
|
||||
if (!$handle) {
|
||||
throw new Exception("Impossible d'ouvrir le fichier CSV.");
|
||||
}
|
||||
|
||||
// Scan up to 8 rows looking for a header row with known column names.
|
||||
// Build colIdx[name] → position map; fall back to positional if header not found.
|
||||
@@ -50,40 +53,66 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$knownHeaders[] = 'license';
|
||||
for ($scan = 0; $scan < 8; $scan++) {
|
||||
$hrow = fgetcsv($handle, 0, ',', '"', '');
|
||||
if ($hrow === false) break;
|
||||
if ($hrow === false) {
|
||||
break;
|
||||
}
|
||||
$headerRowNum++;
|
||||
$normRow = array_map(fn($s) => strtolower(trim((string)$s)), $hrow);
|
||||
$normRow = array_map(fn ($s) => strtolower(trim((string)$s)), $hrow);
|
||||
$hits = 0;
|
||||
$map = [];
|
||||
$used = [];
|
||||
foreach ($knownHeaders as $h) {
|
||||
foreach ($normRow as $pos => $cell) {
|
||||
if (isset($used[$pos])) continue;
|
||||
if (isset($used[$pos])) {
|
||||
continue;
|
||||
}
|
||||
// Exact match
|
||||
if ($cell === $h) { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
|
||||
if ($cell === $h) {
|
||||
$hits++;
|
||||
$map[$h] = $pos;
|
||||
$used[$pos] = true;
|
||||
break;
|
||||
}
|
||||
// Licence/License cross-match
|
||||
if (($h === 'licence' && $cell === 'license') || ($h === 'license' && $cell === 'licence'))
|
||||
{ $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
|
||||
if (($h === 'licence' && $cell === 'license') || ($h === 'license' && $cell === 'licence')) {
|
||||
$hits++;
|
||||
$map[$h] = $pos;
|
||||
$used[$pos] = true;
|
||||
break;
|
||||
}
|
||||
// Prefix match (for compound headers like "contact.visible")
|
||||
$hlen = strlen($h);
|
||||
if ($hlen >= 4 && str_starts_with($cell, $h)) {
|
||||
// Avoid short prefixes matching unrelated words
|
||||
if ($hlen >= 5 || $cell === $h) { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
|
||||
if ($hlen >= 5 || $cell === $h) {
|
||||
$hits++;
|
||||
$map[$h] = $pos;
|
||||
$used[$pos] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Substring match for short distinguishers (ulb, externe)
|
||||
if ($hlen >= 3 && $hlen <= 7 && str_contains($cell, $h)) {
|
||||
$hits++; $map[$h] = $pos; $used[$pos] = true; break;
|
||||
$hits++;
|
||||
$map[$h] = $pos;
|
||||
$used[$pos] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Require at least 11 known headers to trust the row.
|
||||
if ($hits >= 11) { $colIdx = $map; break; }
|
||||
if ($hits >= 11) {
|
||||
$colIdx = $map;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no header row found, rewind and fall back to positional (skip 4 rows).
|
||||
if ($colIdx === null) {
|
||||
rewind($handle);
|
||||
$headerRowNum = 4;
|
||||
for ($i = 0; $i < 4; $i++) fgetcsv($handle);
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
fgetcsv($handle);
|
||||
}
|
||||
} else {
|
||||
// Consume blank/instruction/template rows between header and data.
|
||||
// Stops when a row has a non-empty identifiant column that is not a
|
||||
@@ -92,7 +121,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$peekRow = null;
|
||||
while (true) {
|
||||
$peek = fgetcsv($handle, 0, ',', '"', '');
|
||||
if ($peek === false) break;
|
||||
if ($peek === false) {
|
||||
break;
|
||||
}
|
||||
$headerRowNum++;
|
||||
$val = trim((string)($peek[$idPos] ?? ''));
|
||||
if ($val === '' || str_starts_with(strtolower($val), 'column')
|
||||
@@ -117,28 +148,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
// When header was found: use mapped column.
|
||||
// When header was found but key is missing: use positional fallback.
|
||||
// When no header found: use positional fallback.
|
||||
$cell = function(array $row, string $name) use ($colIdx, $fallbackPositions): string {
|
||||
$cell = function (array $row, string $name) use ($colIdx, $fallbackPositions): string {
|
||||
if ($colIdx !== null) {
|
||||
$pos = $colIdx[$name] ?? null;
|
||||
if ($pos === null) {
|
||||
// Try licence/license cross-lookup
|
||||
if ($name === 'license') $pos = $colIdx['licence'] ?? null;
|
||||
elseif ($name === 'licence') $pos = $colIdx['license'] ?? null;
|
||||
if ($name === 'license') {
|
||||
$pos = $colIdx['licence'] ?? null;
|
||||
} elseif ($name === 'licence') {
|
||||
$pos = $colIdx['license'] ?? null;
|
||||
}
|
||||
}
|
||||
// Fall back to positional if header didn't have this key
|
||||
if ($pos === null) {
|
||||
$pos = $fallbackPositions[$name] ?? null;
|
||||
}
|
||||
if ($pos === null) return '';
|
||||
if ($pos === null) {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
$pos = $fallbackPositions[$name] ?? null;
|
||||
if ($pos === null) return '';
|
||||
if ($pos === null) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return isset($row[$pos]) ? trim((string)$row[$pos]) : '';
|
||||
};
|
||||
|
||||
// Helper: parse "Oui"/"Non"/1/0 strings to int
|
||||
$parseBool = function(string $raw): int {
|
||||
$parseBool = function (string $raw): int {
|
||||
$raw = strtolower(trim($raw));
|
||||
return ($raw === 'oui' || $raw === '1' || $raw === 'yes' || $raw === 'true') ? 1 : 0;
|
||||
};
|
||||
@@ -149,9 +187,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
// Europe/Brussels timezone and convert to UTC for storage, matching
|
||||
// the rest of the DB (SQLite CURRENT_TIMESTAMP) and the db_datetime()
|
||||
// display helper which converts UTC → Brussels on read.
|
||||
$parseBulkDate = function(string $raw): ?string {
|
||||
$parseBulkDate = function (string $raw): ?string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') return null;
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try a set of formats (d/m/Y first — the CSV convention).
|
||||
// Date-only formats (no time component) are anchored to midnight
|
||||
@@ -190,11 +230,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
|
||||
// Code → canonical name (legacy short-code CSV format)
|
||||
$orientationCodeMap = [
|
||||
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
|
||||
'IP'=>'Installation-Performance','PE'=>'Peinture','PH'=>'Photographie',
|
||||
'DE'=>'Dessin','AN'=>'Arts Numériques','GR'=>'Graphisme',
|
||||
'TY'=>'Typographie','DN'=>'Design Numérique','IL'=>'Illustration',
|
||||
'BD'=>'Bande-Dessinée','SE'=>'Sérigraphie','GV'=>'Gravure',
|
||||
'SC' => 'Sculpture','VI' => 'Vidéographie','CA' => "Cinéma d'animation",
|
||||
'IP' => 'Installation-Performance','PE' => 'Peinture','PH' => 'Photographie',
|
||||
'DE' => 'Dessin','AN' => 'Arts Numériques','GR' => 'Graphisme',
|
||||
'TY' => 'Typographie','DN' => 'Design Numérique','IL' => 'Illustration',
|
||||
'BD' => 'Bande-Dessinée','SE' => 'Sérigraphie','GV' => 'Gravure',
|
||||
];
|
||||
|
||||
// Alias map: normalise known variant spellings → canonical DB name.
|
||||
@@ -210,9 +250,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
];
|
||||
|
||||
// Resolve an orientation string (code or full name) → canonical DB name.
|
||||
$resolveOrientation = function(string $raw) use ($orientationCodeMap, $orientationAliases, $importPdo): ?int {
|
||||
$resolveOrientation = function (string $raw) use ($orientationCodeMap, $orientationAliases, $importPdo): ?int {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') return null;
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Try legacy short code
|
||||
if (isset($orientationCodeMap[$raw])) {
|
||||
@@ -226,13 +268,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
}
|
||||
|
||||
// 3. Exact DB match
|
||||
$s = $importPdo->prepare("SELECT id FROM orientations WHERE name = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM orientations WHERE name = ?');
|
||||
$s->execute([$raw]);
|
||||
$r = $s->fetch();
|
||||
if ($r) return (int)$r['id'];
|
||||
if ($r) {
|
||||
return (int)$r['id'];
|
||||
}
|
||||
|
||||
// 4. Case-insensitive DB match
|
||||
$s = $importPdo->prepare("SELECT id FROM orientations WHERE LOWER(name) = LOWER(?)");
|
||||
$s = $importPdo->prepare('SELECT id FROM orientations WHERE LOWER(name) = LOWER(?)');
|
||||
$s->execute([$raw]);
|
||||
$r = $s->fetch();
|
||||
return $r ? (int)$r['id'] : null;
|
||||
@@ -257,9 +301,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
];
|
||||
|
||||
// Resolve an AP string (code or full name) → ap_program id.
|
||||
$resolveAP = function(string $raw) use ($apAliases, $importPdo): ?int {
|
||||
$resolveAP = function (string $raw) use ($apAliases, $importPdo): ?int {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') return null;
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Try alias map (lowercase key) → canonical code
|
||||
$key = strtolower($raw);
|
||||
@@ -268,19 +314,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
}
|
||||
|
||||
// 2. Match by code
|
||||
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM ap_programs WHERE code = ?');
|
||||
$s->execute([$raw]);
|
||||
$r = $s->fetch();
|
||||
if ($r) return (int)$r['id'];
|
||||
if ($r) {
|
||||
return (int)$r['id'];
|
||||
}
|
||||
|
||||
// 3. Code match (legacy)
|
||||
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM ap_programs WHERE code = ?');
|
||||
$s->execute([$raw]);
|
||||
$r = $s->fetch();
|
||||
if ($r) return (int)$r['id'];
|
||||
if ($r) {
|
||||
return (int)$r['id'];
|
||||
}
|
||||
|
||||
// 4. Case-insensitive name match
|
||||
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE LOWER(name) = LOWER(?)");
|
||||
$s = $importPdo->prepare('SELECT id FROM ap_programs WHERE LOWER(name) = LOWER(?)');
|
||||
$s->execute([$raw]);
|
||||
$r = $s->fetch();
|
||||
return $r ? (int)$r['id'] : null;
|
||||
@@ -294,10 +344,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$usePeek = false;
|
||||
} else {
|
||||
$row = fgetcsv($handle, 0, ',', '"', '');
|
||||
if ($row === false) break;
|
||||
if ($row === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$lineNumber++;
|
||||
if (empty($row[0]) && empty($row[1])) continue;
|
||||
if (empty($row[0]) && empty($row[1])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$importDb->beginTransaction();
|
||||
|
||||
@@ -355,10 +409,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
|
||||
if ($title === '' || $year === 0) {
|
||||
$missing = [];
|
||||
if ($title === '') $missing[] = 'titre';
|
||||
if ($year === 0) $missing[] = 'année';
|
||||
throw new Exception("Champ(s) requis manquant(s) : " . implode(', ', $missing)
|
||||
. " (id=\"" . ($identifier ?: '?') . "\", titre=\"" . substr($title, 0, 80) . "\")");
|
||||
if ($title === '') {
|
||||
$missing[] = 'titre';
|
||||
}
|
||||
if ($year === 0) {
|
||||
$missing[] = 'année';
|
||||
}
|
||||
throw new Exception('Champ(s) requis manquant(s) : ' . implode(', ', $missing)
|
||||
. ' (id="' . ($identifier ?: '?') . '", titre="' . substr($title, 0, 80) . '")');
|
||||
}
|
||||
|
||||
$orientationId = $resolveOrientation($orientationCode);
|
||||
@@ -366,26 +424,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
|
||||
$finalityId = null;
|
||||
if (!empty($finalityName)) {
|
||||
$s = $importPdo->prepare("SELECT id FROM finality_types WHERE name = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM finality_types WHERE name = ?');
|
||||
$s->execute([$finalityName]);
|
||||
$r = $s->fetch(); $finalityId = $r ? $r['id'] : null;
|
||||
$r = $s->fetch();
|
||||
$finalityId = $r ? $r['id'] : null;
|
||||
}
|
||||
|
||||
$accessTypeId = null;
|
||||
if (!empty($access)) {
|
||||
$s = $importPdo->prepare("SELECT id FROM access_types WHERE name = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM access_types WHERE name = ?');
|
||||
$s->execute([ucfirst(strtolower($access))]);
|
||||
$r = $s->fetch(); $accessTypeId = $r ? $r['id'] : null;
|
||||
$r = $s->fetch();
|
||||
$accessTypeId = $r ? $r['id'] : null;
|
||||
}
|
||||
if ($accessTypeId === null) {
|
||||
$accessTypeId = 1;
|
||||
}
|
||||
if ($accessTypeId === null) $accessTypeId = 1;
|
||||
|
||||
if (!empty($identifier)) {
|
||||
$s = $importPdo->prepare("SELECT id FROM theses WHERE identifier = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM theses WHERE identifier = ?');
|
||||
$s->execute([$identifier]);
|
||||
if ($s->fetch()) {
|
||||
$importDb->rollback();
|
||||
$skippedCount++;
|
||||
$importResults[] = ['type'=>'skip', 'msg'=>"Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
|
||||
$importResults[] = ['type' => 'skip', 'msg' => "Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -393,7 +455,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
// Resolve license name → license_id
|
||||
$licenseId = null;
|
||||
if (!empty($license)) {
|
||||
$ls = $importPdo->prepare("SELECT id FROM license_types WHERE LOWER(name) = LOWER(?)");
|
||||
$ls = $importPdo->prepare('SELECT id FROM license_types WHERE LOWER(name) = LOWER(?)');
|
||||
$ls->execute([trim($license)]);
|
||||
$lr = $ls->fetch();
|
||||
$licenseId = $lr ? (int)$lr['id'] : null;
|
||||
@@ -444,7 +506,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
foreach (array_map('trim', explode(',', $authorsRaw)) as $idx => $name) {
|
||||
if ($name) {
|
||||
$aId = $importDb->findOrCreateAuthor($name, $idx === 0 ? $contact : null);
|
||||
$s = $importPdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)");
|
||||
$s = $importPdo->prepare('INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)');
|
||||
$s->execute([$thesisId, $aId, $idx + 1]);
|
||||
}
|
||||
}
|
||||
@@ -457,7 +519,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
if ($name) {
|
||||
$juryOrder++;
|
||||
$sId = $importDb->findOrCreateSupervisor($name);
|
||||
$stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
|
||||
$stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
|
||||
$stmt->execute([$thesisId, $sId, 'promoteur', 0, 0, $juryOrder]);
|
||||
}
|
||||
}
|
||||
@@ -468,7 +530,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
if ($name) {
|
||||
$juryOrder++;
|
||||
$sId = $importDb->findOrCreateSupervisor($name);
|
||||
$stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
|
||||
$stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
|
||||
$stmt->execute([$thesisId, $sId, 'lecteur', 0, 0, $juryOrder]);
|
||||
}
|
||||
}
|
||||
@@ -479,7 +541,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
if ($name) {
|
||||
$juryOrder++;
|
||||
$sId = $importDb->findOrCreateSupervisor($name);
|
||||
$stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
|
||||
$stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
|
||||
$stmt->execute([$thesisId, $sId, 'lecteur', 1, 0, $juryOrder]);
|
||||
}
|
||||
}
|
||||
@@ -490,19 +552,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
if ($name) {
|
||||
$juryOrder++;
|
||||
$sId = $importDb->findOrCreateSupervisor($name);
|
||||
$stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
|
||||
$stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
|
||||
$stmt->execute([$thesisId, $sId, 'promoteur', 1, 1, $juryOrder]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($keywordsRaw)) {
|
||||
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
$normalizeTag = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
$tags = array_values(array_unique(array_map($normalizeTag, explode(',', $keywordsRaw))));
|
||||
$tags = array_filter($tags, fn($t) => $t !== '');
|
||||
$tags = array_filter($tags, fn ($t) => $t !== '');
|
||||
foreach (array_slice($tags, 0, 10) as $kw) {
|
||||
$tId = $importDb->findOrCreateTag($kw);
|
||||
if ($tId) {
|
||||
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
|
||||
$s = $importPdo->prepare('INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)');
|
||||
$s->execute([$thesisId, $tId]);
|
||||
}
|
||||
}
|
||||
@@ -510,28 +572,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
if (!empty($languageRaw)) {
|
||||
foreach (array_map('trim', explode(',', $languageRaw)) as $langName) {
|
||||
$langName = strtolower($langName);
|
||||
if ($langName === '') continue;
|
||||
if ($langName === '') {
|
||||
continue;
|
||||
}
|
||||
// Lookup case-insensitively; insert if missing (stored lowercase).
|
||||
$s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL");
|
||||
$s = $importPdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL');
|
||||
$s->execute([$langName]);
|
||||
$r = $s->fetch();
|
||||
$langId = $r ? (int)$r['id'] : null;
|
||||
if ($langId === null) {
|
||||
$importPdo->prepare("INSERT INTO languages (name) VALUES (?)")->execute([$langName]);
|
||||
$importPdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$langName]);
|
||||
$langId = (int)$importPdo->lastInsertId();
|
||||
}
|
||||
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
|
||||
$s2 = $importPdo->prepare('INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)');
|
||||
$s2->execute([$thesisId, $langId]);
|
||||
}
|
||||
}
|
||||
if (!empty($formatsRaw)) {
|
||||
foreach (array_map('trim', explode(',', $formatsRaw)) as $fmt) {
|
||||
if ($fmt) {
|
||||
$s = $importPdo->prepare("SELECT id FROM format_types WHERE name = ?");
|
||||
$s = $importPdo->prepare('SELECT id FROM format_types WHERE name = ?');
|
||||
$s->execute([ucfirst(strtolower($fmt))]);
|
||||
$r = $s->fetch();
|
||||
if ($r) {
|
||||
$s2 = $importPdo->prepare("INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)");
|
||||
$s2 = $importPdo->prepare('INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)');
|
||||
$s2->execute([$thesisId, $r['id']]);
|
||||
}
|
||||
}
|
||||
@@ -540,12 +604,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
|
||||
$importDb->commit();
|
||||
$importedCount++;
|
||||
$importResults[] = ['type'=>'ok', 'msg'=>"\"$title\" (ID: $thesisId)"];
|
||||
$importResults[] = ['type' => 'ok', 'msg' => "\"$title\" (ID: $thesisId)"];
|
||||
|
||||
} catch (Exception $e) {
|
||||
$importDb->rollback();
|
||||
$skippedCount++;
|
||||
$importResults[] = ['type'=>'error', 'msg'=>"Ligne $lineNumber: " . $e->getMessage()];
|
||||
$importResults[] = ['type' => 'error', 'msg' => "Ligne $lineNumber: " . $e->getMessage()];
|
||||
error_log("Import error on line $lineNumber: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -554,7 +618,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$importDone = true;
|
||||
} catch (Exception $e) {
|
||||
$importErrors[] = $e->getMessage();
|
||||
error_log("CSV import error: " . $e->getMessage());
|
||||
error_log('CSV import error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
@@ -571,10 +635,18 @@ try {
|
||||
$sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
|
||||
|
||||
$filters = [];
|
||||
if ($searchQuery) $filters['search'] = $searchQuery;
|
||||
if ($yearFilter) $filters['year'] = $yearFilter;
|
||||
if ($orientationFilter) $filters['orientation'] = $orientationFilter;
|
||||
if ($apFilter) $filters['ap'] = $apFilter;
|
||||
if ($searchQuery) {
|
||||
$filters['search'] = $searchQuery;
|
||||
}
|
||||
if ($yearFilter) {
|
||||
$filters['year'] = $yearFilter;
|
||||
}
|
||||
if ($orientationFilter) {
|
||||
$filters['orientation'] = $orientationFilter;
|
||||
}
|
||||
if ($apFilter) {
|
||||
$filters['ap'] = $apFilter;
|
||||
}
|
||||
$filters['sort'] = $sortCol;
|
||||
$filters['dir'] = $sortDir;
|
||||
|
||||
@@ -589,21 +661,11 @@ try {
|
||||
$trashedTheses = ($tab === 'trash') ? $db->getTrashedTheses() : [];
|
||||
|
||||
// ── Tmp file stats ───────────────────────────────────────────────────
|
||||
$filepondDir = STORAGE_ROOT . '/tmp/filepond';
|
||||
// Abandoned FilePond uploads are purged automatically by cron, so the badge
|
||||
// only reflects the manually-managed trash.
|
||||
$trashDir = STORAGE_ROOT . '/tmp/_trash';
|
||||
$tmpFilepondCount = 0;
|
||||
$tmpTrashCount = 0;
|
||||
$tmpTotalCount = 0;
|
||||
if (is_dir($filepondDir)) {
|
||||
$items = @scandir($filepondDir);
|
||||
if ($items !== false) {
|
||||
foreach ($items as $item) {
|
||||
if ($item !== '.' && $item !== '..' && $item !== '.gitkeep') {
|
||||
$tmpFilepondCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_dir($trashDir)) {
|
||||
$items = @scandir($trashDir);
|
||||
if ($items !== false) {
|
||||
@@ -614,14 +676,15 @@ try {
|
||||
}
|
||||
}
|
||||
}
|
||||
$tmpTotalCount = $tmpFilepondCount + $tmpTrashCount;
|
||||
$tmpTotalCount = $tmpTrashCount;
|
||||
} catch (Exception $e) {
|
||||
error_log("Error loading theses list: " . $e->getMessage());
|
||||
die("Erreur lors du chargement de la liste.");
|
||||
error_log('Error loading theses list: ' . $e->getMessage());
|
||||
die('Erreur lors du chargement de la liste.');
|
||||
}
|
||||
|
||||
$isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
|
||||
$isAdmin = true; $bodyClass = 'admin-body';
|
||||
$isAdmin = true;
|
||||
$bodyClass = 'admin-body';
|
||||
if ($isHtmx) {
|
||||
if ($tab === 'trash') {
|
||||
include APP_ROOT . '/templates/admin/index-trash.php';
|
||||
@@ -640,4 +703,3 @@ if ($isHtmx) {
|
||||
}
|
||||
require_once APP_ROOT . '/templates/admin/footer.php';
|
||||
}
|
||||
|
||||
|
||||
@@ -1142,6 +1142,31 @@ th.admin-ap-col {
|
||||
margin-left: var(--space-2xs);
|
||||
}
|
||||
|
||||
/* Stat line rendered as a card under a cleanup-section heading. */
|
||||
.cleanup-stat-card {
|
||||
margin: 0 0 var(--space-m);
|
||||
padding: var(--space-s) var(--space-l);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2xs) var(--space-m);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* Short French explanation shown under a cleanup-section heading. */
|
||||
.cleanup-section-desc {
|
||||
margin: 0 0 var(--space-m);
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.n-grid {
|
||||
display: block;
|
||||
}
|
||||
@@ -2284,6 +2309,15 @@ th.admin-ap-col {
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
/* Cleanup page: sections are loaded asynchronously into a wrapper div, so
|
||||
give them the same top-level spacing as direct article sections. */
|
||||
#tmp-cleanup-stats-wrapper > section[aria-labelledby] {
|
||||
margin-bottom: var(--space-xl);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Admin TOC: same <details class="toc"> as public pages, positioned sticky */
|
||||
#admin-toc .toc-list {
|
||||
padding-top: var(--space-2xs);
|
||||
|
||||
@@ -167,3 +167,59 @@
|
||||
.btn--red:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Tooltips — display when a .btn carries a data-tip attribute.
|
||||
Shown below the button on hover/focus, positioned above the
|
||||
toolbar so it never clips inside the scrollable table. */
|
||||
.btn[data-tip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn[data-tip]::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 260px;
|
||||
padding: var(--space-3xs) var(--space-xs);
|
||||
border-radius: var(--radius);
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.btn[data-tip]:hover::after,
|
||||
.btn[data-tip]:focus::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Small arrow below the bubble pointing at the button */
|
||||
.btn[data-tip]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 4px solid var(--text-primary);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.btn[data-tip]:hover::before,
|
||||
.btn[data-tip]:focus::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
* All functions are attached to `window` for onclick handlers in PHP templates.
|
||||
*/
|
||||
(() => {
|
||||
function cleanupToggleAll(src, group) {
|
||||
const selector =
|
||||
group === "filepond"
|
||||
? 'input[name="filepond_dirs[]"]'
|
||||
: 'input[name="trash_files[]"]';
|
||||
document.querySelectorAll(selector).forEach((cb) => {
|
||||
function cleanupToggleAll(src) {
|
||||
document.querySelectorAll('input[name="trash_files[]"]').forEach((cb) => {
|
||||
cb.checked = src.checked;
|
||||
});
|
||||
cleanupUpdateBulk();
|
||||
@@ -23,7 +19,7 @@
|
||||
if (!bar || !countEl) return;
|
||||
|
||||
const allChecked = document.querySelectorAll(
|
||||
'input[name="filepond_dirs[]"]:checked, input[name="trash_files[]"]:checked',
|
||||
'input[name="trash_files[]"]:checked',
|
||||
);
|
||||
const n = allChecked.length;
|
||||
|
||||
@@ -48,13 +44,11 @@
|
||||
// Populate hidden inputs from checked checkboxes
|
||||
container.innerHTML = "";
|
||||
document
|
||||
.querySelectorAll(
|
||||
'input[name="filepond_dirs[]"]:checked, input[name="trash_files[]"]:checked',
|
||||
)
|
||||
.querySelectorAll('input[name="trash_files[]"]:checked')
|
||||
.forEach((cb) => {
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "hidden";
|
||||
inp.name = cb.name; // "filepond_dirs[]" or "trash_files[]"
|
||||
inp.name = cb.name; // "trash_files[]"
|
||||
inp.value = cb.value;
|
||||
container.appendChild(inp);
|
||||
});
|
||||
@@ -65,13 +59,17 @@
|
||||
|
||||
function cleanupBulkRestore() {
|
||||
const form = document.getElementById("cleanup-bulk-restore-form");
|
||||
const container = document.getElementById("cleanup-bulk-restore-checkboxes");
|
||||
const container = document.getElementById(
|
||||
"cleanup-bulk-restore-checkboxes",
|
||||
);
|
||||
if (!form || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
let count = 0;
|
||||
document
|
||||
.querySelectorAll('input[name="trash_files[]"]:checked[data-restorable="1"]')
|
||||
.querySelectorAll(
|
||||
'input[name="trash_files[]"]:checked[data-restorable="1"]',
|
||||
)
|
||||
.forEach((cb) => {
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "hidden";
|
||||
@@ -83,17 +81,14 @@
|
||||
|
||||
if (count === 0) return;
|
||||
|
||||
if (!confirm(`Restaurer ${count} fichier(s) vers leur(s) TFE associé(s) ?`)) return;
|
||||
if (!confirm(`Restaurer ${count} fichier(s) vers leur(s) TFE associé(s) ?`))
|
||||
return;
|
||||
|
||||
htmx.trigger(form, "submit");
|
||||
}
|
||||
|
||||
function reattachListeners() {
|
||||
document
|
||||
.querySelectorAll(
|
||||
'input[name="filepond_dirs[]"], input[name="trash_files[]"]',
|
||||
)
|
||||
.forEach((cb) => {
|
||||
document.querySelectorAll('input[name="trash_files[]"]').forEach((cb) => {
|
||||
cb.addEventListener("change", cleanupUpdateBulk);
|
||||
});
|
||||
cleanupUpdateBulk();
|
||||
|
||||
@@ -7,63 +7,92 @@
|
||||
* section[aria-labelledby] headings inside #main-content.
|
||||
* Hides the TOC if fewer than 2 sections exist.
|
||||
*
|
||||
* Guarded: only runs once.
|
||||
* Rebuilds whenever HTMX swaps content so sections that are loaded
|
||||
* asynchronously (e.g. cleanup page fragments) appear in the TOC.
|
||||
*/
|
||||
(() => {
|
||||
if (window.__adminTocBuilt) return;
|
||||
window.__adminTocBuilt = true;
|
||||
let observer = null;
|
||||
let items = [];
|
||||
|
||||
// Generate a spread of thresholds for smooth IntersectionObserver firing.
|
||||
function buildThresholds() {
|
||||
const t = [];
|
||||
for (let i = 0; i <= 20; i++) t.push(i / 20);
|
||||
return t;
|
||||
}
|
||||
|
||||
function tearDown() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
items = [];
|
||||
}
|
||||
|
||||
function build() {
|
||||
var main = document.getElementById("main-content");
|
||||
var toc = document.getElementById("admin-toc");
|
||||
var list = document.getElementById("admin-toc-list");
|
||||
const main = document.getElementById("main-content");
|
||||
const toc = document.getElementById("admin-toc");
|
||||
const list = document.getElementById("admin-toc-list");
|
||||
if (!main || !toc || !list) return;
|
||||
|
||||
// Guard against double population
|
||||
if (list.children.length > 0) return;
|
||||
tearDown();
|
||||
|
||||
var sections = main.querySelectorAll("section[aria-labelledby]");
|
||||
// Repopulate from scratch.
|
||||
list.textContent = "";
|
||||
|
||||
const sections = main.querySelectorAll("section[aria-labelledby]");
|
||||
if (sections.length < 2) {
|
||||
toc.hidden = true;
|
||||
return;
|
||||
}
|
||||
toc.hidden = false;
|
||||
|
||||
var items = [];
|
||||
sections.forEach((sec) => {
|
||||
var headingId = sec.getAttribute("aria-labelledby");
|
||||
var heading = document.getElementById(headingId);
|
||||
const headingId = sec.getAttribute("aria-labelledby");
|
||||
const heading = document.getElementById(headingId);
|
||||
if (!heading) return;
|
||||
if (!sec.id) sec.id = headingId;
|
||||
|
||||
var a = document.createElement("a");
|
||||
a.href = `#${sec.id}`;
|
||||
a.textContent = heading.textContent.trim();
|
||||
// Anchor the link to the heading id. Do NOT assign the heading id to
|
||||
// the <section> itself — that would create a duplicate id on rebuild
|
||||
// (getElementById would then return the section, whose textContent
|
||||
// drags in the whole table).
|
||||
const anchorId = headingId;
|
||||
|
||||
var li = document.createElement("li");
|
||||
// Label excludes .n-meta counters (e.g. “Corbeille 5 fichiers · 2.1 GB”
|
||||
// should read just “Corbeille” in the TOC).
|
||||
let label;
|
||||
if (heading.querySelector?.(".n-meta")) {
|
||||
const clone = heading.cloneNode(true);
|
||||
clone.querySelectorAll(".n-meta").forEach((n) => {
|
||||
n.remove();
|
||||
});
|
||||
label = clone.textContent.trim();
|
||||
} else {
|
||||
label = heading.textContent.trim();
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = `#${anchorId}`;
|
||||
a.textContent = label;
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
items.push({ section: sec, link: a });
|
||||
});
|
||||
|
||||
// Generate a spread of thresholds for smooth IntersectionObserver firing.
|
||||
function buildThresholds() {
|
||||
var t = [];
|
||||
for (var i = 0; i <= 20; i++) t.push(i / 20);
|
||||
return t;
|
||||
}
|
||||
|
||||
var observer = new IntersectionObserver(
|
||||
observer = new IntersectionObserver(
|
||||
() => {
|
||||
// Active section = whose top is closest to the midpoint of the
|
||||
// viewport from above. At page bottom, the last section wins
|
||||
// even if its heading can't reach 50%.
|
||||
var line = window.innerHeight * 0.5;
|
||||
var best = null;
|
||||
var bestDist = Infinity;
|
||||
const line = window.innerHeight * 0.5;
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
// Pass 1: sections whose top is above/at the line
|
||||
items.forEach((item) => {
|
||||
var top = item.section.getBoundingClientRect().top;
|
||||
const top = item.section.getBoundingClientRect().top;
|
||||
if (top <= line && line - top < bestDist) {
|
||||
bestDist = line - top;
|
||||
best = item;
|
||||
@@ -73,8 +102,8 @@
|
||||
// Pass 2: no section above the line — pick closest from below
|
||||
if (!best) {
|
||||
items.forEach((item) => {
|
||||
var top = item.section.getBoundingClientRect().top;
|
||||
var dist = top - line;
|
||||
const top = item.section.getBoundingClientRect().top;
|
||||
const dist = top - line;
|
||||
if (dist >= 0 && dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = item;
|
||||
@@ -85,10 +114,10 @@
|
||||
// Pass 3: at page bottom, if the last section is visible but
|
||||
// its heading never crossed 50%, promote it anyway.
|
||||
if (best) {
|
||||
var lastItem = items[items.length - 1];
|
||||
const lastItem = items[items.length - 1];
|
||||
if (lastItem !== best) {
|
||||
var lastTop = lastItem.section.getBoundingClientRect().top;
|
||||
var atBottom =
|
||||
const lastTop = lastItem.section.getBoundingClientRect().top;
|
||||
const atBottom =
|
||||
window.innerHeight + window.scrollY + 5 >=
|
||||
document.documentElement.scrollHeight;
|
||||
if (atBottom && lastTop > line && lastTop < window.innerHeight) {
|
||||
@@ -110,9 +139,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (document.getElementById("admin-toc")) build();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", build);
|
||||
document.addEventListener("DOMContentLoaded", run);
|
||||
} else {
|
||||
build();
|
||||
run();
|
||||
}
|
||||
|
||||
// HTMX loads many admin sections asynchronously (cleanup fragments, tables).
|
||||
// Rebuild the TOC whenever the content around it changes.
|
||||
if (window.htmx) {
|
||||
document.body.addEventListener("htmx:afterSwap", (e) => {
|
||||
if (e.target?.closest?.("#main-content")) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSettle", (e) => {
|
||||
if (e.target?.closest?.("#main-content")) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<main id="main-content">
|
||||
<main id="main-content" class="admin-main--toc">
|
||||
<?php include APP_ROOT . '/templates/admin/partials/admin-toc.php'; ?>
|
||||
|
||||
<article style="padding-top:var(--space-m)">
|
||||
<h1 style="margin:0 0 var(--space-xl)">
|
||||
<a href="/admin/" class="admin-back-btn" title="Retour à la liste"><?= icon('arrow-left-circle') ?></a>
|
||||
Nettoyer les fichiers temporaires
|
||||
Nettoyage
|
||||
</h1>
|
||||
|
||||
<section aria-labelledby="cleanup-tmp-title">
|
||||
<h2 id="cleanup-tmp-title">Fichiers temporaires</h2>
|
||||
<div id="tmp-cleanup-stats-wrapper"
|
||||
hx-get="/admin/actions/cleanup-stats-fragment.php"
|
||||
hx-trigger="load"
|
||||
@@ -14,7 +14,6 @@
|
||||
hx-indicator="#tmp-cleanup-stats-wrapper">
|
||||
<p class="admin-muted">Chargement…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="cleanup-peertube-title">
|
||||
<h2 id="cleanup-peertube-title">Vidéos PeerTube</h2>
|
||||
|
||||
@@ -29,11 +29,11 @@ $sortArrow = function(string $col) use ($sortCol, $sortDir): string {
|
||||
<div id="bulk-actions" class="admin-bulk-actions" style="display:none">
|
||||
<strong><span id="selected-count">0</span> TFE(s) sélectionné(s)</strong>
|
||||
<div class="admin-bulk-btns">
|
||||
<button type="button" class="btn btn--sm btn--green admin-btn-publish" onclick="confirmBulk('publish')">Publier</button>
|
||||
<button type="button" class="btn btn--sm btn--muted admin-btn-unpublish" onclick="confirmBulk('unpublish')">Dépublier</button>
|
||||
<button type="button" class="btn btn--sm" onclick="confirmExport()">Exporter CSV</button>
|
||||
<button type="button" class="btn btn--sm" onclick="confirmExportFiles()">Exporter fichiers</button>
|
||||
<button type="button" class="btn btn--sm btn--red admin-btn-delete" onclick="confirmBulk('delete')">Supprimer</button>
|
||||
<button type="button" class="btn btn--sm btn--green admin-btn-publish" data-tip="Publier les TFE sélectionnés" onclick="confirmBulk('publish')">Publier</button>
|
||||
<button type="button" class="btn btn--sm btn--muted admin-btn-unpublish" data-tip="Retirer de la publication les TFE sélectionnés" onclick="confirmBulk('unpublish')">Dépublier</button>
|
||||
<button type="button" class="btn btn--sm" data-tip="Exporter en CSV les TFE sélectionnés" onclick="confirmExport()">Exporter CSV</button>
|
||||
<button type="button" class="btn btn--sm" data-tip="Exporter en un fichier archivé les TFE sélectionnés" onclick="confirmExportFiles()">Exporter fichiers</button>
|
||||
<button type="button" class="btn btn--sm btn--red admin-btn-delete" data-tip="Déplacer les TFE sélectionnés vers la corbeille" onclick="confirmBulk('delete')">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,20 +55,21 @@
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
|
||||
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset">✕ Réinitialiser</a>
|
||||
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset" data-tip="Effacer tous les filtres de recherche">✕ Réinitialiser</a>
|
||||
<?php endif; ?>
|
||||
<div class="admin-btn-group">
|
||||
<a href="/admin/add.php" class="btn btn--primary btn--sm"><?= icon('plus-circle') ?> Ajouter un TFE</a>
|
||||
<a href="/admin/add.php" class="btn btn--primary btn--sm" data-tip="Créer une nouvelle fiche de TFE"><?= icon('plus-circle') ?> Ajouter un TFE</a>
|
||||
<button type="button" class="btn btn--primary btn--sm" id="import-dialog-btn"
|
||||
data-tip="Importer des TFE depuis un fichier CSV"
|
||||
onclick="document.getElementById('import-dialog').showModal(); window.XamxamInitFilePonds()">
|
||||
<?= icon('tray-arrow-up') ?> Importer
|
||||
</button>
|
||||
<?php if ($trashCount > 0): ?>
|
||||
<a href="/admin/index.php?tab=trash" class="btn btn--sm <?= $tab === 'trash' ? 'btn--primary' : 'btn--secondary' ?>">
|
||||
<a href="/admin/index.php?tab=trash" class="btn btn--sm <?= $tab === 'trash' ? 'btn--primary' : 'btn--secondary' ?>" data-tip="Afficher les TFE supprimés (<?= $trashCount ?>)">
|
||||
Corbeille (<?= $trashCount ?>)
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="/admin/cleanup.php" class="btn btn--sm btn--secondary">
|
||||
<a href="/admin/cleanup.php" class="btn btn--sm btn--secondary" data-tip="Supprimer définitivement les fichiers orphelins (<?= $tmpTotalCount ?>)">
|
||||
<?= icon('trash') ?> Nettoyer<?= $tmpTotalCount > 0 ? " ($tmpTotalCount)" : '' ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# XAMXAM — abandoned upload garbage-collection cron job
|
||||
# Installed to /etc/cron.d/xamxam-tmp-cleanup (system cron format: minute hour dom month dow user command)
|
||||
#
|
||||
# Deletes staging upload dirs in <storage>/tmp/filepond/ that are abandoned:
|
||||
# - whose referencing PHP session no longer exists, or
|
||||
# - older than the 2h safety fallback.
|
||||
# These are never linked to a published TFE, so removing them is risk-free GC.
|
||||
# Script is a dry-run unless --no-dry-run is passed.
|
||||
# Age threshold overridable via TMP_UPLOAD_MAX_AGE_SECONDS (default 7200).
|
||||
5 * * * * www-data php /tmp/cleanup-tmp-uploads.php --no-dry-run >> /var/log/xamxam-tmp-cleanup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||
+21
-6
@@ -32,8 +32,8 @@ This chains (each step is also runnable individually):
|
||||
`/var/www/xamxam/.env` (idempotent; see below).
|
||||
2. `just deploy` — code + Composer deps + migrations + permissions.
|
||||
3. `just deploy-nginx` — install + apply the nginx config and fix permissions.
|
||||
4. `just deploy-backup`, `just deploy-cleanup-cron`, `just deploy-logrotate` —
|
||||
install backup + cleanup cron jobs and log rotation.
|
||||
4. `just deploy-backup`, `just deploy-cleanup-cron`, `just deploy-tmp-cleanup-cron`, `just deploy-logrotate` —
|
||||
install backup + cleanup (drafts + abandoned uploads) cron jobs and log rotation.
|
||||
|
||||
It finishes by telling you what's left to do in `/admin/account` (set the admin
|
||||
password — a fresh DB starts unauthenticated — and configure SMTP/PeerTube
|
||||
@@ -50,7 +50,7 @@ For a full initial rollout *without* the env/nginx steps (backup + cleanup cron
|
||||
only), when those are already handled:
|
||||
|
||||
```bash
|
||||
just deploy-all-first # deploy + deploy-backup + deploy-cleanup-cron + deploy-logrotate
|
||||
just deploy-all-first # deploy + deploy-backup + deploy-cleanup-cron + deploy-tmp-cleanup-cron + deploy-logrotate
|
||||
```
|
||||
|
||||
### Server `APP_KEY` — idempotent, never overwrites
|
||||
@@ -89,8 +89,9 @@ If you ever rotate `APP_KEY`, re-encrypt the SMTP password with
|
||||
| `just deploy-db` | Push local `xamxam.db` → remote (**refuses** if a remote DB already exists) |
|
||||
| `just deploy-verify-permissions` | Check ownership / permissions on the server |
|
||||
|
||||
> ℹ️ **First deploy?** After `just deploy`, run `just deploy-backup` and
|
||||
> `just deploy-cleanup-cron` to install the backup/cleanup cron jobs, and
|
||||
> ℹ️ **First deploy?** After `just deploy`, run `just deploy-backup`,
|
||||
> `just deploy-cleanup-cron` and `just deploy-tmp-cleanup-cron` to install the
|
||||
> backup/cleanup cron jobs, and
|
||||
> `just deploy-logrotate` to install log rotation. A single `just deploy-all-first`
|
||||
> chains all of these together.
|
||||
> The app log directory `/var/log/xamxam/` is provisioned automatically by
|
||||
@@ -136,6 +137,18 @@ Draft cleanup is handled by a separate cron (`/etc/cron.d/xamxam-cleanup`),
|
||||
installed via `just deploy-cleanup-cron`, logging to
|
||||
`/var/log/xamxam-cleanup-YYYY-MM-DD.log`. Verify with `just deploy-check-cleanup-log`.
|
||||
|
||||
Abandoned FilePond uploads (staging dirs never linked to a published TFE) are
|
||||
garbage-collected hourly by `/etc/cron.d/xamxam-tmp-cleanup`, installed via
|
||||
`just deploy-tmp-cleanup-cron`, logging to
|
||||
`/var/log/xamxam-tmp-cleanup-YYYY-MM-DD.log`. Verify with
|
||||
`just deploy-check-tmp-cleanup-log`.
|
||||
|
||||
Because abandoned uploads are removed automatically, the admin cleanup page
|
||||
(`/admin/cleanup.php`, titled *Nettoyage*) now only surfaces the manually-managed
|
||||
**Corbeille** (trashed files, with per-row restore/delete and bulk actions). The
|
||||
manual "Téléversements abandonnés" table was removed from the UI; those files
|
||||
are purged by the cron above.
|
||||
|
||||
---
|
||||
|
||||
## Permissions model
|
||||
@@ -226,7 +239,8 @@ just deploy-verify-permissions # expect "All permissions OK"
|
||||
unchanged; if that still fails, force a reinstall.
|
||||
- **Backup/schedule not running**
|
||||
Confirm the cron files are installed (`/etc/cron.d/xamxam-backup`,
|
||||
`/etc/cron.d/xamxam-cleanup`) and the log files are writable by `www-data`.
|
||||
`/etc/cron.d/xamxam-cleanup`, `/etc/cron.d/xamxam-tmp-cleanup`) and the log
|
||||
files are writable by `www-data`.
|
||||
|
||||
---
|
||||
|
||||
@@ -238,6 +252,7 @@ just deploy-verify-permissions # expect "All permissions OK"
|
||||
| `just deploy-nginx` | Apply nginx config |
|
||||
| `just deploy-backup` | Install backup script + cron |
|
||||
| `just deploy-cleanup-cron` | Install orphaned-draft cleanup cron |
|
||||
| `just deploy-tmp-cleanup-cron` | Install abandoned-upload GC cron |
|
||||
| `just deploy-list-backups` | List server backups |
|
||||
| `just trigger-backup` | Run backup now |
|
||||
| `just reencrypt-password <key>` | Re-encrypt SMTP password after key rotation |
|
||||
|
||||
@@ -185,6 +185,8 @@ just backup # SQL dump into app/storage/backup_<timestamp>.sql
|
||||
just backup-snapshot # WAL-safe hot backup + gzip into storage/backups/
|
||||
just cleanup-drafts [--no-dry-run] # remove orphaned drafts > 7 days (default)
|
||||
# OLDER_THAN_HOURS=24 to change the threshold
|
||||
just cleanup-tmp-uploads [--no-dry-run] # GC abandoned FilePond uploads (>2h or missing session)
|
||||
# TMP_UPLOAD_MAX_AGE_SECONDS to change the threshold
|
||||
```
|
||||
|
||||
See [database.md](database.md) for the full schema reference.
|
||||
|
||||
@@ -452,6 +452,26 @@ deploy-cleanup-cron:
|
||||
deploy-check-cleanup-log:
|
||||
ssh xamxam "tail -20 /var/log/xamxam-cleanup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'"
|
||||
|
||||
deploy-tmp-cleanup-cron:
|
||||
# Install cron job for abandoned-upload garbage collection (hourly, 2h threshold).
|
||||
# Creates /etc/cron.d/xamxam-tmp-cleanup and log file on the server.
|
||||
@echo "📋 Installing abandoned-upload cleanup cron job…"
|
||||
rsync -v scripts/cleanup-tmp-uploads.php xamxam:/tmp/cleanup-tmp-uploads.php
|
||||
ssh xamxam "chmod 755 /tmp/cleanup-tmp-uploads.php"
|
||||
rsync -v deploy/xamxam-tmp-cleanup.cron xamxam:/tmp/xamxam-tmp-cleanup.cron
|
||||
ssh -t xamxam "sudo install -o root -g root -m 644 /tmp/xamxam-tmp-cleanup.cron /etc/cron.d/xamxam-tmp-cleanup && rm -f /tmp/xamxam-tmp-cleanup.cron"
|
||||
ssh -t xamxam "sudo touch /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log && sudo chown www-data:www-data /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log && sudo chmod 644 /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log"
|
||||
@echo "✅ Abandoned-upload cleanup cron installed."
|
||||
@echo " Cron file: /etc/cron.d/xamxam-tmp-cleanup"
|
||||
@echo " Script: /tmp/cleanup-tmp-uploads.php"
|
||||
@echo " Log file: /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log"
|
||||
@echo ""
|
||||
@echo "Verify with: just deploy-check-tmp-cleanup-log"
|
||||
|
||||
[group('deploy')]
|
||||
deploy-check-tmp-cleanup-log:
|
||||
ssh xamxam "tail -20 /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'"
|
||||
|
||||
[group('deploy')]
|
||||
deploy-logrotate:
|
||||
# Install /etc/logrotate.d/xamxam for app + nginx + cron logs.
|
||||
@@ -493,7 +513,7 @@ deploy-migrate-storage dry_run='' target_host='xamxam':
|
||||
ssh {{target_host}} 'rm -f /var/www/xamxam/migrate-storage-paths.php'
|
||||
|
||||
[group('deploy')]
|
||||
deploy-all-first: deploy deploy-backup deploy-cleanup-cron deploy-logrotate
|
||||
deploy-all-first: deploy deploy-backup deploy-cleanup-cron deploy-tmp-cleanup-cron deploy-logrotate
|
||||
# One-shot: full initial deploy including backup and cleanup cron jobs.
|
||||
|
||||
# One-shot remote provisioning for a fresh xamxam server.
|
||||
@@ -638,3 +658,10 @@ cleanup-drafts dry_run='':
|
||||
# just cleanup-drafts --no-dry-run
|
||||
# OLDER_THAN_HOURS=24 just cleanup-drafts
|
||||
@php scripts/cleanup-drafts.php {{dry_run}}
|
||||
|
||||
cleanup-tmp-uploads dry_run='':
|
||||
# List (dry-run) or delete abandoned FilePond uploads (>2h or missing session).
|
||||
# Pass --no-dry-run to actually delete. Override the age with
|
||||
# TMP_UPLOAD_MAX_AGE_SECONDS:
|
||||
# just cleanup-tmp-uploads --no-dry-run
|
||||
@php scripts/cleanup-tmp-uploads.php {{dry_run}}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cleanup-tmp-uploads.php — Garbage-collect abandoned FilePond uploads.
|
||||
*
|
||||
* Abandoned uploads live in <storage>/tmp/filepond/ (or a private per-user
|
||||
* subfolder when the uploader uses a named uploader directory). A finished,
|
||||
* valid upload is moved out of this staging area as soon as its thesis is
|
||||
* saved, so anything still sitting there is either:
|
||||
* - an upload whose PHP session no longer exists (importer/editor abandoned
|
||||
* mid-file, browser closed, form abandoned), or
|
||||
* - any leftover older than the 2h safety fallback.
|
||||
*
|
||||
* These staging dirs are never referenced by any published TFE, so removing
|
||||
* them is risk-free garbage collection — much like clearing a recycle bin.
|
||||
*
|
||||
* The eligibility logic mirrors the admin "cleanup" page so behaviour is
|
||||
* identical:
|
||||
* - Strategy 1: a manifest.json that references a now-missing PHP session.
|
||||
* - Strategy 2: dir older than 2 hours (time-based fallback).
|
||||
*
|
||||
* Usage (mirrors cleanup-drafts.php):
|
||||
* php /tmp/cleanup-tmp-uploads.php # dry-run (list candidates)
|
||||
* php /tmp/cleanup-tmp-uploads.php --no-dry-run # actually delete
|
||||
*
|
||||
* The 2h fallback threshold is overridable via TMP_UPLOAD_MAX_AGE_SECONDS.
|
||||
*
|
||||
* Exit codes: 0 on success, 1 on error.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Resolve APP_ROOT robustly. In production the app code is deployed flat under
|
||||
// /var/www/xamxam/ (src/, storage/, templates/ at the root, no app/ subdir),
|
||||
// and this script may itself live in /tmp. Point at /var/www/xamxam in
|
||||
// non-CLI-SAPI contexts; local dev (cli-server) keeps the app/ subdir layout.
|
||||
$prodRoot = '/var/www/xamxam';
|
||||
if (is_dir($prodRoot . '/src') && is_file($prodRoot . '/src/Database.php')) {
|
||||
define('APP_ROOT', $prodRoot);
|
||||
} else {
|
||||
define('APP_ROOT', dirname(__DIR__) . '/app');
|
||||
}
|
||||
|
||||
// No app classes needed: abandoned-upload GC is purely filesystem-based,
|
||||
// mirroring the filepond section of the admin cleanup endpoint.
|
||||
$dryRun = !in_array('--no-dry-run', $argv, true);
|
||||
|
||||
// Storage root: APP_ROOT/storage in both dev (app/storage) and prod
|
||||
// (/var/www/xamxam/storage), mirroring bootstrap.php's STORAGE_ROOT.
|
||||
$storageRoot = APP_ROOT . '/storage';
|
||||
$filepondDir = $storageRoot . '/tmp/filepond';
|
||||
|
||||
// Stability threshold (mirrors cleanup-tmp.php): 2 hours by default.
|
||||
$maxAgeSeconds = (int) (getenv('TMP_UPLOAD_MAX_AGE_SECONDS') ?: 7200);
|
||||
if ($maxAgeSeconds < 60) {
|
||||
$maxAgeSeconds = 7200;
|
||||
}
|
||||
|
||||
// PHP session save path — the web process and cron (both www-data) share it.
|
||||
$sessionSavePath = session_save_path();
|
||||
if (!$sessionSavePath || $sessionSavePath === '') {
|
||||
$sessionSavePath = sys_get_temp_dir();
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$removed = 0;
|
||||
$details = [];
|
||||
|
||||
if (!is_dir($filepondDir)) {
|
||||
exit(0); // nothing to collect — quiet exit
|
||||
}
|
||||
|
||||
$items = @scandir($filepondDir);
|
||||
if ($items === false) {
|
||||
error_log('[cleanup-tmp-uploads] Unable to read ' . $filepondDir);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..' || $item === '.gitkeep') {
|
||||
continue;
|
||||
}
|
||||
$dirPath = $filepondDir . '/' . $item;
|
||||
if (!is_dir($dirPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$shouldDelete = false;
|
||||
$reason = '';
|
||||
|
||||
$manifestPath = $dirPath . '/manifest.json';
|
||||
$ageSeconds = $now - filemtime($dirPath);
|
||||
|
||||
// Strategy 1: session-based (preferred) — the uploader's PHP session is gone.
|
||||
if (file_exists($manifestPath)) {
|
||||
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
||||
if (is_array($manifest) && !empty($manifest['session_id'])) {
|
||||
$sessionFile = $sessionSavePath . '/sess_' . $manifest['session_id'];
|
||||
if (!file_exists($sessionFile)) {
|
||||
$shouldDelete = true;
|
||||
$reason = 'session expirée (' . $manifest['session_id'] . ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: time-based fallback (no manifest, or session still alive but old).
|
||||
if (!$shouldDelete && $ageSeconds > $maxAgeSeconds) {
|
||||
$shouldDelete = true;
|
||||
$reason = 'plus de ' . intdiv($maxAgeSeconds, 3600) . 'h';
|
||||
}
|
||||
|
||||
if (!$shouldDelete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Candidate found — report it in dry-run, delete it otherwise.
|
||||
if ($dryRun) {
|
||||
printf("DRY-RUN → %s (%s)\n", $item, $reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
rmdirRecursive($dirPath);
|
||||
$details[] = "filepond/$item: $reason";
|
||||
$removed++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
exit(0); // nothing deleted — candidates already listed above
|
||||
}
|
||||
|
||||
if ($removed > 0) {
|
||||
foreach ($details as $line) {
|
||||
echo "Deleted {$line}\n";
|
||||
}
|
||||
printf("Garbage-collected %d abandoned upload(s).\n", $removed);
|
||||
}
|
||||
// Nothing to collect — quiet exit.
|
||||
exit(0);
|
||||
|
||||
function rmdirRecursive(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$entries = @scandir($dir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $entry;
|
||||
if (is_dir($path)) {
|
||||
rmdirRecursive($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
Reference in New Issue
Block a user