Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions app/Controllers/GeneriController.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,24 @@ public function update(Request $request, Response $response, \mysqli $db, int $i
return $response->withHeader('Location', "/admin/genres/{$id}")->withStatus(302);
}

public function destroy(Request $_request, Response $response, \mysqli $db, int $id): Response
public function destroy(Request $request, Response $response, \mysqli $db, int $id): Response
{
// CSRF validated by CsrfMiddleware
$repo = new GenereRepository($db);
$data = $request->getParsedBody() ?? [];
$cascadeDelete = !empty($data['cascade_delete']);

try {
if (!$repo->delete($id)) {
throw new \RuntimeException('delete() returned false');
$deleted = $cascadeDelete
? $repo->cascadeDelete($id)
: $repo->delete($id);
if (!$deleted) {
throw new \RuntimeException(($cascadeDelete ? 'cascadeDelete' : 'delete') . '() returned false');
}

$_SESSION['success_message'] = __('Genere eliminato con successo!');
$_SESSION['success_message'] = $cascadeDelete
? __('Genere e sottogeneri eliminati con successo!')
: __('Genere eliminato con successo!');
return $response->withHeader('Location', url('/admin/genres'))->withStatus(302);
} catch (\Throwable $e) {
\App\Support\SecureLogger::error('GeneriController::destroy error', ['id' => $id, 'message' => $e->getMessage()]);
Expand Down
93 changes: 93 additions & 0 deletions app/Models/GenereRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,99 @@ public function delete(int $id): bool
return $result;
}

public function cascadeDelete(int $id): bool
{
$ids = $this->collectSubtreeIds($id);
if (empty($ids)) {
return false;
}

$this->db->begin_transaction();

try {
$placeholders = implode(',', array_fill(0, count($ids), '?'));

$stmt = $this->db->prepare("
UPDATE libri
SET genere_id = IF(genere_id IN ({$placeholders}), NULL, genere_id),
sottogenere_id = IF(sottogenere_id IN ({$placeholders}), NULL, sottogenere_id)
WHERE genere_id IN ({$placeholders})
OR sottogenere_id IN ({$placeholders})
");
$this->bindIntParams($stmt, array_merge($ids, $ids, $ids, $ids));
if (!$stmt->execute()) {
throw new \RuntimeException('Errore nello scollegamento dei libri dai generi');
}

$stmt = $this->db->prepare("UPDATE mensole SET genere_id = NULL WHERE genere_id IN ({$placeholders})");
$this->bindIntParams($stmt, $ids);
if (!$stmt->execute()) {
throw new \RuntimeException('Errore nello scollegamento delle mensole dai generi');
}

$stmt = $this->db->prepare("DELETE FROM generi WHERE id = ?");
$stmt->bind_param('i', $id);
$result = $stmt->execute();

$this->db->commit();
QueryCache::clearByPrefix('genre_tree_');
return $result;
} catch (\Throwable $e) {
$this->db->rollback();
throw $e;
}
}

/**
* @return array<int, int>
*/
private function collectSubtreeIds(int $id): array
{
$ids = [];
$queue = [$id];
$stmt = $this->db->prepare("SELECT id FROM generi WHERE parent_id = ?");
if (!$stmt) {
throw new \RuntimeException('Errore nella preparazione della query dei sottogeneri');
}

while ($queue) {
$currentId = array_shift($queue);
if (isset($ids[$currentId])) {
continue;
}

$genre = $this->getById($currentId);
if (!$genre) {
continue;
}

$ids[$currentId] = $currentId;
$stmt->bind_param('i', $currentId);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
$queue[] = (int)$row['id'];
}
}

$stmt->close();
return array_values($ids);
}

/**
* @param array<int, int> $values
*/
private function bindIntParams(\mysqli_stmt $stmt, array $values): void
{
$types = str_repeat('i', count($values));
$refs = [$types];
foreach ($values as $key => $value) {
$values[$key] = (int)$value;
$refs[] = &$values[$key];
}
$stmt->bind_param(...$refs);
}

public function getChildren(int $parent_id): array
{
$stmt = $this->db->prepare("
Expand Down
15 changes: 13 additions & 2 deletions app/Views/generi/dettaglio_genere.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,24 @@
</div>

<!-- Delete genre -->
<?php if (empty($children)): ?>
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl shadow-lg border border-red-200/60 dark:border-red-700/60">
<div class="p-6">
<h2 class="text-lg font-semibold text-red-700 dark:text-red-400 flex items-center gap-2 mb-3">
<i class="fas fa-trash-alt"></i>
<?= __("Elimina genere") ?>
</h2>
<?php if (!empty($children)): ?>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4"><?= __("Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.") ?></p>
<form method="post" action="<?= htmlspecialchars(url("/admin/genres/{$genereId}/delete"), ENT_QUOTES, 'UTF-8') ?>"
data-swal-confirm="<?= htmlspecialchars(__('Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.'), ENT_QUOTES, 'UTF-8') ?>"
data-swal-confirm-button="<?= htmlspecialchars(__('Elimina tutto'), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cascade_delete" value="1">
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
<i class="fas fa-trash-alt mr-1"></i><?= __("Elimina genere e sottogeneri") ?>
</button>
</form>
<?php else: ?>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4"><?= __("Questa azione elimina il genere in modo permanente. Possibile solo se non ha sottogeneri e non è usato da nessun libro.") ?></p>
<form method="post" action="<?= htmlspecialchars(url("/admin/genres/{$genereId}/delete"), ENT_QUOTES, 'UTF-8') ?>"
data-swal-confirm="<?= htmlspecialchars(__('Sei sicuro di voler eliminare questo genere?'), ENT_QUOTES, 'UTF-8') ?>"
Expand All @@ -237,9 +248,9 @@
<i class="fas fa-trash-alt mr-1"></i><?= __("Elimina") ?>
</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion locale/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -6565,5 +6565,9 @@
"Controlla i campi personalizzati: un valore inserito non è valido.": "Überprüfe die benutzerdefinierten Felder: einer der eingegebenen Werte ist ungültig.",
"Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Ausgewählte benutzerdefinierte Felder löschen? Die von Benutzern gespeicherten Werte für diese Felder werden dauerhaft entfernt.",
"I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "Die Anwendungsdateien sind für den Webserver-Benutzer nicht beschreibbar und Pinakes läuft in einem Container. Im offiziellen Image (fabiodalez/pinakes) ist der Code im Image enthalten und absichtlich schreibgeschützt: Aktualisiere, indem du das neue Image ziehst mit «docker compose pull && docker compose up -d» (Datenbank und die Volumes storage/uploads bleiben erhalten). Wenn du ein Community-Image verwendest, das den Code aus einem beschreibbaren Volume einbindet, funktioniert diese Schaltfläche normal — erscheint diese Meldung, ist dieses Volume derzeit schreibgeschützt, prüfe dessen Mount.",
"https://t.me/tuocanale": "https://t.me/deinkanal"
"https://t.me/tuocanale": "https://t.me/deinkanal",
"Genere e sottogeneri eliminati con successo!": "Genre und Untergenres erfolgreich gelöscht!",
"Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Diese Aktion löscht das Genre und alle seine Untergenres dauerhaft. Zugeordnete Bücher werden von diesen Genres getrennt.",
"Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sind Sie sicher, dass Sie dieses Genre und alle seine Untergenres löschen möchten? Zugeordnete Bücher werden von diesen Genres getrennt.",
"Elimina genere e sottogeneri": "Genre und Untergenres löschen"
}
6 changes: 5 additions & 1 deletion locale/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -6565,5 +6565,9 @@
"Controlla i campi personalizzati: un valore inserito non è valido.": "Check the custom fields: one of the values entered is not valid.",
"Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Delete the selected custom fields? The values users saved for these fields will be permanently removed.",
"I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "The application files aren't writable by the web-server user and Pinakes is running inside a container. On the official image (fabiodalez/pinakes) the code is baked into the image and read-only by design: update by pulling the new image with «docker compose pull && docker compose up -d» (your database and the storage/uploads volumes are preserved). If you use a community image that mounts the code from a writable volume this button works normally — seeing this message means that volume is currently read-only, so check its mount.",
"https://t.me/tuocanale": "https://t.me/yourchannel"
"https://t.me/tuocanale": "https://t.me/yourchannel",
"Genere e sottogeneri eliminati con successo!": "Genre and subgenres deleted successfully!",
"Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "This action permanently deletes the genre and all of its subgenres. Associated books will be unlinked from these genres.",
"Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Are you sure you want to delete this genre and all of its subgenres? Associated books will be unlinked from these genres.",
"Elimina genere e sottogeneri": "Delete genre and subgenres"
}
6 changes: 5 additions & 1 deletion locale/fr_FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -6565,5 +6565,9 @@
"Controlla i campi personalizzati: un valore inserito non è valido.": "Vérifiez les champs personnalisés : une des valeurs saisies n'est pas valide.",
"Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Supprimer les champs personnalisés sélectionnés ? Les valeurs enregistrées par les utilisateurs pour ces champs seront définitivement supprimées.",
"I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "Les fichiers de l'application ne sont pas modifiables par l'utilisateur du serveur web et Pinakes s'exécute dans un conteneur. Sur l'image officielle (fabiodalez/pinakes) le code est inclus dans l'image et en lecture seule par choix : mettez à jour en récupérant la nouvelle image avec «docker compose pull && docker compose up -d» (la base de données et les volumes storage/uploads sont préservés). Si vous utilisez une image communautaire qui monte le code depuis un volume accessible en écriture, ce bouton fonctionne normalement — si ce message apparaît, ce volume est actuellement en lecture seule, vérifiez son montage.",
"https://t.me/tuocanale": "https://t.me/votrecanal"
"https://t.me/tuocanale": "https://t.me/votrecanal",
"Genere e sottogeneri eliminati con successo!": "Genre et sous-genres supprimés avec succès !",
"Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Cette action supprime définitivement le genre et tous ses sous-genres. Les livres associés seront dissociés de ces genres.",
"Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Voulez-vous vraiment supprimer ce genre et tous ses sous-genres ? Les livres associés seront dissociés de ces genres.",
"Elimina genere e sottogeneri": "Supprimer le genre et les sous-genres"
}
6 changes: 5 additions & 1 deletion locale/it_IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -6565,5 +6565,9 @@
"Controlla i campi personalizzati: un valore inserito non è valido.": "Controlla i campi personalizzati: un valore inserito non è valido.",
"Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.",
"I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.",
"https://t.me/tuocanale": "https://t.me/tuocanale"
"https://t.me/tuocanale": "https://t.me/tuocanale",
"Genere e sottogeneri eliminati con successo!": "Genere e sottogeneri eliminati con successo!",
"Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.",
"Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.",
"Elimina genere e sottogeneri": "Elimina genere e sottogeneri"
}
11 changes: 6 additions & 5 deletions tests/genre-bugs.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,28 +114,29 @@ test.describe('Issue #64: Genre Edit/Update', () => {
expect([401, 403]).toContain(resp.status());
});

