mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
standardise multi-author support across all forms
- ThesisCreateController: comma-split auteurice, sort alphabetically, use setThesisAuthors() instead of hardcoded createThesis() author_id - Database::createThesis(): removed author_id param and hardcoded insert - Database::findDuplicateThesis(): accepts array of author names, matches any shared author via IN + DISTINCT - ThesisEditController::save(): sort authors alphabetically on save - File folder naming: slug from all authors alphabetically sorted - v_theses_full GROUP_CONCAT: ORDER BY a.name ASC for deterministic display - Migration 012_author_view_order.sql: rebuilds view with alphabetical order
This commit is contained in:
9
TODO.md
9
TODO.md
@@ -36,6 +36,15 @@
|
||||
- [x] `templates/admin/acces.php` — archive button, archived links collapsible section
|
||||
- [x] `scripts/setup-server.sh` — provision `/var/log/xamxam.log` with correct ownership
|
||||
|
||||
## Multi-author support
|
||||
- [x] `ThesisCreateController::validateAndSanitise()` — comma-split `auteurice`, sort alphabetically, build author entries array
|
||||
- [x] `Database::createThesis()` — removed hardcoded `author_id` insert; authors linked via `setThesisAuthors()` instead
|
||||
- [x] `ThesisEditController::save()` — authors sorted alphabetically before `setThesisAuthors()`
|
||||
- [x] `Database::findDuplicateThesis()` — accepts `array` of author names, matches any shared author via `IN` + `DISTINCT`
|
||||
- [x] File folder naming — slug generated from all authors alphabetically sorted (both create and edit)
|
||||
- [x] `v_theses_full` GROUP_CONCAT — `ORDER BY a.name ASC` for deterministic alphabetical display
|
||||
- [x] Migration `012_author_view_order.sql` — rebuilds view with alphabetical author ordering
|
||||
|
||||
## Fix remote 500s and broken TFE pages (post-deploy)
|
||||
- [x] `migrations/pending/008_share_links_is_archived.sql` — `ALTER TABLE share_links ADD COLUMN is_archived` (missing on remote; breaks `acces.php`)
|
||||
- [x] `migrations/pending/009_admin_audit_log.sql` — `CREATE TABLE admin_audit_log` (missing on remote)
|
||||
|
||||
59
app/migrations/pending/012_author_view_order.sql
Normal file
59
app/migrations/pending/012_author_view_order.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Rebuild v_theses_full to ORDER BY a.name in the GROUP_CONCAT for authors,
|
||||
-- ensuring the view always returns authors alphabetically.
|
||||
DROP VIEW IF EXISTS v_theses_full;
|
||||
CREATE VIEW IF NOT EXISTS v_theses_full AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.identifier,
|
||||
t.title,
|
||||
t.subtitle,
|
||||
t.year,
|
||||
t.is_doctoral,
|
||||
t.objet,
|
||||
o.name as orientation,
|
||||
ap.name as ap_program,
|
||||
ft.name as finality_type,
|
||||
t.synopsis,
|
||||
t.context_note,
|
||||
t.duration_minutes,
|
||||
t.duration_pages,
|
||||
t.file_size_info,
|
||||
at.name as access_type,
|
||||
lt.name as license_type,
|
||||
t.license_id,
|
||||
t.access_type_id,
|
||||
t.jury_points,
|
||||
t.submitted_at,
|
||||
t.defense_date,
|
||||
t.published_at,
|
||||
t.is_published,
|
||||
t.baiu_link,
|
||||
t.banner_path,
|
||||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||||
GROUP_CONCAT(DISTINCT s.name) as supervisors,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' THEN s.name END) as jury_lecteurs,
|
||||
GROUP_CONCAT(DISTINCT l.name) as languages,
|
||||
GROUP_CONCAT(DISTINCT fmt.name) as formats,
|
||||
GROUP_CONCAT(DISTINCT tg.name) as keywords,
|
||||
-- First author's email and contact-visibility flag
|
||||
(SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_email,
|
||||
(SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_show_contact
|
||||
FROM theses t
|
||||
LEFT JOIN orientations o ON t.orientation_id = o.id
|
||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
||||
LEFT JOIN finality_types ft ON t.finality_id = ft.id
|
||||
LEFT JOIN access_types at ON t.access_type_id = at.id
|
||||
LEFT JOIN license_types lt ON t.license_id = lt.id
|
||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||
LEFT JOIN authors a ON ta.author_id = a.id
|
||||
LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id
|
||||
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
|
||||
LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id
|
||||
LEFT JOIN languages l ON tl.language_id = l.id
|
||||
LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id
|
||||
LEFT JOIN format_types fmt ON tf.format_id = fmt.id
|
||||
LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id
|
||||
LEFT JOIN tags tg ON tt.tag_id = tg.id
|
||||
GROUP BY t.id;
|
||||
@@ -146,7 +146,7 @@ class ThesisCreateController
|
||||
|
||||
// ── 1b. Duplicate detection ───────────────────────────────────────────
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
$duplicate = $this->db->findDuplicateThesis($data['titre'], $data['auteurName'], $data['annee']);
|
||||
$duplicate = $this->db->findDuplicateThesis($data['titre'], $data['authorNames'], $data['annee']);
|
||||
if ($duplicate !== null) {
|
||||
throw new DuplicateThesisException(
|
||||
$duplicate['id'],
|
||||
@@ -157,9 +157,17 @@ class ThesisCreateController
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Find / create author ───────────────────────────────────────────
|
||||
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']);
|
||||
error_log("ThesisCreateController: author ID $authorId");
|
||||
// ── 2. Build author entries (alphabetically sorted) ───────────────────
|
||||
$authorEntries = [];
|
||||
foreach ($data['authorNames'] as $i => $name) {
|
||||
$authorEntries[] = [
|
||||
'name' => $name,
|
||||
'email' => $i === 0 ? ($data['mail'] ?: null) : null,
|
||||
'show_contact' => $i === 0 ? $data['showContact'] : false,
|
||||
];
|
||||
}
|
||||
$allAuthorsStr = implode(', ', $data['authorNames']);
|
||||
$authorSlug = $this->generateAuthorSlug($allAuthorsStr);
|
||||
|
||||
// ── 3–4. DB writes in a transaction ───────────────────────────────────
|
||||
$this->db->beginTransaction();
|
||||
@@ -178,12 +186,12 @@ class ThesisCreateController
|
||||
'license_id' => $data['licenseId'],
|
||||
'access_type_id' => $data['accessTypeId'],
|
||||
'objet' => $data['objet'],
|
||||
'author_id' => $authorId,
|
||||
]);
|
||||
|
||||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier)");
|
||||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . " author(s)");
|
||||
|
||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||
$this->db->setThesisJury($thesisId, $data['juryMembers']);
|
||||
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
|
||||
$this->db->setThesisFormats($thesisId, $data['formatIds']);
|
||||
@@ -199,7 +207,7 @@ class ThesisCreateController
|
||||
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
|
||||
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
|
||||
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $data['auteurName'], $post);
|
||||
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post);
|
||||
|
||||
return $thesisId;
|
||||
}
|
||||
@@ -263,10 +271,17 @@ class ThesisCreateController
|
||||
*/
|
||||
private function validateAndSanitise(array $post): array
|
||||
{
|
||||
$auteurName = $this->validateRequired(
|
||||
$this->sanitiseString($post['auteurice'] ?? ''),
|
||||
'Nom/Prénom/Pseudo'
|
||||
);
|
||||
// Split authors by comma, trim, filter empty, sort alphabetically.
|
||||
$authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
|
||||
$authorNames = [];
|
||||
if ($authorRaw !== '') {
|
||||
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn($n) => $n !== '');
|
||||
$authorNames = array_values($authorNames);
|
||||
sort($authorNames, SORT_NATURAL);
|
||||
}
|
||||
if (empty($authorNames)) {
|
||||
throw new Exception("Le champ 'Nom/Prénom/Pseudo' est requis.");
|
||||
}
|
||||
|
||||
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
|
||||
$showContact = !empty($post['contact_public']) ? true : false;
|
||||
@@ -371,7 +386,7 @@ class ThesisCreateController
|
||||
}
|
||||
|
||||
return compact(
|
||||
'auteurName',
|
||||
'authorNames',
|
||||
'mail',
|
||||
'showContact',
|
||||
'confirmationEmail',
|
||||
@@ -445,16 +460,14 @@ class ThesisCreateController
|
||||
* @param int $year Used for the storage sub-directory path.
|
||||
* @param string $identifier Thesis identifier slug (e.g. "2024-003").
|
||||
* @param array|null $uploads Multi-file $_FILES entry (may be null).
|
||||
* @param string $authorName Author name for folder and file naming.
|
||||
* @param string $authorSlug Pre-computed author slug for folder and file naming.
|
||||
*/
|
||||
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorName, array $post = []): void
|
||||
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorSlug, array $post = []): void
|
||||
{
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate author slug and unique folder name
|
||||
$authorSlug = $this->generateAuthorSlug($authorName);
|
||||
$folderName = $this->ensureUniqueFolder($year, $authorSlug);
|
||||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
|
||||
|
||||
|
||||
@@ -181,21 +181,22 @@ class ThesisEditController
|
||||
'is_published' => isset($post['is_published']),
|
||||
]);
|
||||
|
||||
// ── 2. Authors ────────────────────────────────────────────────────
|
||||
// ── 2. Authors (alphabetically sorted) ─────────────────────────────
|
||||
$authorsRaw = trim($post['auteurice'] ?? '');
|
||||
$showContact = !empty($post['contact_public']);
|
||||
$authorEntries = [];
|
||||
$authorNames = [];
|
||||
if ($authorsRaw !== '') {
|
||||
foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) {
|
||||
if ($name !== '') {
|
||||
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn($n) => $n !== ''));
|
||||
sort($authorNames, SORT_NATURAL);
|
||||
}
|
||||
$authorEntries = [];
|
||||
foreach ($authorNames as $i => $name) {
|
||||
$authorEntries[] = [
|
||||
'name' => $name,
|
||||
'email' => $i === 0 ? ($post['mail'] ?? null) : null,
|
||||
'show_contact' => $i === 0 ? $showContact : false,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||
|
||||
// ── 3. Jury ───────────────────────────────────────────────────────
|
||||
@@ -338,7 +339,11 @@ class ThesisEditController
|
||||
|
||||
$year = (int)($post['année'] ?? date('Y'));
|
||||
$authorName = trim($post['auteurice'] ?? 'unknown');
|
||||
$authorSlug = $this->generateAuthorSlug($authorName);
|
||||
|
||||
// Sort the raw comma-separated string alphabetically, then slugify.
|
||||
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn($n) => $n !== ''));
|
||||
sort($names, SORT_NATURAL);
|
||||
$authorSlug = $this->generateAuthorSlug(implode(', ', $names));
|
||||
|
||||
// Per-file labels and sort orders submitted alongside the upload inputs
|
||||
$fileLabels = $post['file_labels'] ?? [];
|
||||
|
||||
@@ -838,7 +838,7 @@ class Database
|
||||
t.id, t.identifier, t.title, t.subtitle, t.year,
|
||||
o.name as orientation,
|
||||
ap.name as ap_program,
|
||||
GROUP_CONCAT(DISTINCT a.name) as authors,
|
||||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||||
t.submitted_at,
|
||||
t.is_published,
|
||||
at.name as access_type
|
||||
@@ -983,18 +983,35 @@ class Database
|
||||
* @param int $year Proposed year.
|
||||
* @return array{id:int,identifier:string,title:string,author:string,year:int}|null
|
||||
*/
|
||||
public function findDuplicateThesis(string $title, string $authorName, int $year): ?array
|
||||
/**
|
||||
* @param string $title Proposed title.
|
||||
* @param string[] $authorNames Proposed author names (already trimmed, non-empty).
|
||||
* @param int $year Proposed year.
|
||||
* @return array{id:int,identifier:string,title:string,author:string,year:int}|null
|
||||
*/
|
||||
public function findDuplicateThesis(string $title, array $authorNames, int $year): ?array
|
||||
{
|
||||
// Fetch all theses for the same year and author (case-insensitive).
|
||||
if (empty($authorNames)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch all theses for the same year that share any author with the submission.
|
||||
$numAuthors = count($authorNames);
|
||||
$ph = implode(',', array_fill(0, $numAuthors, 'LOWER(TRIM(?))'));
|
||||
$params = array_merge([$year], $authorNames);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT t.id, t.identifier, t.title, a.name AS author, t.year
|
||||
"SELECT DISTINCT t.id, t.identifier, t.title, t.year,
|
||||
GROUP_CONCAT(a2.name ORDER BY ta2.author_order ASC) AS authors
|
||||
FROM theses t
|
||||
JOIN thesis_authors ta ON ta.thesis_id = t.id AND ta.author_order = 1
|
||||
JOIN thesis_authors ta ON ta.thesis_id = t.id
|
||||
JOIN authors a ON a.id = ta.author_id
|
||||
JOIN thesis_authors ta2 ON ta2.thesis_id = t.id
|
||||
JOIN authors a2 ON a2.id = ta2.author_id
|
||||
WHERE t.year = ?
|
||||
AND LOWER(TRIM(a.name)) = LOWER(TRIM(?))'
|
||||
AND LOWER(TRIM(a.name)) IN ({$ph})
|
||||
GROUP BY t.id"
|
||||
);
|
||||
$stmt->execute([$year, $authorName]);
|
||||
$stmt->execute($params);
|
||||
$candidates = $stmt->fetchAll();
|
||||
|
||||
if (empty($candidates)) {
|
||||
@@ -1016,7 +1033,7 @@ class Database
|
||||
'id' => (int)$row['id'],
|
||||
'identifier' => $row['identifier'],
|
||||
'title' => $row['title'],
|
||||
'author' => $row['author'],
|
||||
'author' => $row['authors'],
|
||||
'year' => (int)$row['year'],
|
||||
];
|
||||
}
|
||||
@@ -1033,7 +1050,7 @@ class Database
|
||||
'id' => (int)$row['id'],
|
||||
'identifier' => $row['identifier'],
|
||||
'title' => $row['title'],
|
||||
'author' => $row['author'],
|
||||
'author' => $row['authors'],
|
||||
'year' => (int)$row['year'],
|
||||
];
|
||||
}
|
||||
@@ -1050,7 +1067,7 @@ class Database
|
||||
'id' => (int)$row['id'],
|
||||
'identifier' => $row['identifier'],
|
||||
'title' => $row['title'],
|
||||
'author' => $row['author'],
|
||||
'author' => $row['authors'],
|
||||
'year' => (int)$row['year'],
|
||||
];
|
||||
}
|
||||
@@ -1849,15 +1866,7 @@ class Database
|
||||
$objet,
|
||||
]);
|
||||
|
||||
$thesisId = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Link author — always author_order = 1 for single-author submissions.
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)'
|
||||
);
|
||||
$stmt->execute([$thesisId, (int)$data['author_id']]);
|
||||
|
||||
return $thesisId;
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -527,7 +527,7 @@ SELECT
|
||||
t.is_published,
|
||||
t.baiu_link,
|
||||
t.banner_path,
|
||||
GROUP_CONCAT(DISTINCT a.name) as authors,
|
||||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||||
GROUP_CONCAT(DISTINCT s.name) as supervisors,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs,
|
||||
|
||||
Reference in New Issue
Block a user