Actualiser api/index.php
CI / PHP Syntax Check (push) Has been cancelled
CI / JavaScript Lint (push) Has been cancelled
CI / Docker Build Test (push) Has been cancelled
CI / Validate Translation Files (push) Has been cancelled
Security Scan (Trivy) / Trivy — Docker image scan (push) Has been cancelled
Security Scan (Trivy) / Trivy — Filesystem scan (push) Has been cancelled
CI / Auto-merge develop → main (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled

This commit is contained in:
2026-06-27 16:25:46 +00:00
parent 3bfa89e61d
commit c513b0b4ef
+239 -1
View File
@@ -1111,7 +1111,12 @@ try {
case 'db_cleanup': case 'db_cleanup':
dbCleanup(getDB()); dbCleanup(getDB());
break; break;
case 'export_full':
exportFullBackup($db);
break;
case 'import_merge':
importMergeBackup($db);
break;
case 'backup_now': case 'backup_now':
echo json_encode(createLocalBackup($db)); echo json_encode(createLocalBackup($db));
break; break;
@@ -8911,6 +8916,239 @@ function restoreLocalBackup(string $filename, PDO $db): array {
return ['success' => true, 'message' => 'Restore complete — reload the page to see the restored data.']; return ['success' => true, 'message' => 'Restore complete — reload the page to see the restored data.'];
} }
/**
* Export complet : zip contenant evershelf.db + .env (config).
*/
function exportFullBackup(PDO $db): void {
EverLog::info('exportFullBackup');
try { $db->exec('PRAGMA wal_checkpoint(FULL)'); } catch (Throwable $e) {}
$dbPath = __DIR__ . '/../data/evershelf.db';
$envPath = __DIR__ . '/../.env';
$tmpZip = sys_get_temp_dir() . '/evershelf_export_' . uniqid() . '.zip';
$zip = new ZipArchive();
if ($zip->open($tmpZip, ZipArchive::CREATE) !== true) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create zip']);
return;
}
$zip->addFile($dbPath, 'evershelf.db');
if (file_exists($envPath)) {
$zip->addFile($envPath, '.env');
}
$zip->close();
$filename = 'evershelf_export_' . date('Y-m-d_Hi') . '.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($tmpZip));
readfile($tmpZip);
@unlink($tmpZip);
exit;
}
/**
* Import avec fusion intelligente (sans doublons) depuis un export_full.
* - Tables référentielles : skip si key existe déjà.
* - products : skip si barcode ou nom existe déjà, sinon insert + mapping ancien_id -> nouvel_id.
* - inventory / transactions / chat_messages : toujours insérés, product_id remappé.
* - .env du zip est ignoré (jamais écrasé automatiquement).
*/
function importMergeBackup(PDO $db): void {
EverLog::info('importMergeBackup');
if (empty($_FILES['file']['tmp_name'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded']);
return;
}
$tmpDir = sys_get_temp_dir() . '/evershelf_import_' . uniqid();
mkdir($tmpDir, 0755, true);
$zip = new ZipArchive();
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
echo json_encode(['success' => false, 'error' => 'Invalid zip file']);
return;
}
$zip->extractTo($tmpDir);
$zip->close();
$srcDbPath = $tmpDir . '/evershelf.db';
if (!file_exists($srcDbPath)) {
echo json_encode(['success' => false, 'error' => 'evershelf.db not found in zip']);
return;
}
$src = new PDO('sqlite:' . $srcDbPath);
$src->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$stats = ['products' => 0, 'inventory' => 0, 'transactions' => 0, 'recipes' => 0,
'recipe_library' => 0, 'chat_messages' => 0, 'shopping_list' => 0,
'categories' => 0, 'subcategories' => 0, 'locations' => 0,
'recipe_tags' => 0, 'custom_units' => 0];
$db->beginTransaction();
try {
// --- 1. Tables référentielles simples : skip si key existe ---
$refTables = [
'categories' => ['key'],
'locations' => ['key'],
'recipe_tags' => ['key'],
'custom_units' => ['key'],
];
foreach ($refTables as $table => $uniqueCols) {
$rows = $src->query("SELECT * FROM {$table}")->fetchAll();
foreach ($rows as $row) {
unset($row['id']);
$where = [];
$params = [];
foreach ($uniqueCols as $c) { $where[] = "$c = ?"; $params[] = $row[$c]; }
$exists = $db->prepare("SELECT 1 FROM {$table} WHERE " . implode(' AND ', $where));
$exists->execute($params);
if ($exists->fetch()) continue;
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO {$table} (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats[$table]++;
}
}
// subcategories : unique (category, key)
$rows = $src->query("SELECT * FROM subcategories")->fetchAll();
foreach ($rows as $row) {
unset($row['id']);
$exists = $db->prepare("SELECT 1 FROM subcategories WHERE category = ? AND key = ?");
$exists->execute([$row['category'], $row['key']]);
if ($exists->fetch()) continue;
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO subcategories (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['subcategories']++;
}
// --- 2. products : skip si barcode (non vide) ou nom existe déjà, sinon insert + map id ---
$productMap = []; // old_id => new_id
$rows = $src->query("SELECT * FROM products")->fetchAll();
foreach ($rows as $row) {
$oldId = (int)$row['id'];
$barcode = trim((string)($row['barcode'] ?? ''));
$existingId = null;
if ($barcode !== '') {
$stmt = $db->prepare("SELECT id FROM products WHERE barcode = ?");
$stmt->execute([$barcode]);
$existingId = $stmt->fetchColumn() ?: null;
}
if (!$existingId) {
$stmt = $db->prepare("SELECT id FROM products WHERE lower(trim(name)) = lower(trim(?))");
$stmt->execute([$row['name']]);
$existingId = $stmt->fetchColumn() ?: null;
}
if ($existingId) {
$productMap[$oldId] = (int)$existingId;
continue;
}
unset($row['id']);
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO products (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$productMap[$oldId] = (int)$db->lastInsertId();
$stats['products']++;
}
// --- 3. inventory : toujours inséré, product_id remappé ---
$rows = $src->query("SELECT * FROM inventory")->fetchAll();
foreach ($rows as $row) {
$newPid = $productMap[(int)$row['product_id']] ?? null;
if (!$newPid) continue;
unset($row['id']);
$row['product_id'] = $newPid;
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO inventory (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['inventory']++;
}
// --- 4. transactions : toujours insérées, product_id remappé ---
$rows = $src->query("SELECT * FROM transactions")->fetchAll();
foreach ($rows as $row) {
$newPid = $productMap[(int)$row['product_id']] ?? null;
if (!$newPid) continue;
unset($row['id']);
$row['product_id'] = $newPid;
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO transactions (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['transactions']++;
}
// --- 5. recipes : skip si (date, meal) existe ---
$rows = $src->query("SELECT * FROM recipes")->fetchAll();
foreach ($rows as $row) {
$exists = $db->prepare("SELECT 1 FROM recipes WHERE date = ? AND meal = ?");
$exists->execute([$row['date'], $row['meal']]);
if ($exists->fetch()) continue;
unset($row['id']);
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO recipes (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['recipes']++;
}
// --- 6. recipe_library : skip si title existe ---
$rows = $src->query("SELECT * FROM recipe_library")->fetchAll();
foreach ($rows as $row) {
$exists = $db->prepare("SELECT 1 FROM recipe_library WHERE title = ?");
$exists->execute([$row['title']]);
if ($exists->fetch()) continue;
unset($row['id']);
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO recipe_library (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['recipe_library']++;
}
// --- 7. chat_messages : toujours insérés ---
$rows = $src->query("SELECT * FROM chat_messages")->fetchAll();
foreach ($rows as $row) {
unset($row['id']);
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO chat_messages (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['chat_messages']++;
}
// --- 8. shopping_list : skip si lower(name) existe ---
$rows = $src->query("SELECT * FROM shopping_list")->fetchAll();
foreach ($rows as $row) {
$exists = $db->prepare("SELECT 1 FROM shopping_list WHERE lower(name) = lower(?)");
$exists->execute([$row['name']]);
if ($exists->fetch()) continue;
unset($row['id']);
$cols = array_keys($row);
$stmt = $db->prepare("INSERT INTO shopping_list (" . implode(',', $cols) . ") VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")");
$stmt->execute(array_values($row));
$stats['shopping_list']++;
}
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
return;
} finally {
@unlink($srcDbPath);
@unlink($tmpDir . '/.env');
@rmdir($tmpDir);
}
echo json_encode(['success' => true, 'stats' => $stats], JSON_UNESCAPED_UNICODE);
}
// ===== GOOGLE DRIVE BACKUP ===== // ===== GOOGLE DRIVE BACKUP =====
/** Write / overwrite a single key in the .env file (used by OAuth callback). */ /** Write / overwrite a single key in the .env file (used by OAuth callback). */