test('delete section visible only for leaf genres (no children)', async ({ page }) => {
test('delete section supports cascade for genre groups', async ({ page }) => {
await loginAsAdmin(page);

// Get a root genre — should NOT show delete (has children)
// Get a root genre — should show cascade delete (has children)
const rootResp = await page.request.get(`${BASE}/api/generi?only_parents=1&limit=5`);
const roots = await rootResp.json();
const rootWithChildren = roots.find(g => g.children_count > 0);
if (rootWithChildren) {
await page.goto(`${BASE}/admin/genres/${rootWithChildren.id}`);
// Delete button should NOT be visible for genres with children
const deleteForm = page.locator('form[action*="/delete"]');
await expect(deleteForm).toBeHidden();
await expect(deleteForm).toBeVisible();
await expect(deleteForm.locator('input[name="cascade_delete"]')).toHaveValue('1');
}

// Get a leaf genre (no children) — should show delete
// Get a leaf genre (no children) — should show normal delete
const allResp = await page.request.get(`${BASE}/api/generi?limit=100`);
const allGenres = await allResp.json();
const leafGenre = allGenres.find(g => g.children_count === 0);
if (leafGenre) {
await page.goto(`${BASE}/admin/genres/${leafGenre.id}`);
const deleteForm = page.locator('form[action*="/delete"]');
await expect(deleteForm).toBeVisible();
await expect(deleteForm.locator('input[name="cascade_delete"]')).toHaveCount(0);
}
});
});
Expand Down
Loading