diff --git a/api/index.php b/api/index.php index 15ce0bc..770f3d1 100644 --- a/api/index.php +++ b/api/index.php @@ -1111,7 +1111,12 @@ try { case 'db_cleanup': dbCleanup(getDB()); break; - + case 'export_full': + exportFullBackup($db); + break; + case 'import_merge': + importMergeBackup($db); + break; case 'backup_now': echo json_encode(createLocalBackup($db)); 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.']; } +/** + * 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 ===== /** Write / overwrite a single key in the .env file (used by OAuth callback). */