diff --git a/.gitignore b/.gitignore index 529f3e2..6466f45 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ app/storage/theses/* !app/storage/theses/.gitkeep app/storage/banners/* !app/storage/banners/.gitkeep +app/storage/tmp/* +!app/storage/tmp/.gitkeep # Nix .direnv/ diff --git a/TODO.md b/TODO.md index d078b07..1694935 100644 --- a/TODO.md +++ b/TODO.md @@ -1,123 +1,9 @@ -# TODO +# FilePond Server-ID Refactor -## Move Restrictions d'accès aux fichiers to acces.php - -- [x] Remove fieldset from templates/admin/contenus.php -- [x] Add fieldset to templates/admin/acces.php -- [x] Load $siteSettings in admin/acces.php controller -- [x] Update redirect in settings.php for formulaire_restrictions → /admin/acces.php - -## Fix PeerTube upload — Google-resumable protocol adherence - -- [x] Use Location header from init response (not reconstruct URL from JSON body) -- [x] Switch chunk method from PUT → PATCH (Google-resumable variant) -- [x] Use actual file MIME type in chunk Content-Type (not application/octet-stream) -- [x] Ensure chunk size is multiple of 256 KB -- [x] Add PATCH/HEAD methods to httpRequest() -- [x] Add CURLOPT_HEADERFUNCTION to capture response headers -- [x] Disable CURLOPT_FOLLOWLOCATION to preserve Location header -- [x] Add cancelUpload() helper for Delete-on-error cleanup -- [x] PeerTube upload fixed — simple multipart POST /api/v1/videos/upload works -- [x] Upload progress: 0-25% browser upload, 25-99% server polling via /admin/actions/upload-progress.php -- [x] Decorelate formats from fichiers: no HTMX toggling; Site web/Vidéo/Audio always visible -- [x] Sticky formats fieldset inside parent container -- [x] Server-side progress: PeerTubeService writes to temp file, client polls progress endpoint -- [x] Fix cover deletion bug: !empty() instead of isset() -- [x] Remove old duplicate file list CSS; unified recap+edit file figure styling -- [x] Standardise uploaded/preexisting files styling: recap now matches edit (classes, icons, meta row, display_label) -- [x] Refactor licence fieldset: Libre→CC2r+licence, Interne→opt-in licence, Interdit→none - -## HTMX Toast Feedback for Settings Checkboxes (contenus.php) - -- [x] Add `hx-target` response divs to the three fieldsets in contenus.php -- [x] Update settings.php to return HTML toast on HTMX requests - -## JS Refactoring — Extract inline scripts into app/ files - -- [x] Remove overtype-webcomponent.min.js (unused) -- [x] Extract copyLogContent + fallbackCopy + tab-updater → app/admin-logs.js -- [x] Extract copyUrl → app/clipboard.js -- [x] Extract tag-search inline script → app/pill-search.js (generalized for tag + language) -- [x] Extract tfe.php access-request form → app/access-request.js -- [x] Update all templates to use new external JS files - -## Production Error Fixes (2026-05-11 remote logs) - -- [x] **413 Request Entity Too Large** — bumped `client_max_body_size` to 256M, PHP post/upload to 256M, timeouts to 300s -- [x] **Missing `v_smtp_active` view** on server — made all `CREATE VIEW` statements idempotent with `IF NOT EXISTS` in schema.sql -- [x] **`bars.svg` 404** — created `app/public/assets/img/bars.svg` (animated SVG spinner) -- [x] **Nginx rate limiting too aggressive** — increased admin zone to 300r/m, burst=30 to handle ~11 concurrent HTMX fragment requests on contenus.php page load -- [x] **Migration idempotency** — `CREATE INDEX` / `CREATE TRIGGER` / `CREATE VIEW` now use `IF NOT EXISTS` in schema.sql and generate-schema.py; migrate.sh no longer fails on re-run -- [ ] **Database readonly** — intermittent permission issue after deploy (added deploy-nginx recipe; permissions should be fixed by --chown + deploy-server.sh) -- [x] **Upload progress bar not visible** — `collectFileNames()` now also checks FilePond instances directly (not just `input.files`); `upload-progress.php` no longer requires admin auth (blocked partage form polling) -- [x] **Validation error messages hidden by generic fallback** — `ErrorHandler::userMessage` doesn't handle plain `Exception`, so all `throw new Exception(...)` validation messages fall through to "Une erreur inattendue…". Fix: add `Exception` pass-through before the generic fallback. -- [x] **Validation messages use wrong field names** — "Nom/Prénom/Pseudo" → "Auteur·ice(s)", "Titre du mémoire" → "Titre du TFE" to match form labels. Updated autofocusFieldForError in both controllers. - -## PeerTube Alternate Labels & FilePond Pools - -- [x] Add `peertube_video_label` and `peertube_audio_label` columns (migration 029) -- [x] Update PeerTubeService getSettings/updateSettings for new fields -- [x] Add label fields to parametres.php admin form -- [x] Handle label saving in admin/actions/settings.php -- [x] Uncomment video/audio slots in fichiers-fragment.php with FilePond pools when PeerTube enabled -- [x] Register `peertube_video` / `peertube_audio` queue types in file-upload-filepond.js -- [x] Update handlePeerTubeUpload → handlePeerTubeQueueFiles in both create/edit controllers -- [x] When PeerTube active, restrict TFE pool to PDF/images/VTT/archives only (no video/audio) -- [x] Add HTMX swap attributes to Vidéo/Audio format checkboxes for live toggling -- [x] Store PeerTube uploads as `peertube_ids:{uuid}` in thesis_files.file_path -- [x] Create `templates/partials/peertube-embed.php` iframe embed template -- [x] Render PeerTube embeds in public thesis view (tfe.php) -- [x] Handle PeerTube files in admin recapitulatif.php and fichiers-fragment.php -- [x] Shared SMTP credentials — remove username/password from peertube_settings (migration 031) -- [x] PeerTubeService reads credentials from SmtpRelay -- [x] OAuth client_id/secret fetched on-demand and cached in-memory (no DB storage) -- [x] Resumable upload protocol (POST init + PUT chunks) in PeerTubeService::upload() -- [x] Admin recapitulatif: show real PeerTube watch links (public/unlisted only) -- [x] Optimize public thesis view: load PeerTube instance URL once before file loop -- [ ] Test end-to-end: activate PeerTube, set labels, submit form with video/audio files - -## SQLite Backup & Data Integrity (docs/backup-plan.md) - -### Phase 1 — WAL Mode -- [x] WAL mode already active (`PRAGMA journal_mode` → `wal`) — set in Database constructor -- [ ] Verify `-wal` and `-shm` sidecar files exist after writes -- [ ] Verify nginx/PHP write access to sidecar files on server -- [x] Add deploy-verify-permissions recipe that checks ownership, directory perms, file perms, and writability after rsync -- [x] deploy recipe now uploads and runs deploy-server.sh to fix permissions, then verifies them -- [x] deploy recipe now runs migrations (scripts/migrate.sh) after ensuring DB exists -- [x] fix migrate.sh to detect server vs local layout (no app/ subdir on server) -- [x] regenerate schema.sql from local DB via generate-schema.py (includes v_smtp_active, all 28 migrations) -- [x] fix generate-schema.py to include v_smtp_active (was explicitly excluded) - -### Phase 2 — Audit Log -- [x] `admin_audit_log` table already exists (migration 009), `AdminLogger` already writes to it -- [x] Create the `audit_log` table for data-level audit (before/after row snapshots) -- [x] Create `Audit.php` helper class -- [x] Instrument all DELETE, UPDATE, INSERT operations on core tables (theses, tags, languages, thesis_files) -- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5` - -### Phase 3 — Soft Deletes -- [x] Add `deleted_at` columns to `languages`, `tags`, `theses` -- [x] Rebuild views `v_theses_full` and `v_theses_public` with `deleted_at IS NULL` filters -- [x] Update `schema.sql` for fresh installs -- [x] Replace all hard DELETEs with soft deletes (`DELETE` → `UPDATE ... SET deleted_at = ...`) -- [x] Add `deleted_at IS NULL` to all SELECT queries touching these tables -- [x] Add admin "Corbeille" view for soft-deleted theses with Restore and Hard Delete actions -- [ ] Test each htmx-driven element (language search, tag search, repertoire filters) to confirm deleted entries don't appear -- [ ] Admin: add soft-deleted tags/languages view with restore option - -### Phase 4 — Hourly Snapshots via Cronjob -- [x] Create `scripts/backup-sqlite.sh` (hot backup via `sqlite3 .backup`, gzip, retention pruning) -- [x] Test locally — backup created, restores correctly -- [x] Add `just backup-snapshot` command for local ad-hoc backups -- [x] Deploy backup script to server (`/usr/local/bin/backup-sqlite.sh`) — `just deploy-backup-script` -- [x] Create `/var/backups/xamxam/` directory on server — part of `just deploy-backup-cron` -- [x] Add cron jobs (hourly 30d + daily 90d) — `just deploy-backup-cron` -- [x] Test restore from production backup — `just test-restore ` -- [x] Manual backup trigger — `just trigger-backup` -- [x] Check backup log — `just deploy-check-backup-log` -- [x] List remote backups — `just deploy-list-backups` -- [x] One-shot deploy — `just deploy-backup` (script + cron) - -### Phase 5 — Remote Sync *(for later)* -- [ ] (Deferred) +- [x] Step 1 — Build 4 PHP endpoints (process.php, revert.php, load.php, remove.php) +- [x] Step 2 — Update ThesisFileHandler to accept file_ids instead of $_FILES +- [x] Step 3 — Update file-upload-filepond.js (async server model + all fixes) +- [x] Step 4 — Update templates (data-queue-type on all inputs, data-existing-files in edit) +- [x] Step 5 — Update upload-progress.js (new collectFileNames, pending-uploads guard) +- [ ] Step 6 — QA / integration testing +- [ ] Step 7 — Cleanup: remove transition flags, remove INPUT_ID_TO_TYPE diff --git a/app/public/admin/actions/filepond/load.php b/app/public/admin/actions/filepond/load.php new file mode 100644 index 0000000..b8a695a --- /dev/null +++ b/app/public/admin/actions/filepond/load.php @@ -0,0 +1,76 @@ +getConnection(); +$stmt = $pdo->prepare('SELECT * FROM thesis_files WHERE id = ?'); +$stmt->execute([$dbId]); +$fileRow = $stmt->fetch(); + +if (!$fileRow) { + http_response_code(404); + die('Fichier introuvable.'); +} + +$filePath = $fileRow['file_path'] ?? ''; +$fileName = $fileRow['file_name'] ?? basename($filePath); +$mimeType = $fileRow['mime_type'] ?? 'application/octet-stream'; + +// ── Skip PeerTube and website entries (no actual file) ─────────────────── +if (str_starts_with($filePath, 'peertube_ids:')) { + http_response_code(404); + die('Fichier PeerTube — pas de flux direct.'); +} +if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) { + http_response_code(404); + die('URL — pas de flux direct.'); +} + +// ── Resolve absolute path ──────────────────────────────────────────────── +$absPath = STORAGE_ROOT . '/' . $filePath; + +if (!file_exists($absPath) || !is_readable($absPath)) { + http_response_code(404); + die('Fichier absent du disque.'); +} + +// ── Stream the file ────────────────────────────────────────────────────── +$fileSize = filesize($absPath); + +// Content-Disposition: inline so FilePond receives it as a valid file +header('Content-Type: ' . $mimeType); +header('Content-Length: ' . $fileSize); +header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"'); +header('Cache-Control: no-cache'); + +readfile($absPath); +exit; diff --git a/app/public/admin/actions/filepond/process.php b/app/public/admin/actions/filepond/process.php new file mode 100644 index 0000000..79a19d6 --- /dev/null +++ b/app/public/admin/actions/filepond/process.php @@ -0,0 +1,265 @@ + ['name' => 'img.png', 'tmp_name' => '/tmp/...', ...]] +// +// Multi-file queue inputs (queue_file[tfe][], queue_file[annexe][], etc.) arrive nested: +// $_FILES = ['queue_file' => ['name' => ['tfe' => ['file1.pdf']], 'tmp_name' => ['tfe' => ['/tmp/...']], ...]] +// +// We extract the first available file entry regardless of nesting depth. + +$upload = null; + +// Try flat structure first (single-file inputs) +foreach ($_FILES as $key => $info) { + if (is_array($info) && isset($info['tmp_name']) && is_string($info['tmp_name'])) { + $upload = $info; + break; + } +} + +// Try nested queue structure: $_FILES['queue_file']['tmp_name'][][0] +if ($upload === null && isset($_FILES['queue_file']['tmp_name'])) { + foreach ($_FILES['queue_file']['tmp_name'] as $subKey => $subValue) { + if (is_array($subValue) && isset($subValue[0]) && is_string($subValue[0])) { + $upload = [ + 'name' => $_FILES['queue_file']['name'][$subKey][0] ?? '', + 'tmp_name' => $_FILES['queue_file']['tmp_name'][$subKey][0] ?? '', + 'error' => $_FILES['queue_file']['error'][$subKey][0] ?? UPLOAD_ERR_NO_FILE, + 'size' => $_FILES['queue_file']['size'][$subKey][0] ?? 0, + ]; + break; + } + } +} + +if ($upload === null) { + error_log('[filepond:process] No usable file found. _FILES: ' . substr(json_encode($_FILES, JSON_PARTIAL_OUTPUT_ON_ERROR), 0, 500)); + http_response_code(400); + die('Aucun fichier reçu.'); +} + +$err = $upload['error'] ?? -1; +if ($err !== UPLOAD_ERR_OK) { + error_log('[filepond:process] Upload error ' . $err . ' for ' . ($upload['name'] ?? '?')); + http_response_code(400); + die('Erreur de téléversement (code ' . $err . ').'); +} +$queueType = trim($_POST['queue_type'] ?? ''); +error_log('[filepond:process] Received file | name=' . $upload['name'] . ' | size=' . $upload['size'] . ' | queue_type=' . $queueType); + +// ── MIME / extension whitelist (mirrored from ThesisFileHandler) ───────── +const ALLOWED_MIME_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', + 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', + 'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4', + 'text/vtt', + 'application/zip', 'application/x-zip-compressed', + 'application/x-tar', 'application/gzip', + 'application/octet-stream', +]; + +const ALLOWED_EXTENSIONS = [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', + 'pdf', + 'mp4', 'webm', 'ogv', 'mov', + 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a', + 'vtt', + 'zip', 'tar', 'gz', 'tgz', +]; + +// Per-queue-type constraints +const QUEUE_MIME_MAP = [ + 'cover' => ['image/jpeg', 'image/png', 'image/webp'], + 'note_intention' => ['application/pdf'], + 'tfe' => null, // full whitelist + 'video' => ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'], + 'audio' => ['audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/flac', 'audio/x-wav', 'audio/aac', 'audio/mp4'], + 'annexe' => ['application/pdf', 'application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/gzip', 'application/octet-stream'], + 'peertube_video' => ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'], + 'peertube_audio' => ['audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/flac', 'audio/x-wav', 'audio/aac', 'audio/mp4'], +]; + +const QUEUE_SIZE_LIMITS = [ + 'cover' => 20 * 1024 * 1024, // 20 MB + 'note_intention' => 100 * 1024 * 1024, // 100 MB + 'tfe' => 500 * 1024 * 1024, // 500 MB (per-file, but per-extension overrides below) + 'video' => 500 * 1024 * 1024, // 500 MB + 'audio' => 500 * 1024 * 1024, // 500 MB + 'annexe' => 500 * 1024 * 1024, // 500 MB + 'peertube_video' => 500 * 1024 * 1024, // 500 MB + 'peertube_audio' => 500 * 1024 * 1024, // 500 MB +]; + +// Per-extension overrides for TFE (from ThesisFileHandler constants) +const AV_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a']; +const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB +const MAX_AV_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB + +// ── MIME detection ─────────────────────────────────────────────────────── +$finfo = new finfo(FILEINFO_MIME_TYPE); +$mimeType = $finfo->file($upload['tmp_name']); +$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); +error_log('[filepond:process] MIME detected | mime=' . $mimeType . ' | ext=' . $ext); + +// Fix common mismatches +if ($mimeType === 'text/plain' && $ext === 'vtt') { + $mimeType = 'text/vtt'; +} +if ($mimeType === 'audio/mpeg' && $ext === 'mp3') { + $mimeType = 'audio/mp3'; +} + +// ── Validate MIME / extension ──────────────────────────────────────────── +$allowedMimes = QUEUE_MIME_MAP[$queueType] ?? null; +if ($allowedMimes !== null) { + if (!in_array($mimeType, $allowedMimes, true)) { + http_response_code(415); + die("Type de fichier non accepté ($mimeType)."); + } +} else { + // Full whitelist + if (!in_array($mimeType, ALLOWED_MIME_TYPES, true) + && !in_array($ext, ALLOWED_EXTENSIONS, true)) { + http_response_code(415); + die("Type de fichier non accepté ($mimeType / .$ext)."); + } +} + +// ── Validate size ──────────────────────────────────────────────────────── +$sizeLimit = QUEUE_SIZE_LIMITS[$queueType] ?? MAX_PDF_SIZE; + +// Per-extension overrides for TFE queue (PDF=100MB, AV=2GB) +if ($queueType === 'tfe') { + if ($ext === 'pdf' || $mimeType === 'application/pdf') { + $sizeLimit = MAX_PDF_SIZE; + } elseif (in_array($ext, AV_EXTENSIONS, true) + || str_starts_with($mimeType, 'video/') + || str_starts_with($mimeType, 'audio/')) { + $sizeLimit = MAX_AV_SIZE; + } +} + +if ($upload['size'] > $sizeLimit) { + $limitMb = round($sizeLimit / 1024 / 1024); + $sizeMb = round($upload['size'] / 1024 / 1024); + http_response_code(413); + die("Fichier trop volumineux ($sizeMb MB, max $limitMb MB)."); +} + +// ── Generate unique file_id ────────────────────────────────────────────── +$fileId = bin2hex(random_bytes(16)); // 32-char hex + +// ── Save to tmp/filepond/{file_id}/ ────────────────────────────────────── +$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; +if (!mkdir($tmpDir, 0755, true)) { + error_log('[filepond:process] Failed to create tmp dir: ' . $tmpDir); + http_response_code(500); + die('Erreur serveur — impossible de stocker le fichier.'); +} + +$originalName = basename($upload['name']); +$targetPath = $tmpDir . '/' . $originalName; + +if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { + error_log('[filepond:process] move_uploaded_file FAILED | from=' . $upload['tmp_name'] . ' | to=' . $targetPath); + rmdir($tmpDir); // clean up empty dir + http_response_code(500); + die('Erreur serveur — échec du déplacement du fichier.'); +} +chmod($targetPath, 0644); +error_log('[filepond:process] File saved to tmp | file_id=' . $fileId . ' | path=' . $targetPath); + +// ── PeerTube: upload immediately (don't wait for form submit) ──────────── +$isPeerTube = str_starts_with($queueType, 'peertube_'); +if ($isPeerTube) { + require_once APP_ROOT . '/src/PeerTubeService.php'; + if (PeerTubeService::isEnabled(new Database())) { + $ptFileType = ($queueType === 'peertube_video') ? 'video' : 'audio'; + try { + $result = PeerTubeService::upload( + new Database(), + $targetPath, + $originalName, + $originalName, // title — will be overridden by form submit metadata + '' + ); + // Return a special ID prefix so the controller knows not to look in tmp/ + $fileId = 'peertube:' . $result['uuid']; + // Clean up temp file — PeerTube has its own copy now + @unlink($targetPath); + @rmdir($tmpDir); + error_log('[filepond:process] PeerTube upload OK | uuid=' . $result['uuid'] . ' | url=' . $result['watchUrl']); + header('Content-Type: text/plain; charset=utf-8'); + echo $fileId; + exit; + } catch (\Throwable $e) { + @unlink($targetPath); + @rmdir($tmpDir); + error_log('[filepond:process] PeerTube upload FAILED: ' . $e->getMessage()); + http_response_code(500); + die('Erreur lors du téléversement vers PeerTube.'); + } + } else { + // PeerTube not enabled — reject the upload + @unlink($targetPath); + @rmdir($tmpDir); + http_response_code(503); + die('PeerTube n\'est pas activé.'); + } +} + +// ── Write manifest ─────────────────────────────────────────────────────── +$manifest = [ + 'queue_type' => $queueType, + 'original_name' => $originalName, + 'mime' => $mimeType, + 'ext' => $ext, + 'size' => $upload['size'], + 'session_id' => session_id(), + 'uploaded_at' => date('c'), +]; +file_put_contents($tmpDir . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_SLASHES)); + +// ── Return file_id as plain text ───────────────────────────────────────── +error_log('[filepond:process] SUCCESS | file_id=' . $fileId . ' | queue_type=' . $queueType . ' | name=' . $originalName); +header('Content-Type: text/plain; charset=utf-8'); +echo $fileId; diff --git a/app/public/admin/actions/filepond/remove.php b/app/public/admin/actions/filepond/remove.php new file mode 100644 index 0000000..7242e3e --- /dev/null +++ b/app/public/admin/actions/filepond/remove.php @@ -0,0 +1,80 @@ +getConnection(); +$stmt = $pdo->prepare('SELECT * FROM thesis_files WHERE id = ?'); +$stmt->execute([$dbId]); +$fileRow = $stmt->fetch(); + +if (!$fileRow) { + http_response_code(404); + die('Fichier introuvable.'); +} + +// ── Move physical file to _trash/ for recovery ─────────────────────────── +$filePath = $fileRow['file_path'] ?? ''; +if ($filePath !== '' + && !str_starts_with($filePath, 'peertube_ids:') + && !str_starts_with($filePath, 'http://') + && !str_starts_with($filePath, 'https://')) { + + $absPath = STORAGE_ROOT . '/' . $filePath; + if (file_exists($absPath)) { + $trashDir = STORAGE_ROOT . '/tmp/_trash'; + if (!is_dir($trashDir)) { + mkdir($trashDir, 0755, true); + } + $trashPath = $trashDir . '/' . basename($filePath); + // Append db_id to avoid name collisions + $trashPath = $trashDir . '/' . $dbId . '_' . basename($filePath); + rename($absPath, $trashPath); + } +} + +// ── Soft-delete the row (set deleted_at timestamp) ─────────────────────── +// thesis_files may not have a deleted_at column; delete outright for now. +$delStmt = $pdo->prepare('DELETE FROM thesis_files WHERE id = ?'); +$delStmt->execute([$dbId]); + +http_response_code(200); +exit; diff --git a/app/public/admin/actions/filepond/revert.php b/app/public/admin/actions/filepond/revert.php new file mode 100644 index 0000000..28350e2 --- /dev/null +++ b/app/public/admin/actions/filepond/revert.php @@ -0,0 +1,74 @@ +isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } +} +rmdir($tmpDir); + +http_response_code(200); +exit; diff --git a/app/public/assets/js/app/file-upload-filepond.js b/app/public/assets/js/app/file-upload-filepond.js index 5985ad8..5c2ad3b 100644 --- a/app/public/assets/js/app/file-upload-filepond.js +++ b/app/public/assets/js/app/file-upload-filepond.js @@ -1,19 +1,19 @@ /** * file-upload-filepond.js * - * Thin FilePond wrapper — replaces the old custom file-upload-queue.js. + * FilePond wrapper with async server round-trip architecture. * * Architecture: * 1. Each is upgraded to a FilePond instance. * 2. FilePond handles drag-to-reorder, thumbnails, remove, validation — zero custom DOM. - * 3. storeAsFile: true preserves native multipart form submission. - * Server receives files via $_FILES indexed by each input's name attribute - * (e.g. queue_file[tfe][], queue_file[video][], etc.). - * 4. Type + size validation: via native FilePond options + FileValidateType/Size plugins. - * beforeAddFile is used ONLY for hybrid validation (e.g. per-type size limits) - * and returns true/false per the FilePond API contract. - * 5. Order serialization: hidden inputs track file order from pond.getFiles(). - * 6. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs. + * 3. Async upload: files are POSTed to /admin/actions/filepond/process.php immediately. + * The server returns a file_id stored as item.serverId. + * 4. Form submit sends only file_ids (tiny payload), not the files themselves. + * 5. Type + size validation: via native FilePond options + FileValidateType/Size plugins + * plus fileValidateSizeFilterItem for per-extension size caps. + * 6. Order serialization: hidden inputs track file order using serverId (not filename). + * 7. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs. + * 8. Edit mode: loads existing files via data-existing-files JSON + server.load. */ (function () { @@ -119,19 +119,6 @@ }, }; - // Map input id → queue type - var INPUT_ID_TO_TYPE = { - "tfe-files-input": "tfe", - "tfe-files-input-2": "tfe", - "video-files-input": "video", - "audio-files-input": "audio", - "annexe-files-input": "annexe", - "couverture": "cover", - "note_intention": "note_intention", - "peertube-video-input": "peertube_video", - "peertube-audio-input": "peertube_audio", - }; - // ── Helpers ─────────────────────────────────────────────────────────── /** @@ -154,37 +141,124 @@ return m ? m[1].toLowerCase() : ""; } + /** + * Get the CSRF token from the meta tag. + */ + function getCsrfToken() { + var meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + } + // ── Order serialization ─────────────────────────────────────────────── /** * Create/update a hidden input that serializes the file order for a queue. - * Name: queue_order[] - * Value: pipe-separated list of file names. + * Name: queue_file[][] for each file_id. + * Name: queue_order[] for the pipe-separated order. */ function syncOrderInput(queueType, pond) { if (!pond || !pond.element) return; var form = pond.element.closest("form"); if (!form) return; - var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']"); var files = pond.getFiles(); - if (files.length === 0) { - if (orderInput) orderInput.remove(); - return; + + // Remove old order input and all queue_file hidden inputs for this queueType + var oldOrder = form.querySelector("input[name='queue_order[" + queueType + "]']"); + if (oldOrder) oldOrder.remove(); + + var oldHidden = form.querySelectorAll("input[name='queue_file[" + queueType + "][]'][data-filepond-id]"); + for (var h = 0; h < oldHidden.length; h++) { + oldHidden[h].remove(); } - var names = []; + if (files.length === 0) return; + + // Create hidden inputs per file: queue_file[][] = serverId + var ids = []; for (var i = 0; i < files.length; i++) { - names.push(files[i].filename || files[i].file.name); + var f = files[i]; + // Only include files that have been uploaded and have a serverId + var id = f.serverId || null; + if (id) { + ids.push(id); + var hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = "queue_file[" + queueType + "][]"; + hidden.value = id; + hidden.setAttribute("data-filepond-id", "1"); + form.appendChild(hidden); + } } - if (!orderInput) { - orderInput = document.createElement("input"); + // Create order input + if (ids.length > 0) { + var orderInput = document.createElement("input"); orderInput.type = "hidden"; orderInput.name = "queue_order[" + queueType + "]"; + orderInput.value = ids.join("|"); form.appendChild(orderInput); } - orderInput.value = names.join("|"); + } + + // ── Server config builder ───────────────────────────────────────────── + + function buildServerConfig(queueType) { + var csrfToken = getCsrfToken(); + console.log('[filepond] buildServerConfig | queueType=' + queueType + ' | csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING')); + + return { + process: { + url: '/admin/actions/filepond/process.php', + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + ondata: function (formData) { + formData.append('queue_type', queueType); + console.log('[filepond] process ondata | queueType=' + queueType); + return formData; + }, + onload: function (response) { + var id = response.trim(); + console.log('[filepond] process onload | serverId=' + id); + return id; // file_id stored as serverId + }, + onerror: function (response) { + console.error('[filepond] process onerror | status=' + response.status + ' | body=' + response); + return response; + }, + }, + + revert: { + url: '/admin/actions/filepond/revert.php', + method: 'DELETE', + headers: { 'X-CSRF-Token': csrfToken }, + onload: function () { console.log('[filepond] revert OK'); }, + onerror: function (r) { console.error('[filepond] revert ERROR | body=' + r); }, + }, + + load: '/admin/actions/filepond/load.php?id=', + // FilePond appends the source value (db_id) automatically + + remove: function (source, load, error) { + console.log('[filepond] remove called | db_id=' + source); + fetch('/admin/actions/filepond/remove.php', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ db_id: source }), + }) + .then(function (r) { + console.log('[filepond] remove response | ok=' + r.ok + ' | status=' + r.status); + r.ok ? load() : error('Erreur suppression'); + }) + .catch(function (e) { + console.error('[filepond] remove fetch error', e); + error('Erreur réseau'); + }); + }, + }; } // ── FilePond configuration per queue type ───────────────────────────── @@ -208,8 +282,9 @@ return { allowMultiple: cfg.allowMultiple, allowReorder: true, - allowProcess: false, - storeAsFile: true, + + // ── Async server model (replaces storeAsFile + allowProcess: false) ── + server: buildServerConfig(queueType), // ── Native FilePond validation ── acceptedFileTypes: acceptedFileTypes, @@ -233,17 +308,28 @@ labelButtonRetryItemLoad: "Réessayer", labelButtonProcessItem: "Charger", - // ── Per-extension size validation (hybrid: FilePond validates global maxFileSize, - // beforeAddFile enforces per-extension limits via false return) ── + // ── Per-extension size validation ────────────────────────────── + // Uses fileValidateSizeFilterItem if the FileValidateSize plugin supports it. + // Falls back to beforeAddFile for silent rejection (the plugin shows the error). + fileValidateSizeFilterItem: function (item) { + var ext = getExt(item.filename); + if (ext && perExtMax[ext]) { + return parseSize(perExtMax[ext]); // per-extension cap for this item + } + return parseSize(cfg.maxFileSize); // queue default + }, + + // Fallback: if fileValidateSizeFilterItem is not available, + // beforeAddFile enforces per-extension limits (silent rejection). beforeAddFile: function (item) { + // This check is redundant if fileValidateSizeFilterItem works, + // but serves as a fallback. + if (typeof item.file === 'undefined') return true; var f = item.file; var ext = getExt(f.name); if (ext && perExtMax[ext]) { var limit = parseSize(perExtMax[ext]); if (limit > 0 && f.size > limit) { - // Return false per FilePond API contract — the FileValidateSize - // plugin sets the error state via maxFileSize, but per-extension - // cap violations must be rejected here. return false; } } @@ -255,13 +341,14 @@ onremovefile: function () { syncOrderInput(queueType, this); }, onreorderfiles: function () { syncOrderInput(queueType, this); }, onupdatefiles: function () { syncOrderInput(queueType, this); }, + + // Re-sync after async upload completes (serverId is now set) + onprocessfile: function (error, item) { + if (!error) syncOrderInput(queueType, this); + }, }; } - // ── Instance tracking ──────────────────────────────────────────────── - - var _ponds = {}; - // ── Public API ──────────────────────────────────────────────────────── /** @@ -273,25 +360,30 @@ // Canonical duplicate check: FilePond.find() is the authoritative source if (FilePond.find(input)) return; - var id = input.id; - var queueType = INPUT_ID_TO_TYPE[id]; - if (!queueType) { - queueType = input.dataset.queueType || null; - } + // Queue type: always from data-queue-type attribute + var queueType = input.dataset.queueType || null; if (!queueType) return; var options = buildFilePondOptions(queueType, input); if (!options) return; - options.name = input.getAttribute("name") || input.name || ""; - var pond = FilePond.create(input, options); + console.log('[filepond] Created instance | queueType=' + queueType + ' | inputId=' + (input.id || 'none') + ' | inputName=' + (input.getAttribute('name') || input.name || '?')); - var key = id || queueType; - _ponds[key] = pond; - - // Initial order serialization (for existing files in edit mode — none expected) + // Initial order serialization syncOrderInput(queueType, pond); + + // ── Edit mode: load existing files ── + var existingFiles = []; + try { + existingFiles = JSON.parse(input.dataset.existingFiles || '[]'); + } catch (_) {} + + if (existingFiles.length) { + pond.addFiles(existingFiles.map(function (f) { + return { source: f.source, options: f.options }; + })); + } }); }; @@ -305,23 +397,22 @@ var pond = FilePond.find(input); if (pond) { try { - // Remove order input before destroying + // Remove order/hidden inputs before destroying var form = input.closest("form"); if (form) { - var id = input.id; - var queueType = INPUT_ID_TO_TYPE[id] || input.dataset.queueType || null; + var queueType = input.dataset.queueType || null; if (queueType) { var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']"); if (orderInput) orderInput.remove(); + var hiddenInputs = form.querySelectorAll("input[name='queue_file[" + queueType + "][]'][data-filepond-id]"); + for (var h = 0; h < hiddenInputs.length; h++) { + hiddenInputs[h].remove(); + } } } pond.destroy(); } catch (_) {} } - // Clean up tracking - if (input.id && _ponds[input.id]) { - delete _ponds[input.id]; - } }); } @@ -340,6 +431,29 @@ // ── Bootstrap ───────────────────────────────────────────────────────── + // Global FilePond event listeners for debugging + document.addEventListener('FilePond:processfile', function (e) { + console.log('[filepond:event] processfile | id=' + (e.detail.file ? e.detail.file.serverId : '') + ' | error=' + (e.detail.error || 'none')); + }); + document.addEventListener('FilePond:processfilestart', function (e) { + console.log('[filepond:event] processfilestart | filename=' + (e.detail.file ? e.detail.file.filename : '?')); + }); + document.addEventListener('FilePond:processfileprogress', function (e) { + var pct = e.detail.progress; + if (pct && (pct === 0 || pct === 1 || Math.floor(pct * 100) % 25 === 0)) { + console.log('[filepond:event] processfileprogress | pct=' + Math.floor(pct * 100) + '%'); + } + }); + document.addEventListener('FilePond:processfileabort', function (e) { + console.log('[filepond:event] processfileabort'); + }); + document.addEventListener('FilePond:processfilerevert', function (e) { + console.log('[filepond:event] processfilerevert'); + }); + document.addEventListener('FilePond:error', function (e) { + console.error('[filepond:event] error', e.detail); + }); + // Register FilePond plugins (idempotent) if (typeof FilePondPluginFileValidateType !== "undefined") { FilePond.registerPlugin(FilePondPluginFileValidateType); diff --git a/app/public/assets/js/app/upload-progress.js b/app/public/assets/js/app/upload-progress.js index fc70dd9..ce04227 100644 --- a/app/public/assets/js/app/upload-progress.js +++ b/app/public/assets/js/app/upload-progress.js @@ -33,7 +33,7 @@ function collectFileNames() { const names = []; - // Check raw elements (non-FilePond or FilePond-managed with storeAsFile) + // Check raw elements (non-FilePond) const inputs = form.querySelectorAll('input[type="file"]'); for (const fi of inputs) { if (fi.files) { @@ -42,8 +42,7 @@ } } } - // Also check FilePond instances directly (their storeAsFile hidden inputs may not - // have .files populated yet when the submit event fires) + // Read processed file names from FilePond instances (async mode) if (typeof FilePond !== 'undefined') { const pondInputs = form.querySelectorAll('.tfe-file-picker'); for (const pi of pondInputs) { @@ -51,8 +50,11 @@ if (pond) { const pondFiles = pond.getFiles(); for (const pf of pondFiles) { - const name = pf.filename || (pf.file && pf.file.name); - if (name) names.push(name); + // Only count successfully uploaded files (have serverId) + if (pf.serverId) { + const name = pf.filename || (pf.file && pf.file.name) || pf.serverId; + if (name) names.push(name); + } } } } @@ -61,6 +63,32 @@ } form.addEventListener('submit', function (e) { + // ── Guard: block submit if any FilePond item is still uploading ── + if (typeof FilePond !== 'undefined') { + let stillUploading = false; + const pondInputs = form.querySelectorAll('.tfe-file-picker'); + for (const pi of pondInputs) { + const pond = FilePond.find(pi); + if (pond) { + const pondFiles = pond.getFiles(); + for (const pf of pondFiles) { + if (pf.status === FilePond.FileStatus.PROCESSING || + pf.status === FilePond.FileStatus.IDLE) { + stillUploading = true; + break; + } + } + } + if (stillUploading) break; + } + if (stillUploading) { + e.preventDefault(); + progressLabel.textContent = 'Veuillez attendre la fin du téléversement…'; + progressWrap.style.display = ''; + return; + } + } + const fileNames = collectFileNames(); if (!fileNames.length) return; diff --git a/app/public/partage/fichiers-fragment.php b/app/public/partage/fichiers-fragment.php index a63d8dd..0e6dd8c 100644 --- a/app/public/partage/fichiers-fragment.php +++ b/app/public/partage/fichiers-fragment.php @@ -221,8 +221,10 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); name="queue_file[tfe][]" multiple class="tfe-file-picker" + data-queue-type="tfe" - data-peertube-active=""> + data-peertube-active="" + data-existing-files=''> PDF (max 100 MB) · Images (max 500 MB) · VTT · Archives (max 500 MB). @@ -245,24 +247,14 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); + class="tfe-file-picker" + data-queue-type="annexe"> PDF ou archives ZIP/TAR. Max 500 MB. Glissez pour réordonner. - -
- -
- - Le TFE sera affiché comme un site embarqué sur sa page publique. -
-
- - +
@@ -271,7 +263,8 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); name="queue_file[peertube_video][]" multiple accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogv,.mov" - class="tfe-file-picker"> + class="tfe-file-picker" + data-queue-type="peertube_video"> MP4, WebM ou MOV. Max 500 MB. Hébergé sur PeerTube.
@@ -283,13 +276,14 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); name="queue_file[video][]" multiple accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogv,.mov" - class="tfe-file-picker"> + class="tfe-file-picker" + data-queue-type="video"> MP4, WebM ou MOV. Max 500 MB. - +
@@ -298,7 +292,8 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); name="queue_file[peertube_audio][]" multiple accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/mp4,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a" - class="tfe-file-picker"> + class="tfe-file-picker" + data-queue-type="peertube_audio"> MP3, OGG, WAV, FLAC ou AAC. Max 500 MB. Hébergé sur PeerTube.
@@ -310,11 +305,23 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); name="queue_file[audio][]" multiple accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/mp4,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a" - class="tfe-file-picker"> + class="tfe-file-picker" + data-queue-type="audio"> MP3, OGG, WAV, FLAC ou AAC. Max 500 MB. + +
+ +
+ + Le TFE sera affiché comme un site embarqué sur sa page publique. +
+
+ diff --git a/app/public/partage/index.php b/app/public/partage/index.php index 9c8a095..e9a19e6 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -290,6 +290,11 @@ function renderShareLinkForm(string $slug, array $link): void } $shareCsrfToken = $_SESSION[$shareCsrfKey]; + // Also set a global CSRF token for FilePond async uploads (read from ) + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = $shareCsrfToken; + } + $pageTitle = 'Soumettre un TFE'; $isVerified = !empty($_SESSION['share_verified_' . $slug]); @@ -392,6 +397,9 @@ function renderShareLinkForm(string $slug, array $link): void + + + diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 736db60..64dd685 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -194,26 +194,38 @@ class ThesisCreateController $folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/'; $filePrefix = $folderName; - $this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix); - $this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix); + if (!empty($post['filepond_mode'])) { + // New path: files already on server via async FilePond uploads + // Cover and note_intention also go through FilePond async flow + $this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix); + $this->handleFilePondSingleFile($thesisId, $post, 'note_intention', $folderPath, $filePrefix); + $nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, 1); + $nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'video', $folderPath, $filePrefix, $nextNum); + $nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'audio', $folderPath, $filePrefix, $nextNum); + $this->handleFilePondQueueFiles($thesisId, $post, 'annexe', $folderPath, $filePrefix, 0); + $this->handleFilePondQueueFiles($thesisId, $post, 'peertube_video', $folderPath, $filePrefix, 0, null); + $this->handleFilePondQueueFiles($thesisId, $post, 'peertube_audio', $folderPath, $filePrefix, 0, null); + } else { + // Legacy path: files arrive via multipart $_FILES + $this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix); + $this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix); + $queueFiles = $files['queue_file'] ?? []; + $qTfe = $this->extractFilesSubArray($queueFiles, 'tfe'); + $qVideo = $this->extractFilesSubArray($queueFiles, 'video'); + $qAudio = $this->extractFilesSubArray($queueFiles, 'audio'); + $qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe'); - // TFE files from client-side JS queue (FormData) - $queueFiles = $files['queue_file'] ?? []; - $qTfe = $this->extractFilesSubArray($queueFiles, 'tfe'); - $qVideo = $this->extractFilesSubArray($queueFiles, 'video'); - $qAudio = $this->extractFilesSubArray($queueFiles, 'audio'); - $qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe'); + $nextNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, 1); + $nextNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $nextNum); + $nextNum = $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $nextNum); + $this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix); - $nextNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, 1); - $nextNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $nextNum); - $nextNum = $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $nextNum); - $this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix); - - // ── 5b. PeerTube video / audio uploads (from FilePond queue) ────────── - $qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video'); - $qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio'); - $this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTVideo, 'video'); - $this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTAudio, 'audio'); + // ── 5b. PeerTube video / audio uploads (from FilePond queue) ────────── + $qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video'); + $qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio'); + $this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTVideo, 'video'); + $this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTAudio, 'audio'); + } // ── 6. Website URL — stored as thesis_files row ────────────────────── $this->handleWebsiteUrl($thesisId, $post); diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 197bfd5..a85f1ff 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -340,7 +340,24 @@ class ThesisEditController } // ── Cover image (outside transaction — filesystem op) ───────────────── - if (!empty($post['remove_cover'])) { + if (!empty($post['filepond_mode'])) { + // Async path: cover file_id arrives in post, not $_FILES + if (!empty($post['remove_cover'])) { + foreach ($existingFiles as $f) { + if ($f['file_type'] === 'cover') { + $this->db->deleteThesisFile((int)$f['id'], $thesisId); + if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { + $abs = STORAGE_ROOT . '/' . $f['file_path']; + if (file_exists($abs)) { + @unlink($abs); + } + } + break; + } + } + } + $this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix); + } elseif (!empty($post['remove_cover'])) { foreach ($existingFiles as $f) { if ($f['file_type'] === 'cover') { $this->db->deleteThesisFile((int)$f['id'], $thesisId); @@ -358,22 +375,43 @@ class ThesisEditController } // ── Note d'intention (replace if uploaded) ──────────────────────────── - // Remove old note_intention row+file if new one is uploaded - if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) { - foreach ($existingFiles as $f) { - if ($f['file_type'] === 'note_intention') { - $this->db->deleteThesisFile((int)$f['id'], $thesisId); - if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { - $abs = STORAGE_ROOT . '/' . $f['file_path']; - if (file_exists($abs)) { - @unlink($abs); + if (!empty($post['filepond_mode'])) { + // Remove old note_intention if new one is uploaded via async path + $newNoteId = ($post['queue_file']['note_intention'] ?? null); + $hasNewNote = $newNoteId !== null && (is_array($newNoteId) ? !empty($newNoteId) : $newNoteId !== ''); + if ($hasNewNote) { + foreach ($existingFiles as $f) { + if ($f['file_type'] === 'note_intention') { + $this->db->deleteThesisFile((int)$f['id'], $thesisId); + if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { + $abs = STORAGE_ROOT . '/' . $f['file_path']; + if (file_exists($abs)) { + @unlink($abs); + } } + break; } - break; } } + $this->handleFilePondSingleFile($thesisId, $post, 'note_intention', $folderPath, $filePrefix); + } else { + // Legacy path + if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) { + foreach ($existingFiles as $f) { + if ($f['file_type'] === 'note_intention') { + $this->db->deleteThesisFile((int)$f['id'], $thesisId); + if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { + $abs = STORAGE_ROOT . '/' . $f['file_path']; + if (file_exists($abs)) { + @unlink($abs); + } + } + break; + } + } + } + $this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix); } - $this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix); // ── Delete individual thesis files ──────────────────────────────────── $deleteIds = isset($post['delete_files']) && is_array($post['delete_files']) @@ -411,38 +449,52 @@ class ThesisEditController } } - // ── New TFE/video/audio files upload (from client-side JS queue) ── - $queueFiles = $files['queue_file'] ?? []; - $qTfe = $this->extractFilesSubArray($queueFiles, 'tfe'); - $qVideo = $this->extractFilesSubArray($queueFiles, 'video'); - $qAudio = $this->extractFilesSubArray($queueFiles, 'audio'); - $qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe'); - + // ── New TFE/video/audio files upload (choose path based on filepond_mode) + // Count existing TFE files (not cover, note_intention, website, annex, caption, PeerTube) $tfeCount = 0; foreach ($existingFiles as $f) { if (!in_array($f['file_type'] ?? '', ['cover', 'note_intention', 'website', 'annex', 'caption'], true) - && !str_starts_with($f['file_path'] ?? '', 'http')) { + && !str_starts_with($f['file_path'] ?? '', 'http') + && !str_starts_with($f['file_path'] ?? '', 'peertube_ids:')) { $tfeCount++; } } - $startNum = $tfeCount + 1; - $startNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, $startNum); - $startNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $startNum); - $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $startNum); - $this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix); + if (!empty($post['filepond_mode'])) { + // New path: files already on server via async FilePond uploads + $nextNum = $tfeCount + 1; + $nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, $nextNum); + $nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'video', $folderPath, $filePrefix, $nextNum); + $this->handleFilePondQueueFiles($thesisId, $post, 'audio', $folderPath, $filePrefix, $nextNum); + $this->handleFilePondQueueFiles($thesisId, $post, 'annexe', $folderPath, $filePrefix, 0); + $this->handleFilePondQueueFiles($thesisId, $post, 'peertube_video', $folderPath, $filePrefix, 0, $progressToken); + $this->handleFilePondQueueFiles($thesisId, $post, 'peertube_audio', $folderPath, $filePrefix, 0, $progressToken); + } else { + // Legacy path: files arrive via multipart $_FILES + $queueFiles = $files['queue_file'] ?? []; + $qTfe = $this->extractFilesSubArray($queueFiles, 'tfe'); + $qVideo = $this->extractFilesSubArray($queueFiles, 'video'); + $qAudio = $this->extractFilesSubArray($queueFiles, 'audio'); + $qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe'); - // Legacy annexe files (direct upload, non-queue path — kept for backwards compat) - if (isset($files['annexes']) && is_array($files['annexes']['name'] ?? null)) { - $this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post); + $startNum = $tfeCount + 1; + $startNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, $startNum); + $startNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $startNum); + $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $startNum); + $this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix); + + // Legacy annexe files (direct upload, non-queue path — kept for backwards compat) + if (isset($files['annexes']) && is_array($files['annexes']['name'] ?? null)) { + $this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post); + } + + // ── PeerTube video / audio uploads (from FilePond queue) ────────────── + $qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video'); + $qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio'); + $this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTVideo, 'video', $progressToken); + $this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTAudio, 'audio', $progressToken); } - // ── PeerTube video / audio uploads (from FilePond queue) ────────────── - $qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video'); - $qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio'); - $this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTVideo, 'video', $progressToken); - $this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTAudio, 'audio', $progressToken); - // ── Website URL — add or update ────────────────────────────────────── $this->handleWebsiteUrl($thesisId, $post); } diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php index beff829..07f2b93 100644 --- a/app/src/Controllers/ThesisFileHandler.php +++ b/app/src/Controllers/ThesisFileHandler.php @@ -845,4 +845,360 @@ trait ThesisFileHandler mkdir($baseDir . $candidate, 0755, true); return $candidate; } + + // ── FilePond async file processing ────────────────────────────────────── + + /** + * Process a single file from the FilePond async flow (cover, note_intention). + * + * Unlike queue files, these arrive as a single file_id in $post['queue_file'][$queueKey] + * (which will be a string, not an array — PHP normalizes single-value inputs). + */ + protected function handleFilePondSingleFile( + int $thesisId, + array $post, + string $queueKey, + string $folderPath, + string $filePrefix + ): void { + $raw = $post['queue_file'][$queueKey] ?? null; + if ($raw === null || (is_array($raw) && empty($raw))) { + return; + } + + // PHP may send a single value as scalar or single-element array + $fileId = is_array($raw) ? $raw[0] : $raw; + $fileId = trim($fileId); + if ($fileId === '' || !preg_match('/^[a-f0-9]{32}$/', $fileId)) { + return; + } + + $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; + $manifestPath = $tmpDir . '/manifest.json'; + + if (!is_dir($tmpDir) || !file_exists($manifestPath)) { + error_log("ThesisFileHandler: single file_id $fileId not found in tmp/"); + return; + } + + $manifest = json_decode(file_get_contents($manifestPath), true); + if (!is_array($manifest)) { + error_log("ThesisFileHandler: invalid manifest for $fileId"); + return; + } + + // Find the actual file + $actualFile = null; + $dh = opendir($tmpDir); + while (($entry = readdir($dh)) !== false) { + if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { + continue; + } + $actualFile = $tmpDir . '/' . $entry; + break; + } + closedir($dh); + + if ($actualFile === null || !file_exists($actualFile)) { + error_log("ThesisFileHandler: no file found in tmp dir for $fileId"); + return; + } + + $dir = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $ext = $manifest['ext']; + $mimeType = $manifest['mime']; + $originalName = $manifest['original_name']; + $size = $manifest['size']; + + if ($queueKey === 'cover') { + $targetName = $filePrefix . '_COUVERTURE.' . $ext; + $fileType = 'cover'; + } elseif ($queueKey === 'note_intention') { + $targetName = $filePrefix . '_NOTE_INTENTION.pdf'; + $fileType = 'note_intention'; + } else { + error_log("ThesisFileHandler: unknown single file queue key $queueKey"); + return; + } + + $targetPath = $dir . $targetName; + + if (!rename($actualFile, $targetPath)) { + error_log("ThesisFileHandler: failed to move $queueKey from tmp"); + return; + } + + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + + $this->db->insertThesisFile( + $thesisId, $fileType, + $relPath, + $originalName, + $size, + $mimeType + ); + error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName"); + + $this->cleanupFilePondTmp($fileId); + } + + /** + * Process queue files that were uploaded asynchronously via FilePond. + * + * Instead of receiving $_FILES, this method reads ad-hoc `queue_file[tfe][]` + * style hidden inputs containing opaque file_ids. Each file_id maps to + * a directory under tmp/filepond/ with a manifest.json and the actual file. + * + * This is the new path (Step 2 of the refactor). The old $_FILES path + * (handleTfeQueueFiles) is kept for backwards compatibility and can be + * removed once the new flow is stable. + * + * @param int $thesisId + * @param array $post $_POST array (contains queue_file[tfe][] etc.) + * @param string $queueKey Queue sub-key ('tfe', 'video', 'audio', 'annexe', 'peertube_video', 'peertube_audio') + * @param string $folderPath Relative path to the thesis folder. + * @param string $filePrefix The shared file prefix. + * @param int $startNum Starting number for TFE_XX (only used for tfe/video/audio queues). + * @param string|null $progressToken Optional progress token for PeerTube uploads. + * @return int The next TFE number (for tfe/video/audio queues). + */ + protected function handleFilePondQueueFiles( + int $thesisId, + array $post, + string $queueKey, + string $folderPath, + string $filePrefix, + int $startNum = 1, + ?string $progressToken = null + ): int { + $fileIds = $post['queue_file'][$queueKey] ?? []; + if (!is_array($fileIds) || empty($fileIds)) { + return $startNum; + } + + $dir = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $isPeerTube = false; // peerTube handled inline per fileId (peertube: prefix) + $isAnnexe = $queueKey === 'annexe'; + $isTfeLike = in_array($queueKey, ['tfe', 'video', 'audio'], true); + + // ── Collect files from tmp/ ────────────────────────────────────────── + $files = []; + $vttQueue = []; + + foreach ($fileIds as $fileId) { + $fileId = trim($fileId); + if ($fileId === '') { + continue; + } + + // PeerTube files have been uploaded already; just insert DB row + if (str_starts_with($fileId, 'peertube:')) { + $uuid = substr($fileId, strlen('peertube:')); + $fileType = ($queueKey === 'peertube_video') ? 'video' : 'audio'; + $storedPath = 'peertube_ids:' . $uuid; + $this->db->insertThesisFile( + $thesisId, $fileType, + $storedPath, + $uuid . ' (PeerTube)', + 0, + ($queueKey === 'peertube_video') ? 'video/mp4' : 'audio/mpeg', + null, null + ); + error_log("ThesisFileHandler: PeerTube file associated → $uuid"); + continue; + } + + // Regular tmp files (hex file_id) + if (!preg_match('/^[a-f0-9]{32}$/', $fileId)) { + continue; + } + + $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; + $manifestPath = $tmpDir . '/manifest.json'; + + if (!is_dir($tmpDir) || !file_exists($manifestPath)) { + error_log("ThesisFileHandler: file_id $fileId not found in tmp/"); + continue; + } + + $manifest = json_decode(file_get_contents($manifestPath), true); + if (!is_array($manifest)) { + error_log("ThesisFileHandler: invalid manifest for $fileId"); + continue; + } + + // Find the actual file in the tmp dir (there should be exactly one non-manifest file) + $actualFile = null; + $dh = opendir($tmpDir); + while (($entry = readdir($dh)) !== false) { + if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { + continue; + } + $actualFile = $tmpDir . '/' . $entry; + break; + } + closedir($dh); + + if ($actualFile === null || !file_exists($actualFile)) { + error_log("ThesisFileHandler: no file found in tmp dir for $fileId"); + continue; + } + + $entry = [ + 'fileId' => $fileId, + 'tmpDir' => $tmpDir, + 'mimeType' => $manifest['mime'], + 'ext' => $manifest['ext'], + 'size' => $manifest['size'], + 'origName' => $manifest['original_name'], + 'label' => '', + 'sortOrder' => null, + 'fileType' => $this->detectFileType($manifest['mime'], $manifest['ext']), + 'actualFile' => $actualFile, + ]; + + if ($isTfeLike && $entry['fileType'] === 'caption') { + $vttQueue[] = $entry; + } else { + $files[] = $entry; + } + } + + // ── Handle annexe queue ────────────────────────────────────────────── + if ($isAnnexe) { + $num = 1; + foreach ($files as $f) { + $padded = sprintf('%02d', $num); + $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $f['ext']; + $targetPath = $dir . $targetName; + + if (!rename($f['actualFile'], $targetPath)) { + error_log("ThesisFileHandler: failed to move annexe {$f['origName']}"); + continue; + } + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + + $this->db->insertThesisFile( + $thesisId, 'annex', + $relPath, + basename($f['origName']), + $f['size'], + $f['mimeType'], + null, null + ); + error_log("ThesisFileHandler: annexe (filepond) → $targetName"); + $num++; + $this->cleanupFilePondTmp($f['fileId']); + } + return $startNum; + } + + // ── Handle TFE/video/audio queues ──────────────────────────────────── + // Sort by hierarchy rank (PDF → video → audio → caption → image → archive) + $filesWithRank = []; + foreach ($files as $f) { + $f['hierarchy'] = $this->tfeHierarchyRank($f['mimeType'], $f['ext']); + $filesWithRank[] = $f; + } + usort($filesWithRank, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']); + + $num = $startNum; + $vttIdx = 0; + $videoCount = 0; + foreach ($filesWithRank as $f) { + if ($f['fileType'] === 'video') { + $videoCount++; + } + } + + foreach ($filesWithRank as $f) { + if ($f['fileType'] === 'caption') { + $vttQueue[] = $f; + continue; + } + + if ($f['fileType'] === 'video') { + $this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + if (!empty($vttQueue)) { + $vtt = array_shift($vttQueue); + $this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + } + } else { + $this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + } + } + + // Orphaned VTTs + foreach ($vttQueue as $vtt) { + $this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + } + + return $num; + } + + /** + * Write a single TFE file from tmp/filepond to the thesis directory. + */ + private function writeTfeFileFromTmp(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void + { + $padded = sprintf('%02d', $num); + $targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext']; + $targetPath = $dir . $targetName; + + if (!rename($f['actualFile'], $targetPath)) { + error_log("ThesisFileHandler: failed to move TFE {$f['origName']} from tmp"); + return; + } + + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + + $this->db->insertThesisFile( + $thesisId, $f['fileType'], + $relPath, + basename($f['origName']), + $f['size'], + $f['mimeType'], + $f['label'] !== '' ? $f['label'] : null, + $f['sortOrder'] + ); + error_log("ThesisFileHandler: TFE uploaded (filepond) → $targetName ({$f['fileType']})"); + + $this->cleanupFilePondTmp($f['fileId']); + } + + /** + * Clean up a tmp/filepond directory after processing. + */ + private function cleanupFilePondTmp(string $fileId): void + { + $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; + if (!is_dir($tmpDir)) { + return; + } + $it = new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS); + $files_it = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files_it as $file) { + if ($file->isDir()) { + @rmdir($file->getRealPath()); + } else { + @unlink($file->getRealPath()); + } + } + @rmdir($tmpDir); + } } diff --git a/app/storage/tmp/.gitkeep b/app/storage/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index 27fe7ef..6c1edeb 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -1468,6 +1468,84 @@ +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +\\\\\\\ to: okvqnxxl 4c0538fc "refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent" (rebased revision) ++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: okvqnxxl 4c0538fc "refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: lmomnqus a26fbab2 "filepond: implement async server-ID architecture" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: lmomnqus 0a7007c5 "filepond: implement async server-ID architecture" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: lmomnqus 0a7007c5 "filepond: implement async server-ID architecture" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: opqqqptm 1701f1f1 "filepond: implement async server-ID architecture" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: opqqqptm 25762c66 "filepond: implement async server-ID architecture" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: opqqqptm 25762c66 "filepond: implement async server-ID architecture" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: ttylnzqt 64ded48a "filepond: fix CSRF token, partage meta tag, file field detection, add debug logging" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: ttylnzqt 2c31453c "filepond: fix CSRF token, partage meta tag, file field detection, add debug logging" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: ttylnzqt 2c31453c "filepond: fix CSRF token, partage meta tag, file field detection, add debug logging" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: rotylxwp 93dbea67 "filepond: fix nested $_FILES extraction for multi-file queues" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: rotylxwp 25144d51 "filepond: fix nested $_FILES extraction for multi-file queues" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: rotylxwp 25144d51 "filepond: fix nested $_FILES extraction for multi-file queues" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: vmxqlrmt 1806a2cd "filepond: fix $_FILES unwrapping for PHP-nested multi-file queue inputs" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: vmxqlrmt fa81358d "filepond: fix $_FILES unwrapping for PHP-nested multi-file queue inputs" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: vmxqlrmt fa81358d "filepond: fix $_FILES unwrapping for PHP-nested multi-file queue inputs" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: uzsxkykq af017a38 "filepond: fix audio/mp3 MIME, immediate PeerTube upload, handle nested queue $_FILES" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: uzsxkykq c23ac0f6 "filepond: fix audio/mp3 MIME, immediate PeerTube upload, handle nested queue $_FILES" (rebased revision) +++ $linkName = $link['name'] ?? ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?> diff --git a/app/templates/head.php b/app/templates/head.php index 852162d..30c6d06 100644 --- a/app/templates/head.php +++ b/app/templates/head.php @@ -64,6 +64,9 @@ + + + diff --git a/app/templates/partials/form/fieldset-files.php b/app/templates/partials/form/fieldset-files.php index 0c272e9..14f465c 100644 --- a/app/templates/partials/form/fieldset-files.php +++ b/app/templates/partials/form/fieldset-files.php @@ -40,7 +40,8 @@ $adminMode = $adminMode ?? false; + class="tfe-file-picker" + data-queue-type="tfe"> Types acceptés : PDF · JPG/PNG/GIF/WEBP · MP4/WebM/MOV (vidéo) · MP3/OGG/WAV/FLAC (audio) · ZIP/TAR (archives). Max 500 MB par fichier. Les fichiers .vtt sont des sous-titres et seront associés automatiquement à la vidéo précédente. diff --git a/app/templates/partials/form/form.php b/app/templates/partials/form/form.php index 8368cd7..4530b3d 100644 --- a/app/templates/partials/form/form.php +++ b/app/templates/partials/form/form.php @@ -145,6 +145,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
+ @@ -322,6 +323,25 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; $_POST['website_label'] = $existingWebsiteLabel; $_POST['admin_mode'] = $adminMode ? '1' : '0'; $_POST['has_annexes'] = $formData['has_annexes'] ?? null; + + // Build existing-files JSON for FilePond edit mode + $existingFilesJsonForTfe = []; + if (!empty($currentFiles)) { + foreach ($currentFiles as $f) { + $ft = $f['file_type'] ?? ''; + $fp = $f['file_path'] ?? ''; + // Skip cover (handled separately) and website/peertube (no actual file) + if ($ft === 'cover' || str_starts_with($fp, 'http://') || str_starts_with($fp, 'https://') || str_starts_with($fp, 'peertube_ids:')) { + continue; + } + // Only include files that can be streamed back via load.php + $existingFilesJsonForTfe[] = [ + 'source' => (string)((int)$f['id']), + 'options' => ['type' => 'local'], + ]; + } + } + include APP_ROOT . '/public/partage/fichiers-fragment.php'; $_POST = $_savedPost; unset($_savedPost); @@ -338,6 +358,25 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; $_POST['edit_mode'] = '1'; $_POST['_cover'] = $currentCover['file_path'] ?? null; $_POST['has_annexes'] = $formData['has_annexes'] ?? null; + + // Build existing-files JSON for FilePond edit mode + $existingFilesJsonForTfe = []; + if (!empty($currentFiles)) { + foreach ($currentFiles as $f) { + $ft = $f['file_type'] ?? ''; + $fp = $f['file_path'] ?? ''; + // Skip cover (handled separately) and website/peertube (no actual file) + if ($ft === 'cover' || str_starts_with($fp, 'http://') || str_starts_with($fp, 'https://') || str_starts_with($fp, 'peertube_ids:')) { + continue; + } + // Only include files that can be streamed back via load.php + $existingFilesJsonForTfe[] = [ + 'source' => (string)((int)$f['id']), + 'options' => ['type' => 'local'], + ]; + } + } + include APP_ROOT . '/public/partage/fichiers-fragment.php'; $_POST = $_savedPost; unset($_savedPost);