diff --git a/README.md b/README.md index c6014ca..1b3dbe2 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,24 @@ The included `backup.sh` creates local daily backups of your database: 0 3 * * * /path/to/evershelf/backup.sh ``` +### Google Drive Backup (Optional) + +EverShelf supports automatic daily backups to Google Drive via OAuth 2.0. This works on any server, including private IP / local network setups (no public domain required). + +**Setup:** + +1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select or create a project. +2. Enable the **Google Drive API** (`APIs & Services → Enable APIs → Google Drive API`). +3. Go to `APIs & Services → Credentials → Create Credentials → OAuth client ID`. +4. Application type: **Web application**. +5. Add **`http://localhost`** as an Authorized Redirect URI (this is the key — it works even without a real domain). +6. Copy **Client ID** and **Client Secret** into EverShelf Settings → Backup. +7. Enter your **Google Drive Folder ID** (the last part of the folder URL). +8. Click **Authorize with Google** and sign in. +9. The browser will redirect to `http://localhost` and may show a connection error — **this is expected**. Copy the full URL from the address bar (e.g. `http://localhost/?code=4%2F0A...`) and paste it into the field that appears in EverShelf, then click **Submit**. + +> **Note:** While the OAuth app is in *Testing* status in Google Cloud Console, you must add your Google account as a test user under `APIs & Services → OAuth consent screen → Test users`. + --- ## 🏗️ Architecture diff --git a/api/index.php b/api/index.php index 5749c3c..4fce5a5 100644 --- a/api/index.php +++ b/api/index.php @@ -23,6 +23,8 @@ define('FOODFACTS_CACHE_PATH', __DIR__ . '/../data/food_facts_cache.json'); define('SHOPPING_NAME_CACHE_PATH', __DIR__ . '/../data/shopping_name_cache.json'); define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json'); define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.json'); +define('BACKUP_DIR', __DIR__ . '/../data/backups'); +define('BACKUP_LAST_TS_PATH', __DIR__ . '/../data/backup_last_ts.json'); // Gemini pricing (USD per 1M tokens) — configurable in .env (GEMINI_COST_25F_IN etc.) // Defaults: gemini-2.5-flash $0.15/M in · $0.60/M out — gemini-2.0-flash $0.10/M in · $0.40/M out define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15)); @@ -119,6 +121,12 @@ if (($_GET['action'] ?? '') === 'ping') { exit; } +// ── Google Drive OAuth callback — returns HTML, not JSON ────────────────────── +if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') { + _gdriveHandleOAuthCallback(); + exit; +} + // ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ──── if (($_GET['action'] ?? '') === 'get_logs') { require_once __DIR__ . '/logger.php'; @@ -690,6 +698,7 @@ try { 'save_settings', 'product_save', 'product_delete', 'product_merge', 'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove', 'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync', + 'backup_delete', 'backup_restore', ]; if (in_array($action, $demoBlocked, true)) { EverLog::warn('demo_mode blocked (403)'); @@ -908,6 +917,98 @@ try { dbCleanup(getDB()); break; + case 'backup_now': + echo json_encode(createLocalBackup($db)); + break; + case 'backup_list': + echo json_encode(listLocalBackups()); + break; + case 'backup_delete': + $fn = json_decode(file_get_contents('php://input'), true)['filename'] ?? ''; + echo json_encode(deleteLocalBackup($fn)); + break; + case 'backup_restore': + $fn = json_decode(file_get_contents('php://input'), true)['filename'] ?? ''; + echo json_encode(restoreLocalBackup($fn, $db)); + break; + case 'gdrive_push': + echo json_encode(backupToGDrive($db)); + break; + case 'gdrive_test': + $tokResult = _gdriveGetTokenEx(); + if (!empty($tokResult['token'])) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => $tokResult['error'] ?? 'Auth failed']); + } + break; + case 'gdrive_oauth_url': + $clientId = env('GDRIVE_CLIENT_ID', ''); + if (empty($clientId)) { + echo json_encode(['success' => false, 'error' => 'GDRIVE_CLIENT_ID not configured — save settings first']); + } else { + // Use http://localhost so the flow works on any self-hosted server (IP, local domain, etc.). + // Google will redirect to http://localhost?code=... after auth; user copies and pastes the URL. + // Override via GDRIVE_REDIRECT_URI env var for installations with a real public domain. + $redirectUri = env('GDRIVE_REDIRECT_URI', '') ?: 'http://localhost'; + $url = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'scope' => 'https://www.googleapis.com/auth/drive.file', + 'response_type' => 'code', + 'access_type' => 'offline', + 'prompt' => 'consent', + ]); + echo json_encode(['success' => true, 'url' => $url, 'redirect_uri' => $redirectUri]); + } + break; + + case 'gdrive_oauth_exchange': + // Manual code exchange: accepts {code, redirect_uri} from the JS after user copies URL. + $_exchangeBody = json_decode(file_get_contents('php://input'), true) ?? []; + $code = trim($_exchangeBody['code'] ?? ''); + $redirectUri = trim($_exchangeBody['redirect_uri'] ?? '') ?: (env('GDRIVE_REDIRECT_URI', '') ?: 'http://localhost'); + if (empty($code)) { + echo json_encode(['success' => false, 'error' => 'No authorization code provided']); + break; + } + $clientId = env('GDRIVE_CLIENT_ID', ''); + $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); + if (!$clientId || !$clientSecret) { + echo json_encode(['success' => false, 'error' => 'Client ID/Secret not configured — save settings first']); + break; + } + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code', + ]), + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $gdriveExResp = curl_exec($ch); + $gdriveExErr = curl_error($ch); + curl_close($ch); + if (!$gdriveExResp) { + echo json_encode(['success' => false, 'error' => 'cURL error: ' . $gdriveExErr]); + break; + } + $gdriveExData = json_decode($gdriveExResp, true); + if (!empty($gdriveExData['refresh_token'])) { + _gdriveSetEnvVar('GDRIVE_REFRESH_TOKEN', $gdriveExData['refresh_token']); + echo json_encode(['success' => true]); + } else { + $errDesc = $gdriveExData['error_description'] ?? $gdriveExData['error'] ?? $gdriveExResp; + echo json_encode(['success' => false, 'error' => 'Token exchange failed: ' . $errDesc]); + } + break; + case 'gemini_product_hint': geminiProductHint(); break; @@ -2145,7 +2246,12 @@ function updateInventory(PDO $db): void { $stmt = $db->prepare("UPDATE products SET package_unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$input['package_unit'], $input['package_size'] ?? 0, $input['product_id']]); } - + + // Real-time Bring! sync: if quantity changed, keep Bring! in sync immediately + if (isset($input['quantity']) && $prevRow && abs((float)$input['quantity'] - (float)$prevRow['quantity']) > 0.001) { + try { bringQuickSyncProduct($db, (int)$prevRow['product_id']); } catch (Throwable $e) {} + } + echo json_encode(['success' => true]); } @@ -2915,14 +3021,24 @@ function getServerSettings(): void { 'price_currency' => env('PRICE_CURRENCY', 'EUR'), 'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'), 'recipe_retention_days' => (int)env('RECIPE_RETENTION_DAYS', '7'), - 'transaction_retention_days' => (int)env('TRANSACTION_RETENTION_DAYS', '7'), + 'transaction_retention_days' => (int)env('TRANSACTION_RETENTION_DAYS', '90'), 'vacuum_expiry_extension_days' => (int)env('VACUUM_EXPIRY_EXTENSION_DAYS', '30'), + // Backup + 'backup_enabled' => env('BACKUP_ENABLED', 'true') === 'true', + 'backup_retention_days' => (int)env('BACKUP_RETENTION_DAYS', '3'), + 'gdrive_enabled' => env('GDRIVE_ENABLED', 'false') === 'true', + 'gdrive_folder_id' => env('GDRIVE_FOLDER_ID', ''), + 'gdrive_retention_days' => (int)env('GDRIVE_RETENTION_DAYS', '30'), + 'gdrive_client_id_set' => !empty(env('GDRIVE_CLIENT_ID')), + 'gdrive_refresh_token_set'=> !empty(env('GDRIVE_REFRESH_TOKEN')), ]); } function dbCleanup(?PDO $db = null): void { $recipeDays = max(1, (int)env('RECIPE_RETENTION_DAYS', '7')); - $txDays = max(1, (int)env('TRANSACTION_RETENTION_DAYS', '7')); + // Minimum 90 days: smart shopping needs months of history to compute frequencies. + // A value below 30 will cause the shopping list to appear nearly empty. + $txDays = max(30, (int)env('TRANSACTION_RETENTION_DAYS', '90')); $pdo = $db ?? getDB(); try { // Delete old recipes (generated recipe plans) @@ -2976,6 +3092,9 @@ function saveSettings(): void { 'tts_auth_header_name' => 'TTS_AUTH_HEADER_NAME', 'tts_auth_header_value' => 'TTS_AUTH_HEADER_VALUE', 'tts_extra_fields' => 'TTS_EXTRA_FIELDS', + 'gdrive_folder_id' => 'GDRIVE_FOLDER_ID', + 'gdrive_client_id' => 'GDRIVE_CLIENT_ID', + 'gdrive_client_secret' => 'GDRIVE_CLIENT_SECRET', ]; // Boolean keys $boolMap = [ @@ -2991,6 +3110,8 @@ function saveSettings(): void { 'screensaver_enabled' => 'SCREENSAVER_ENABLED', 'price_enabled' => 'PRICE_ENABLED', 'zerowaste_tips_enabled' => 'ZEROWASTE_TIPS_ENABLED', + 'backup_enabled' => 'BACKUP_ENABLED', + 'gdrive_enabled' => 'GDRIVE_ENABLED', ]; // Integer keys $intMap = [ @@ -3000,6 +3121,8 @@ function saveSettings(): void { 'recipe_retention_days' => 'RECIPE_RETENTION_DAYS', 'transaction_retention_days' => 'TRANSACTION_RETENTION_DAYS', 'vacuum_expiry_extension_days'=> 'VACUUM_EXPIRY_EXTENSION_DAYS', + 'backup_retention_days' => 'BACKUP_RETENTION_DAYS', + 'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS', ]; // Float keys $floatMap = [ @@ -3031,7 +3154,7 @@ function saveSettings(): void { if (array_key_exists('appliances', $input)) { $envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances']; } - + // Write .env file $lines = []; foreach ($envVars as $key => $val) { @@ -6108,6 +6231,428 @@ function computeShoppingName(string $name, string $category = '', string $brand return ucfirst($name); } +/** + * Real-time Bring! sync for a single product. + * Called after inventory changes (use/update/add) to keep Bring! in sync immediately + * instead of waiting for the next cron cycle. + */ +function bringQuickSyncProduct(PDO $db, int $productId): void { + $stmt = $db->prepare("SELECT SUM(quantity) FROM inventory WHERE product_id = ? AND quantity > 0"); + $stmt->execute([$productId]); + $totalQty = (float)($stmt->fetchColumn() ?: 0); + + $auth = bringAuth(); + if (!$auth) return; + $listUUID = $auth['bringListUUID']; + + $stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?"); + $stmt->execute([$productId]); + $prod = $stmt->fetch(); + if (!$prod) return; + + $genericName = $prod['shopping_name'] ?: computeShoppingName($prod['name'], '', $prod['brand']); + $bringName = italianToBring($genericName); + + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if (!$listData || !isset($listData['purchase'])) return; + + $onBring = false; + foreach ($listData['purchase'] as $item) { + if (strcasecmp($item['name'] ?? '', $bringName) === 0) { $onBring = true; break; } + } + + if ($totalQty <= 0 && !$onBring) { + // Out of stock — add to Bring! + $spec = $genericName !== $prod['name'] + ? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') . ' · 🛒 Esaurito' + : ($prod['brand'] ? $prod['brand'] . ' · ' : '') . '🛒 Esaurito'; + bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", + http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec])); + EverLog::info('bringQuickSync: added to Bring!', ['product_id' => $productId, 'name' => $bringName]); + } elseif ($totalQty > 0 && $onBring) { + // Back in stock — remove from Bring! + bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", + http_build_query(['uuid' => $listUUID, 'remove' => $bringName])); + EverLog::info('bringQuickSync: removed from Bring!', ['product_id' => $productId, 'name' => $bringName]); + } +} + +// ===== LOCAL BACKUP ===== + +/** + * Create a timestamped local backup of evershelf.db. + * WAL-checkpointed before copy. Purges backups older than BACKUP_RETENTION_DAYS. + */ +function createLocalBackup(?PDO $db = null): array { + EverLog::info('createLocalBackup'); + $backupDir = BACKUP_DIR; + if (!is_dir($backupDir) && !mkdir($backupDir, 0755, true)) { + return ['success' => false, 'error' => 'Cannot create backup directory']; + } + + $dbFile = __DIR__ . '/../data/evershelf.db'; + if (!file_exists($dbFile)) { + return ['success' => false, 'error' => 'Database file not found']; + } + + // WAL checkpoint: flush WAL into main DB file before copying + try { + $pdo = $db ?? getDB(); + $pdo->exec('PRAGMA wal_checkpoint(FULL)'); + } catch (Throwable $e) { /* non-fatal */ } + + $date = date('Y-m-d_Hi'); + $filename = "evershelf_{$date}.db"; + $destPath = "$backupDir/$filename"; + + if (!copy($dbFile, $destPath)) { + return ['success' => false, 'error' => 'Failed to copy database file']; + } + + // Purge local backups older than retention + $retentionDays = max(1, (int)env('BACKUP_RETENTION_DAYS', '3')); + $cutoff = strtotime("-{$retentionDays} days"); + $purged = 0; + foreach (glob("$backupDir/evershelf_*.db") ?: [] as $f) { + if ($f !== $destPath && filemtime($f) < $cutoff) { + unlink($f); + $purged++; + } + } + + $sizeKb = (int)round(filesize($destPath) / 1024); + $result = [ + 'success' => true, + 'filename' => $filename, + 'path' => $destPath, + 'size_kb' => $sizeKb, + 'purged' => $purged, + 'created_at' => date('c'), + ]; + + // Update last-backup timestamp file + file_put_contents(BACKUP_LAST_TS_PATH, json_encode(['ts' => time(), 'filename' => $filename, 'size_kb' => $sizeKb])); + + return $result; +} + +/** + * List local backup files with metadata. + */ +function listLocalBackups(): array { + $backupDir = BACKUP_DIR; + $backups = []; + foreach (glob("$backupDir/evershelf_*.db") ?: [] as $f) { + $backups[] = [ + 'filename' => basename($f), + 'size_kb' => (int)round(filesize($f) / 1024), + 'created_at' => date('c', filemtime($f)), + ]; + } + usort($backups, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); + + $lastTs = []; + if (file_exists(BACKUP_LAST_TS_PATH)) { + $lastTs = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: []; + } + + return [ + 'success' => true, + 'backups' => $backups, + 'last_backup_ts' => $lastTs['ts'] ?? null, + 'last_backup_file'=> $lastTs['filename'] ?? null, + 'retention_days' => max(1, (int)env('BACKUP_RETENTION_DAYS', '3')), + ]; +} + +/** + * Delete a specific local backup file. + */ +function deleteLocalBackup(string $filename): array { + if (!preg_match('/^evershelf_\d{4}-\d{2}-\d{2}_\d{4}\.db$/', $filename)) { + return ['success' => false, 'error' => 'Invalid backup filename']; + } + $path = BACKUP_DIR . '/' . $filename; + if (!file_exists($path)) { + return ['success' => false, 'error' => 'File not found']; + } + return unlink($path) ? ['success' => true] : ['success' => false, 'error' => 'Failed to delete file']; +} + +/** + * Restore a local backup: replaces the current evershelf.db. + * Clears WAL/SHM files and invalidates smart shopping cache. + */ +function restoreLocalBackup(string $filename, PDO $db): array { + if (!preg_match('/^evershelf_\d{4}-\d{2}-\d{2}_\d{4}\.db$/', $filename)) { + return ['success' => false, 'error' => 'Invalid backup filename']; + } + $backupPath = BACKUP_DIR . '/' . $filename; + if (!file_exists($backupPath)) { + return ['success' => false, 'error' => 'Backup file not found']; + } + $dbPath = __DIR__ . '/../data/evershelf.db'; + + // Flush WAL before replacing DB + try { $db->exec('PRAGMA wal_checkpoint(FULL)'); } catch (Throwable $e) {} + + if (!copy($backupPath, $dbPath)) { + return ['success' => false, 'error' => 'Failed to restore backup']; + } + // Remove stale WAL/SHM so next connection starts clean + @unlink($dbPath . '-wal'); + @unlink($dbPath . '-shm'); + // Invalidate dependent caches + @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); + + EverLog::info('restoreLocalBackup', ['filename' => $filename]); + return ['success' => true, 'message' => 'Restore complete — reload the page to see the restored data.']; +} + +// ===== GOOGLE DRIVE BACKUP ===== + +/** Write / overwrite a single key in the .env file (used by OAuth callback). */ +function _gdriveSetEnvVar(string $key, string $value): void { + $envFile = __DIR__ . '/../.env'; + $envVars = loadEnv(); + $envVars[$key] = $value; + $lines = []; + foreach ($envVars as $k => $v) { $lines[] = "$k=$v"; } + file_put_contents($envFile, implode("\n", $lines) . "\n"); +} + +/** + * Build the OAuth 2.0 redirect URI for the server-side callback. + * Used only for _gdriveHandleOAuthCallback (legacy flow). + * The interactive auth URL now uses GDRIVE_REDIRECT_URI or http://localhost instead. + */ +function _gdriveRedirectUri(): string { + $override = env('GDRIVE_REDIRECT_URI', ''); + if (!empty($override)) return $override; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + return "$scheme://$host/api/index.php?action=gdrive_oauth_callback"; +} + +/** + * Get an access token using a stored OAuth 2.0 refresh token. + */ +function _gdriveGetTokenOAuth(): array { + $clientId = env('GDRIVE_CLIENT_ID', ''); + $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); + $refreshToken = env('GDRIVE_REFRESH_TOKEN', ''); + if (!$clientId || !$clientSecret) { + return ['error' => 'GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET are required for OAuth']; + } + if (!$refreshToken) { + return ['error' => 'Not authorized yet — click "Authorize with Google" first']; + } + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]), + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $curlErr = curl_error($ch); + curl_close($ch); + if (!$response) return ['error' => 'cURL failed: ' . $curlErr]; + $data = json_decode($response, true); + if (!empty($data['access_token'])) return ['token' => $data['access_token']]; + return ['error' => 'OAuth refresh error: ' . ($data['error_description'] ?? $data['error'] ?? $response)]; +} + +/** + * Handle the OAuth 2.0 callback: exchange the code for tokens, store refresh_token. + * Returns HTML (not JSON) — must be called before Content-Type header is sent. + */ +function _gdriveHandleOAuthCallback(): void { + $code = $_GET['code'] ?? ''; + if (empty($code)) { + http_response_code(400); + header('Content-Type: text/html; charset=utf-8'); + echo '

❌ Error

No authorization code received.

'; + return; + } + $clientId = env('GDRIVE_CLIENT_ID', ''); + $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); + $redirectUri = _gdriveRedirectUri(); + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code', + ]), + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + curl_close($ch); + $data = json_decode($response, true); + header('Content-Type: text/html; charset=utf-8'); + if (!empty($data['refresh_token'])) { + _gdriveSetEnvVar('GDRIVE_REFRESH_TOKEN', $data['refresh_token']); + echo 'EverShelf ✔' + . '

✔ Google Drive Authorized!

' + . '

EverShelf can now back up to your Google Drive.

' + . '

This tab will close automatically.

' + . '' + . ''; + } else { + $err = htmlspecialchars($data['error_description'] ?? $data['error'] ?? 'Unknown error'); + http_response_code(400); + echo "

❌ Authorization failed

$err

"; + } +} + +/** + * Obtain a short-lived Google API access token via OAuth 2.0 refresh token. + * Returns ['token' => string] on success, ['error' => string] on failure. + */ +function _gdriveGetToken(): ?string { return _gdriveGetTokenOAuth()['token'] ?? null; } +function _gdriveGetTokenEx(): array { return _gdriveGetTokenOAuth(); } + +/** + * Upload a file to Google Drive using multipart upload. + * Returns the Drive file ID on success, null on failure. + */ +/** Returns ['id' => string] on success or ['error' => string] on failure. */ +function _gdriveUploadFile(string $token, string $folderId, string $filePath, string $remoteName): array { + if (!file_exists($filePath)) return ['error' => 'Local backup file not found: ' . $filePath]; + $mimeType = 'application/x-sqlite3'; + $metadata = json_encode(['name' => $remoteName, 'parents' => [$folderId]]); + $fileContent = file_get_contents($filePath); + $boundary = 'es_backup_' . bin2hex(random_bytes(8)); + $body = "--$boundary\r\n" + . "Content-Type: application/json; charset=UTF-8\r\n\r\n" + . $metadata . "\r\n" + . "--$boundary\r\n" + . "Content-Type: $mimeType\r\n\r\n" + . $fileContent . "\r\n" + . "--$boundary--"; + + $ch = curl_init('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer $token", + "Content-Type: multipart/related; boundary=$boundary", + "Content-Length: " . strlen($body), + ], + CURLOPT_TIMEOUT => 120, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $curlErr = curl_error($ch); + curl_close($ch); + if (!$response) return ['error' => 'cURL upload failed: ' . $curlErr]; + $data = json_decode($response, true); + if (!empty($data['id'])) return ['id' => $data['id']]; + $apiErr = $data['error']['message'] ?? $data['error']['status'] ?? json_encode($data); + return ['error' => 'Drive API error: ' . $apiErr]; +} + +/** + * Delete Drive backups older than $retentionDays. + * Returns count of deleted files. + */ +function _gdrivePurgeOld(string $token, string $folderId, int $retentionDays): int { + if ($retentionDays <= 0) return 0; + $cutoff = date('c', strtotime("-{$retentionDays} days")); + $q = "'$folderId' in parents and name contains 'evershelf_' and trashed=false"; + $url = 'https://www.googleapis.com/drive/v3/files?' + . http_build_query(['q' => $q, 'fields' => 'files(id,name,createdTime)', 'pageSize' => '1000']); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + curl_close($ch); + if (!$response) return 0; + $data = json_decode($response, true); + $deleted = 0; + foreach ($data['files'] ?? [] as $file) { + if (!empty($file['createdTime']) && $file['createdTime'] < $cutoff) { + $ch = curl_init("https://www.googleapis.com/drive/v3/files/{$file['id']}"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], + CURLOPT_TIMEOUT => 15, + ]); + curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code === 204) $deleted++; + } + } + return $deleted; +} + +/** + * Full backup flow: create local snapshot, upload to Google Drive, purge old Drive files. + */ +function backupToGDrive(?PDO $db = null): array { + EverLog::info('backupToGDrive'); + if (env('GDRIVE_ENABLED', 'false') !== 'true') { + return ['success' => false, 'error' => 'Google Drive backup is not enabled']; + } + $folderId = env('GDRIVE_FOLDER_ID', ''); + if (empty($folderId)) { + return ['success' => false, 'error' => 'GDRIVE_FOLDER_ID not configured']; + } + + // 1. Create (or reuse recent) local backup + $local = createLocalBackup($db); + if (!$local['success']) return $local; + + // 2. Authenticate with Google + $tokResult = _gdriveGetTokenEx(); + if (empty($tokResult['token'])) { + return ['success' => false, 'error' => $tokResult['error'] ?? 'Google Drive authentication failed']; + } + $token = $tokResult['token']; + + // 3. Upload + $uploadResult = _gdriveUploadFile($token, $folderId, $local['path'], $local['filename']); + if (empty($uploadResult['id'])) { + return ['success' => false, 'error' => $uploadResult['error'] ?? 'Upload to Google Drive failed']; + } + $driveFileId = $uploadResult['id']; + + // 4. Purge old files on Drive + $retentionDays = max(0, (int)env('GDRIVE_RETENTION_DAYS', '30')); + $purgedRemote = $retentionDays > 0 ? _gdrivePurgeOld($token, $folderId, $retentionDays) : 0; + + EverLog::info('backupToGDrive ok', ['file' => $local['filename'], 'drive_id' => $driveFileId, 'purged_remote' => $purgedRemote]); + return [ + 'success' => true, + 'filename' => $local['filename'], + 'size_kb' => $local['size_kb'], + 'drive_file_id' => $driveFileId, + 'purged_local' => $local['purged'], + 'purged_remote' => $purgedRemote, + 'created_at' => $local['created_at'], + ]; +} + /** * Server-side Bring! cleanup: remove items from Bring! that the app auto-added * but are no longer flagged by smart shopping (stock is now adequate). diff --git a/assets/css/style.css b/assets/css/style.css index 528c815..e6a47e3 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -7133,6 +7133,7 @@ body.cooking-mode-active .app-header { --bg: #0f172a; --bg-card: #1e293b; --bg-dark: #020617; + --bg-secondary: #263448; --text: #e2e8f0; --text-light: #94a3b8; --text-muted: #64748b; @@ -7384,3 +7385,159 @@ body.cooking-mode-active .app-header { color: var(--primary-light); } /* @media prefers-color-scheme: auto handled in JS */ + +/* ===== DARK MODE — EXTENDED COMPONENT OVERRIDES ===== */ + +/* ── Inventory badges ── */ +[data-theme="dark"] .badge-location { background: #0c2a4e; color: #7dd3fc; } +[data-theme="dark"] .badge-category { background: #1e293b; color: #94a3b8; } +[data-theme="dark"] .badge-qty { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .badge-expiry { background: #2a1a00; color: #fcd34d; } +[data-theme="dark"] .badge-expired { background: #2a0808; color: #fca5a5; } + +/* ── Urgency / priority badges ── */ +[data-theme="dark"] .badge-critical { background: #2a0808; color: #fca5a5; } +[data-theme="dark"] .badge-high { background: #2a1200; color: #fdba74; } +[data-theme="dark"] .badge-medium { background: #2a1e00; color: #fde68a; } +[data-theme="dark"] .badge-low { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .badge-freq-high { background: #2e0d1a; color: #f9a8d4; } +[data-theme="dark"] .badge-tag-add { background: #1e293b; color: #94a3b8; } + +/* ── Smart shopping badges ── */ +[data-theme="dark"] .smart-freq-badge.freq-suggest { background: #0c2a4e; color: #7dd3fc; } +[data-theme="dark"] .smart-freq-badge.freq-suggest-approx { background: #0c1f3a; color: #93c5fd; font-style: italic; } +[data-theme="dark"] .smart-pred-badge { background: #2a1e00; color: #fde68a; } +[data-theme="dark"] .smart-pred-badge.pred-urgent { background: #2a0808; color: #fca5a5; } +[data-theme="dark"] .smart-pred-badge.pred-soon { background: #2a1200; color: #fdba74; } +[data-theme="dark"] .smart-bring-badge { background: #0c2a4e; color: #7dd3fc; } + +/* ── AW trend mini-cards ── */ +[data-theme="dark"] .aw-tcard-good { background: #0f2a1a; border-color: #166534; } +[data-theme="dark"] .aw-tcard-ok { background: #1c1300; border-color: #78350f; } +[data-theme="dark"] .aw-tcard-bad { background: #2a0808; border-color: #7f1d1d; } + +/* ── Alert sections ── */ +[data-theme="dark"] .alert-danger { background: #2a0808; border-color: var(--danger); } +[data-theme="dark"] .alert-item { background: rgba(255,255,255,0.04); } +[data-theme="dark"] .alert-item-qty { background: rgba(255,255,255,0.06); } +[data-theme="dark"] .alert-review { background: #1c1300; border-color: #78350f; } +[data-theme="dark"] .alert-review h3 { color: #fcd34d; } +[data-theme="dark"] .alert-opened { background: #0c1f3a; border-color: #1e3a8a; } +[data-theme="dark"] .alert-opened h3 { color: #7dd3fc; } +[data-theme="dark"] .alert-item-badge.opened { background: #1e40af; } + +/* ── Opened expiry badges ── */ +[data-theme="dark"] .opened-expiry-ok { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .opened-expiry-soon { background: #2a1e00; color: #fde68a; } +[data-theme="dark"] .opened-expiry-urgent { background: #2a0808; color: #fca5a5; } + +/* ── Alert banner: gradient overrides ── */ +[data-theme="dark"] .alert-banner.banner-expired { background: #2a0808; border-color: #7f1d1d; } +[data-theme="dark"] .banner-expired .alert-banner-title { color: #fca5a5; } +[data-theme="dark"] .banner-expired .alert-banner-counter { color: #f87171; } +[data-theme="dark"] .alert-banner.banner-expiring { background: #1c1300; border-color: #78350f; } +[data-theme="dark"] .banner-expiring .alert-banner-title { color: #fdba74; } +[data-theme="dark"] .banner-expiring .alert-banner-counter { color: #fb923c; } +[data-theme="dark"] .alert-banner.banner-expired-ok { background: #0f2a1a; border-color: #166534; } +[data-theme="dark"] .banner-expired-ok .alert-banner-title { color: #86efac; } +[data-theme="dark"] .banner-expired-ok .alert-banner-counter { color: #4ade80; } +[data-theme="dark"] .alert-banner.banner-expired-warning { background: #1c1300; border-color: #78350f; } +[data-theme="dark"] .banner-expired-warning .alert-banner-title { color: #fde68a; } +[data-theme="dark"] .banner-expired-warning .alert-banner-counter { color: #fcd34d; } +[data-theme="dark"] .alert-banner.banner-expired-danger { background: #2a0808; border-color: #7f1d1d; border-width: 2px; } +[data-theme="dark"] .banner-expired-danger .alert-banner-title { color: #fca5a5; } +[data-theme="dark"] .alert-banner.banner-prediction { background: #1a1040; border-color: #6d28d9; } +[data-theme="dark"] .banner-prediction .alert-banner-title { color: #c4b5fd; } +[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; } +[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; } +[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; } +[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; } +[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; } + +/* ── Alert banner: default text & close ── */ +[data-theme="dark"] .alert-banner-title { color: #e2e8f0; } +[data-theme="dark"] .alert-banner-detail { color: #94a3b8; } +[data-theme="dark"] .alert-banner-close { color: #94a3b8; background: rgba(255,255,255,0.06); } +[data-theme="dark"] .banner-safety-warning { color: #fdba74; } +[data-theme="dark"] .banner-safety-ok { color: #86efac; } +[data-theme="dark"] .banner-safety-danger { color: #fca5a5; } + +/* ── Banner action buttons ── */ +[data-theme="dark"] .btn-banner-ok { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .btn-banner-edit { background: #1a1040; color: #c4b5fd; } +[data-theme="dark"] .btn-banner-ai { background: #2e1a4a; color: #c4b5fd; } +[data-theme="dark"] .btn-banner-weigh { background: #2e1a4a; color: #c4b5fd; } +[data-theme="dark"] .btn-banner-confirm { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .btn-banner-use { background: #0c2a4e; color: #7dd3fc; } +[data-theme="dark"] .btn-banner-throw { background: #2a0808; color: #fca5a5; } +[data-theme="dark"] .btn-banner-throw-primary { background: #dc2626; color: #fff; } +[data-theme="dark"] .btn-banner-use-danger { background: #1e293b; color: #64748b; } +[data-theme="dark"] .btn-banner-vacuum { background: #2e1a4a; color: #c4b5fd; } +[data-theme="dark"] .btn-banner-edit2 { background: #0c2a4e; color: #7dd3fc; } + +/* ── Review items ── */ +[data-theme="dark"] .review-item { background: rgba(255,255,255,0.04); } +[data-theme="dark"] .review-item-meta { color: #94a3b8; } +[data-theme="dark"] .review-warn { color: #fca5a5; } +[data-theme="dark"] .review-qty-value { background: #2a0808; color: #fca5a5; } +[data-theme="dark"] .btn-review-ok { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .btn-review-ok:active { background: #0d2416; } +[data-theme="dark"] .btn-review-edit { background: #1a1040; color: #c4b5fd; } +[data-theme="dark"] .btn-review-edit:active { background: #140d36; } + +/* ── Chat UI ── */ +[data-theme="dark"] .chat-header-bar { background: var(--bg-card); border-color: var(--border); } +[data-theme="dark"] .chat-title { color: #818cf8; } +[data-theme="dark"] .chat-suggestion { background: #1a1040; border-color: #3730a3; color: #a5b4fc; } +[data-theme="dark"] .chat-suggestion:active { background: #2e1a4a; } +[data-theme="dark"] .chat-gemini { background: var(--bg-card); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); } +[data-theme="dark"] .chat-gemini strong { color: #818cf8; } +[data-theme="dark"] .chat-input-bar { background: var(--bg-card); border-color: var(--border); } + +/* ── Settings status ── */ +[data-theme="dark"] .settings-status.success { background: #0f2a1a; color: #86efac; } +[data-theme="dark"] .settings-status.error { background: #2a0808; color: #fca5a5; } + +/* ── Inventory status bar ── */ +[data-theme="dark"] .inventory-status-bar { + background: linear-gradient(135deg, #0c2a4e 0%, #1a1040 100%); + border-color: #1e3a8a; +} +[data-theme="dark"] .inventory-status-bar .inv-status-title { color: #7dd3fc; } +[data-theme="dark"] .inventory-status-bar .inv-status-total { color: #e2e8f0; background: rgba(0,0,0,0.3); } +[data-theme="dark"] .inventory-status-bar .inv-status-item { color: #93c5fd; background: rgba(0,0,0,0.2); } + +/* ── Use inventory info ── */ +[data-theme="dark"] .use-inventory-info { background: #0c2a4e; color: #7dd3fc; } +[data-theme="dark"] #use-expiry-hint { background: #2a1e00; border-color: #78350f; color: #fde68a; } + +/* ── Recipe components ── */ +[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; } +[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; } +[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; } +[data-theme="dark"] .recipe-subtype-chip { background: #1c1300; border-color: #78350f; color: var(--text); } +[data-theme="dark"] .recipe-subtype-chip:has(input:checked) { background: #2a1e00; border-color: #d97706; } + +/* ── Bug report pills ── */ +[data-theme="dark"] .bug-type-pill { background: var(--bg-card); border-color: var(--border); color: var(--text-light); } + +/* ── Shopping tag menu ── */ +[data-theme="dark"] .shopping-tag-menu-container { background: #1a2336; } + +/* ── Edit unknown card ── */ +[data-theme="dark"] .edit-unknown-card.highlight { background: #1c1300; border-color: var(--warning); } + +/* ── AI match image ── */ +[data-theme="dark"] .ai-match-img { background: var(--bg-card); } + +/* ── Inline edit button ── */ +[data-theme="dark"] .btn-edit-inline { background: rgba(30,41,59,0.92); border-color: var(--border); color: var(--text); } + +/* ── Setup wizard ── */ +[data-theme="dark"] .setup-body p { color: var(--text-muted); } +[data-theme="dark"] .setup-footer { border-color: var(--border); } +[data-theme="dark"] .setup-skip-link { color: var(--text-muted); } +[data-theme="dark"] .setup-skip-link:hover { color: var(--text-light); } + +/* ── Appliance remove active ── */ +[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; } diff --git a/assets/js/app.js b/assets/js/app.js index 6a80d74..c2fa663 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2206,15 +2206,257 @@ function _applySyncedSettings(serverSettings) { } } -let _infoTabTimer = null; +let _infoTabTimer = null; +let _backupTabTimer = null; /** * Load the Info tab: Gemini token usage + cost, log size, DB size, log level. * Called on tab click; auto-refreshes every 30s while the tab is open. */ +// ── Backup Tab ──────────────────────────────────────────────────────────────── + +async function _loadBackupTab() { + if (_backupTabTimer) { clearInterval(_backupTabTimer); _backupTabTimer = null; } + await _renderBackupTab(); + // Pull server settings to populate inputs if not yet loaded + try { + const ss = await api('get_settings'); + if (ss) { + const bkRetEl = document.getElementById('setting-backup-retention-days'); + if (bkRetEl) { bkRetEl.value = ss.backup_retention_days || 3; bkRetEl.dataset.loaded = '1'; } + const gdriveEnEl = document.getElementById('setting-gdrive-enabled'); + if (gdriveEnEl) gdriveEnEl.checked = !!ss.gdrive_enabled; + const gdriveFolderEl = document.getElementById('setting-gdrive-folder-id'); + if (gdriveFolderEl) { gdriveFolderEl.value = ss.gdrive_folder_id || ''; gdriveFolderEl.dataset.loaded = '1'; } + const gdriveRetEl = document.getElementById('setting-gdrive-retention-days'); + if (gdriveRetEl) { gdriveRetEl.value = ss.gdrive_retention_days || 30; gdriveRetEl.dataset.loaded = '1'; } + // Pre-fill client_id (never show secret back) + if (ss.gdrive_client_id_set) { + const ciEl = document.getElementById('setting-gdrive-client-id'); + if (ciEl && !ciEl.value) ciEl.placeholder = '● ● ● already configured ● ● ●'; + } + // OAuth token status + const oauthStatusEl = document.getElementById('gdrive-oauth-token-status'); + if (oauthStatusEl) { + oauthStatusEl.textContent = ss.gdrive_refresh_token_set + ? ('✅ ' + (t('settings.backup.gdrive_oauth_authorized') || 'Authorized')) + : ('⚠️ ' + (t('settings.backup.gdrive_oauth_not_authorized') || 'Not authorized yet')); + oauthStatusEl.style.color = ss.gdrive_refresh_token_set ? '#15803d' : '#b45309'; + } + // Redirect URI for OAuth setup — always http://localhost for self-hosted compat + // (can be overridden server-side via GDRIVE_REDIRECT_URI env var) + const rdEl = document.getElementById('gdrive-redirect-uri-display'); + if (rdEl) rdEl.textContent = 'http://localhost'; + } + } catch(e) { /* non-critical */ } +} + +async function _renderBackupTab() { + const lastInfoEl = document.getElementById('backup-last-info'); + const listEl = document.getElementById('backup-list-container'); + try { + const data = await api('backup_list'); + if (!data || !data.success) { + if (lastInfoEl) lastInfoEl.innerHTML = 'Error loading backup info'; + return; + } + // Last backup info + if (lastInfoEl) { + if (data.last_backup_ts) { + const secsAgo = Math.floor(Date.now() / 1000) - data.last_backup_ts; + let ago; + if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') || 'adesso' : `${secsAgo}s fa`; + else if (secsAgo < 3600) ago = `${Math.floor(secsAgo / 60)} min fa`; + else if (secsAgo < 86400) ago = `${Math.floor(secsAgo / 3600)}h fa`; + else ago = `${Math.floor(secsAgo / 86400)}gg fa`; + const name = data.last_backup_file || ''; + lastInfoEl.innerHTML = `${t('settings.backup.last_backup') || 'Ultimo backup'}: ${ago} (${name})`; + } else { + lastInfoEl.innerHTML = `${t('settings.backup.no_backup_yet') || 'Nessun backup ancora'}`; + } + } + // Backup list + if (listEl) { + if (!data.backups || data.backups.length === 0) { + listEl.innerHTML = `

${t('settings.backup.list_empty') || 'Nessun backup disponibile'}

`; + } else { + const rows = data.backups.map(b => { + const d = new Date(b.created_at); + const dateStr = d.toLocaleString(); + return `
+ ${b.filename} + ${b.size_kb} KB · ${dateStr} + + +
`; + }).join(''); + listEl.innerHTML = `

${t('settings.backup.retention_info') || ''} ${data.retention_days} ${t('settings.backup.retention_days') || 'gg'}

${rows}`; + } + } + } catch(e) { + if (lastInfoEl) lastInfoEl.innerHTML = 'Error: ' + e.message + ''; + } +} + +async function _backupNow() { + const btn = document.getElementById('btn-backup-now'); + const statusEl = document.getElementById('backup-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = t('settings.backup.backing_up') || '⏳ Backup in corso…'; statusEl.style.display = 'block'; } + try { + const r = await api('backup_now'); + if (r && r.success) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${r.filename} (${r.size_kb} KB)`; } + await _renderBackupTab(); + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } finally { + if (btn) btn.disabled = false; + if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 5000); + } +} + +async function _backupDelete(filename) { + if (!confirm(`${t('settings.backup.delete_confirm') || 'Eliminare il backup'} ${filename}?`)) return; + const r = await api('backup_delete', {}, 'POST', { filename }); + if (r && r.success) await _renderBackupTab(); + else alert(`❌ ${r?.error || 'Error deleting backup'}`); +} + +async function _backupRestore(filename) { + if (!confirm(`${t('settings.backup.restore_confirm') || 'Ripristinare il backup'} "${filename}"?\n\n⚠️ ATTENZIONE: tutti i dati attuali verranno SOSTITUITI. Questa azione è irreversibile.`)) return; + const statusEl = document.getElementById('backup-status'); + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Ripristino in corso…'; statusEl.style.display = 'block'; } + try { + const r = await api('backup_restore', {}, 'POST', { filename }); + if (r && r.success) { + alert(`✅ ${r.message || 'Ripristino completato!'}\n\nLa pagina verrà ricaricata.`); + location.reload(); + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } +} + +async function _gdriveTest() { + const btn = document.getElementById('btn-gdrive-test'); + const statusEl = document.getElementById('gdrive-test-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Test connessione…'; statusEl.style.display = 'block'; } + try { + // Save current settings first so the server has the latest JSON/folder + await saveSettings(); + const r = await api('gdrive_test'); + if (r && r.success) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${t('settings.backup.gdrive_ok') || 'Connessione riuscita!'}`; } + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } finally { + if (btn) btn.disabled = false; + if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 6000); + } +} + +async function _gdrivePushNow() { + const btn = document.getElementById('btn-gdrive-push'); + const statusEl = document.getElementById('gdrive-test-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = t('settings.backup.gdrive_pushing') || '⏳ Upload in corso…'; statusEl.style.display = 'block'; } + try { + await saveSettings(); + const r = await api('gdrive_push'); + if (r && r.success) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${r.filename} → Drive (purged: ${r.purged_remote || 0})`; } + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } finally { + if (btn) btn.disabled = false; + if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 6000); + } +} + +async function _gdriveAuthorize() { + const btn = document.getElementById('btn-gdrive-authorize'); + if (btn) btn.disabled = true; + try { + await saveSettings(); + const r = await api('gdrive_oauth_url'); + if (r && r.success) { + window.open(r.url, '_blank', 'width=600,height=700,noopener'); + // Store redirect_uri used so gdrive_oauth_exchange can match it + window._gdriveLastRedirectUri = r.redirect_uri || 'http://localhost'; + // Show manual code input section + const codeSection = document.getElementById('gdrive-code-section'); + if (codeSection) codeSection.style.display = ''; + const statusEl = document.getElementById('gdrive-oauth-token-status'); + if (statusEl) { + statusEl.textContent = t('settings.backup.gdrive_oauth_window_opened') || '🔑 Authorization page opened — authorize and paste the URL below'; + statusEl.style.color = '#2563eb'; + } + } else { + alert('❌ ' + (r?.error || 'Failed to get OAuth URL')); + } + } catch(e) { + alert('❌ ' + e.message); + } finally { + if (btn) btn.disabled = false; + } +} + +async function _gdriveSubmitCode() { + const inputEl = document.getElementById('gdrive-code-input'); + const btn = document.getElementById('btn-gdrive-submit-code'); + const raw = (inputEl?.value || '').trim(); + if (!raw) { alert(t('settings.backup.gdrive_code_empty') || 'Paste the URL or code first'); return; } + + // Accept either a full URL (extract code param) or just the bare code + let code = raw; + try { + const u = new URL(raw); + const c = u.searchParams.get('code'); + if (c) code = c; + } catch(e) { /* not a URL, use as-is */ } + + if (btn) btn.disabled = true; + try { + const r = await api('gdrive_oauth_exchange', null, 'POST', { + code, + redirect_uri: window._gdriveLastRedirectUri || 'http://localhost' + }); + if (r && r.success) { + const statusEl = document.getElementById('gdrive-oauth-token-status'); + if (statusEl) { + statusEl.textContent = '✅ ' + (t('settings.backup.gdrive_oauth_authorized') || 'Authorized'); + statusEl.style.color = '#15803d'; + } + const codeSection = document.getElementById('gdrive-code-section'); + if (codeSection) codeSection.style.display = 'none'; + if (inputEl) inputEl.value = ''; + } else { + alert('❌ ' + (r?.error || 'Code exchange failed')); + } + } catch(e) { + alert('❌ ' + e.message); + } finally { + if (btn) btn.disabled = false; + } +} + async function _loadInfoTab() { // Cancel any previous auto-refresh - if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; } + if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; } + if (_backupTabTimer) { clearInterval(_backupTabTimer); _backupTabTimer = null; } await _renderInfoTab(); // Auto-refresh every 30s while Info tab is visible _infoTabTimer = setInterval(_renderInfoTab, 30_000); @@ -2685,6 +2927,15 @@ async function loadSettingsUI() { if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled; const scaleUrlUiEl = document.getElementById('setting-scale-url'); if (scaleUrlUiEl) scaleUrlUiEl.value = s.scale_gateway_url || ''; + // Backup settings pre-fill (populated fully when _loadBackupTab() is called) + const bkRetEl = document.getElementById('setting-backup-retention-days'); + if (bkRetEl && !bkRetEl.dataset.loaded) bkRetEl.value = s.backup_retention_days || 3; + const gdriveEnUiEl = document.getElementById('setting-gdrive-enabled'); + if (gdriveEnUiEl) gdriveEnUiEl.checked = !!s.gdrive_enabled; + const gdriveFolderUiEl = document.getElementById('setting-gdrive-folder-id'); + if (gdriveFolderUiEl && !gdriveFolderUiEl.dataset.loaded) gdriveFolderUiEl.value = s.gdrive_folder_id || ''; + const gdriveRetUiEl = document.getElementById('setting-gdrive-retention-days'); + if (gdriveRetUiEl && !gdriveRetUiEl.dataset.loaded) gdriveRetUiEl.value = s.gdrive_retention_days || 30; // Hide kiosk download banner if running inside Android WebView (kiosk mode) const kioskBanner = document.getElementById('kiosk-download-banner'); if (kioskBanner && /; wv\)/.test(navigator.userAgent)) { @@ -3092,6 +3343,22 @@ async function saveSettings() { if (priceCurrencySaveEl) s.price_currency = priceCurrencySaveEl.value; const priceMonthsSaveEl = document.getElementById('setting-price-update-months'); if (priceMonthsSaveEl) s.price_update_months = parseInt(priceMonthsSaveEl.value, 10) || 3; + // Backup settings + const backupEnabledEl = document.getElementById('setting-backup-enabled'); + if (backupEnabledEl) s.backup_enabled = backupEnabledEl.checked; + const backupRetentionEl = document.getElementById('setting-backup-retention-days'); + if (backupRetentionEl) s.backup_retention_days = parseInt(backupRetentionEl.value, 10) || 3; + const gdriveEnabledEl = document.getElementById('setting-gdrive-enabled'); + if (gdriveEnabledEl) s.gdrive_enabled = gdriveEnabledEl.checked; + const gdriveFolderEl = document.getElementById('setting-gdrive-folder-id'); + if (gdriveFolderEl) s.gdrive_folder_id = gdriveFolderEl.value.trim(); + const gdriveRetentionEl = document.getElementById('setting-gdrive-retention-days'); + if (gdriveRetentionEl) s.gdrive_retention_days = parseInt(gdriveRetentionEl.value, 10) || 30; + // OAuth fields + const gdriveClientIdEl = document.getElementById('setting-gdrive-client-id'); + if (gdriveClientIdEl && gdriveClientIdEl.value.trim()) s.gdrive_client_id = gdriveClientIdEl.value.trim(); + const gdriveClientSecretEl = document.getElementById('setting-gdrive-client-secret'); + if (gdriveClientSecretEl && gdriveClientSecretEl.value.trim()) s.gdrive_client_secret = gdriveClientSecretEl.value.trim(); saveSettingsToStorage(s); // Save ALL settings to server .env @@ -3136,8 +3403,15 @@ async function saveSettings() { price_currency: s.price_currency, price_update_months: s.price_update_months, recipe_retention_days: s.recipe_retention_days || 7, - transaction_retention_days: s.transaction_retention_days || 7, + transaction_retention_days: s.transaction_retention_days || 90, vacuum_expiry_extension_days: s.vacuum_expiry_extension_days || 30, + backup_enabled: s.backup_enabled !== false, + backup_retention_days: s.backup_retention_days || 3, + gdrive_enabled: !!s.gdrive_enabled, + gdrive_folder_id: s.gdrive_folder_id || '', + gdrive_retention_days: s.gdrive_retention_days || 30, + ...(s.gdrive_client_id ? { gdrive_client_id: s.gdrive_client_id } : {}), + ...(s.gdrive_client_secret ? { gdrive_client_secret: s.gdrive_client_secret } : {}), }, tokenHeader); const statusEl = document.getElementById('settings-status'); if (result.success) { @@ -14857,7 +15131,7 @@ document.addEventListener('DOMContentLoaded', () => { // ===== SETUP WIZARD ===== let _setupStep = 0; let _setupPendingSteps = []; -const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '' }; +const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '', gdrive_folder_id: '', gdrive_client_id: '', gdrive_client_secret: '' }; /** * Returns indices of setup steps that still need configuration. @@ -14880,8 +15154,10 @@ function _getMissingSetupSteps(serverSettings) { if (!s.gemini_key && !srv.gemini_key_set) missing.push(1); // Step 2 — Bring! credentials (check both localStorage and server .env) if ((!s.bring_email && !srv.bring_email) || (!s.bring_password && !srv.bring_password_set)) missing.push(2); + // Step 3 — Google Drive backup (always optional on first run, skippable) + if (!srv.gdrive_refresh_token_set && !srv.gdrive_folder_id) missing.push(3); } - // Note: step 3 (done screen) gets appended automatically when there are missing steps + // Note: step 4 (done screen) gets appended automatically when there are missing steps return missing; } @@ -14930,6 +15206,30 @@ function _setupSteps() { ${t('btn.cancel')} — ${_currentLang === 'it' ? 'configura dopo' : 'configure later'} ` }, + { + title: '☁️ Google Drive Backup', + desc: t('settings.backup.gdrive_wizard_hint') || 'Optional: automatically back up to Google Drive daily.', + render: () => ` +
+ ${t('settings.backup.gdrive_oauth_how_to') || '📋 Setup guide'} +
    ${t('settings.backup.gdrive_oauth_steps') || ''}
+
+
+ + +
+
+ + +
+
+ + +
+

${t('settings.backup.gdrive_redirect_uri_label') || 'Redirect URI:'} http://localhost

+ ${t('settings.backup.gdrive_skip') || 'Skip — configure later in Settings'} + ` + }, { title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : _currentLang === 'fr' ? 'Tout est prêt !' : _currentLang === 'es' ? '¡Todo listo!' : 'All set!'), desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.' @@ -14948,8 +15248,8 @@ function _setupSteps() { function showSetupWizard(pendingSteps) { _setupPendingSteps = pendingSteps || _getMissingSetupSteps(); if (_setupPendingSteps.length === 0) return; - // Append the "done" step (3) at the end - _setupPendingSteps.push(3); + // Append the "done" step (4) at the end + _setupPendingSteps.push(4); _setupStep = 0; // Pre-fill _setupData from existing settings so we don't lose them const s = getSettings(); @@ -15012,6 +15312,13 @@ function _setupCollectCurrent() { const pass = document.getElementById('setup-bring-password'); if (email) _setupData.bring_email = email.value.trim(); if (pass) _setupData.bring_password = pass.value.trim(); + } else if (realIndex === 3) { + const folderEl = document.getElementById('setup-gdrive-folder'); + const clientIdEl = document.getElementById('setup-gdrive-client-id'); + const clientSecretEl = document.getElementById('setup-gdrive-client-secret'); + if (folderEl) _setupData.gdrive_folder_id = folderEl.value.trim(); + if (clientIdEl) _setupData.gdrive_client_id = clientIdEl.value.trim(); + if (clientSecretEl) _setupData.gdrive_client_secret = clientSecretEl.value.trim(); } } @@ -15052,6 +15359,9 @@ async function _finishSetup() { if (_setupData.gemini_key) envPayload.gemini_key = _setupData.gemini_key; if (_setupData.bring_email) envPayload.bring_email = _setupData.bring_email; if (_setupData.bring_password) envPayload.bring_password = _setupData.bring_password; + if (_setupData.gdrive_folder_id) envPayload.gdrive_folder_id = _setupData.gdrive_folder_id; + if (_setupData.gdrive_client_id) { envPayload.gdrive_client_id = _setupData.gdrive_client_id; envPayload.gdrive_enabled = true; } + if (_setupData.gdrive_client_secret) envPayload.gdrive_client_secret = _setupData.gdrive_client_secret; try { if (Object.keys(envPayload).length > 0) { await api('save_settings', {}, 'POST', envPayload); diff --git a/index.html b/index.html index f5eb4a3..b9cbd9c 100644 --- a/index.html +++ b/index.html @@ -841,6 +841,7 @@ +
@@ -1342,6 +1343,93 @@
+ +
+ +
+

💾 Backup Locale

+

Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).

+
+ Caricamento… +
+
+ + +
+ + + +
+

Caricamento…

+
+
+ +
+

☁️ Google Drive

+

Carica automaticamente il backup su Google Drive usando un Service Account.

+
+ +
+
+ +
+ + +

Copia l'ID dalla URL della cartella Drive: …/folders/ID

+
+ +
+
+ 📋 Come configurare OAuth 2.0 (passo dopo passo) +
    +
    +
    + + +
    +
    + + +
    +
    + Redirect URI (aggiungi in Google Cloud Console): + http://localhost +

    Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa http://localhost.

    +
    +
    + + +
    + + +
    + +
    + + +
    +
    + + +
    + +
    +
    +
    +
    diff --git a/translations/de.json b/translations/de.json index 60d8bb1..068928f 100644 --- a/translations/de.json +++ b/translations/de.json @@ -762,6 +762,53 @@ "card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.", "label": "Tipps beim Kochen anzeigen" }, + "backup": { + "tab": "Backup", + "local_title": "Lokales Backup", + "local_hint": "Täglicher Datenbank-Snapshot. Konfiguriere, wie viele Tage Backups aufbewahrt werden.", + "enabled": "Tägliches automatisches Backup aktivieren", + "retention_days": "Aufbewahrung (Tage)", + "retention_info": "Backups werden aufbewahrt für", + "backup_now": "Jetzt sichern", + "backing_up": "Sicherung läuft…", + "backed_up": "Sicherung abgeschlossen", + "backup_error": "Sicherungsfehler", + "last_backup": "Letztes Backup", + "no_backup_yet": "Noch kein Backup erstellt", + "list_empty": "Keine Backups verfügbar", + "restore_btn": "Wiederherstellen", + "restore_confirm": "Backup wiederherstellen", + "delete_btn": "Löschen", + "delete_confirm": "Backup löschen", + "gdrive_title": "Google Drive", + "gdrive_hint": "Backups automatisch via OAuth 2.0 auf Google Drive hochladen. Keine externen Bibliotheken erforderlich.", + "gdrive_enabled": "Google Drive Backup aktivieren", + "gdrive_folder_id": "Drive-Ordner-ID", + "gdrive_folder_id_hint": "Kopiere die ID aus der Drive-Ordner-URL: …/folders/ID", + "gdrive_retention_days": "Drive-Aufbewahrung (Tage, 0=alles behalten)", + "gdrive_test": "Verbindung testen", + "gdrive_ok": "Verbindung erfolgreich!", + "gdrive_error": "Verbindung fehlgeschlagen", + "gdrive_push_now": "Jetzt auf Drive hochladen", + "gdrive_pushing": "Wird hochgeladen…", + "gdrive_pushed": "Auf Drive hochgeladen", + "gdrive_wizard_hint": "Optional: täglich automatisch via OAuth 2.0 auf Google Drive sichern.", + "gdrive_skip": "Überspringen — später in Einstellungen konfigurieren", + "gdrive_client_id": "Client-ID", + "gdrive_client_secret": "Client-Secret", + "gdrive_redirect_uri_hint": "Füge http://localhost als autorisierten Weiterleitungs-URI in der Google Cloud Console hinzu. Funktioniert auf jedem Server, auch ohne öffentliche Domain.", + "gdrive_code_title": "Autorisierungs-URL oder Code einfügen", + "gdrive_code_hint": "Nach der Autorisierung öffnet der Browser http://localhost und zeigt möglicherweise einen Verbindungsfehler — das ist normal. Kopiere die URL aus der Adressleiste (z.B. http://localhost/?code=4%2F0A...) und füge sie hier ein.", + "gdrive_code_submit": "Bestätigen", + "gdrive_code_empty": "Bitte zuerst die URL oder den Autorisierungscode einfügen", + "gdrive_redirect_uri_label": "Redirect-URI (in Google Cloud Console eintragen):", + "gdrive_oauth_authorize": "Mit Google autorisieren", + "gdrive_oauth_authorized": "Autorisiert", + "gdrive_oauth_not_authorized": "Noch nicht autorisiert", + "gdrive_oauth_window_opened": "Browserfenster geöffnet — autorisieren und zurückkehren", + "gdrive_oauth_how_to": "OAuth 2.0 einrichten (Schritt für Schritt)", + "gdrive_oauth_steps": "
  1. Gehe zu console.cloud.google.com und wähle dein Projekt
  2. Aktiviere die Google Drive API: APIs & Dienste → APIs aktivieren → Google Drive API
  3. Gehe zu APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID
  4. Anwendungstyp: Webanwendung; füge die unten angezeigte URL als Autorisierter Weiterleitungs-URI hinzu
  5. Kopiere Client-ID und Client-Secret in die Felder oben und speichere
  6. Klicke auf Mit Google autorisieren: melde dich an und erteile den Zugriff
  7. Das Fenster schließt sich automatisch und Backups sind bereit
  8. " + }, "info": { "tab": "Info", "ai_title": "Gemini AI — Token-Nutzung", diff --git a/translations/en.json b/translations/en.json index 8eb5f89..61d7268 100644 --- a/translations/en.json +++ b/translations/en.json @@ -762,6 +762,53 @@ "card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.", "label": "Show tips during cooking" }, + "backup": { + "tab": "Backup", + "local_title": "Local Backup", + "local_hint": "Daily database snapshot. Configure how many days of backups to keep.", + "enabled": "Enable daily automatic backup", + "retention_days": "Retention (days)", + "retention_info": "Backups are kept for", + "backup_now": "Backup Now", + "backing_up": "Backing up…", + "backed_up": "Backup complete", + "backup_error": "Backup error", + "last_backup": "Last backup", + "no_backup_yet": "No backup has been created yet", + "list_empty": "No backups available", + "restore_btn": "Restore", + "restore_confirm": "Restore backup", + "delete_btn": "Delete", + "delete_confirm": "Delete backup", + "gdrive_title": "Google Drive", + "gdrive_hint": "Automatically back up to Google Drive via OAuth 2.0. No external libraries required.", + "gdrive_enabled": "Enable Google Drive backup", + "gdrive_folder_id": "Drive Folder ID", + "gdrive_folder_id_hint": "Copy the ID from the Drive folder URL: …/folders/ID", + "gdrive_retention_days": "Drive retention (days, 0=keep all)", + "gdrive_test": "Test Connection", + "gdrive_ok": "Connection successful!", + "gdrive_error": "Connection failed", + "gdrive_push_now": "Upload to Drive Now", + "gdrive_pushing": "Uploading…", + "gdrive_pushed": "Uploaded to Drive", + "gdrive_wizard_hint": "Optional: automatically back up to Google Drive daily via OAuth 2.0.", + "gdrive_skip": "Skip — configure later in Settings", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_hint": "Add http://localhost as an authorized redirect URI in Google Cloud Console. This works on any server, even without a public domain.", + "gdrive_code_title": "Paste the authorization URL or code", + "gdrive_code_hint": "After authorizing, the browser will open http://localhost and may show a connection error — that is expected. Copy the URL from the address bar (e.g. http://localhost/?code=4%2F0A...) and paste it here.", + "gdrive_code_submit": "Submit", + "gdrive_code_empty": "Paste the URL or authorization code first", + "gdrive_redirect_uri_label": "Redirect URI (add this in Google Cloud Console):", + "gdrive_oauth_authorize": "Authorize with Google", + "gdrive_oauth_authorized": "Authorized", + "gdrive_oauth_not_authorized": "Not authorized yet", + "gdrive_oauth_window_opened": "Browser window opened — authorize and come back", + "gdrive_oauth_how_to": "How to set up OAuth 2.0 (step by step)", + "gdrive_oauth_steps": "
  9. Go to console.cloud.google.com and select your project
  10. Enable the Google Drive API: APIs & Services → Enable APIs → Google Drive API
  11. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID
  12. Application type: Web application; add http://localhost as an Authorized redirect URI
  13. Copy the Client ID and Client Secret into the fields above and save
  14. Click Authorize with Google, sign in and grant access
  15. The browser will open http://localhost (a connection error is expected): copy the URL from the address bar and paste it in the field that appears below
  16. " + }, "info": { "tab": "Info", "ai_title": "Gemini AI — Token Usage", diff --git a/translations/es.json b/translations/es.json index 583c8b3..5b126d5 100644 --- a/translations/es.json +++ b/translations/es.json @@ -759,6 +759,53 @@ "card_title": "♻️ Consejos sin desperdicios", "card_hint": "Durante la cocción, muestra consejos sobre cómo reutilizar los restos generados en cada paso (peladuras, agua de cocción, etc.). Desactivado por defecto.", "label": "Mostrar consejos durante la cocción" + }, + "backup": { + "tab": "Copia de seguridad", + "local_title": "Copia local", + "local_hint": "Instantánea diaria de la base de datos. Configura cuántos días de copias de seguridad conservar.", + "enabled": "Activar copia de seguridad diaria automática", + "retention_days": "Retención (días)", + "retention_info": "Las copias se conservan durante", + "backup_now": "Hacer copia ahora", + "backing_up": "Haciendo copia…", + "backed_up": "Copia completada", + "backup_error": "Error en la copia", + "last_backup": "Última copia", + "no_backup_yet": "Aún no se ha creado ninguna copia", + "list_empty": "No hay copias disponibles", + "restore_btn": "Restaurar", + "restore_confirm": "Restaurar la copia", + "delete_btn": "Eliminar", + "delete_confirm": "Eliminar la copia", + "gdrive_title": "Google Drive", + "gdrive_hint": "Copias de seguridad automáticas en Google Drive via OAuth 2.0. No se requieren bibliotecas externas.", + "gdrive_enabled": "Activar copia en Google Drive", + "gdrive_folder_id": "ID de carpeta de Drive", + "gdrive_folder_id_hint": "Copia el ID desde la URL de la carpeta de Drive: …/folders/ID", + "gdrive_retention_days": "Retención en Drive (días, 0=mantener todo)", + "gdrive_test": "Probar conexión", + "gdrive_ok": "Conexión exitosa!", + "gdrive_error": "Conexión fallida", + "gdrive_push_now": "Subir a Drive ahora", + "gdrive_pushing": "Subiendo…", + "gdrive_pushed": "Subido a Drive", + "gdrive_wizard_hint": "Opcional: copia de seguridad diaria automática en Google Drive via OAuth 2.0.", + "gdrive_skip": "Omitir — configurar después en Ajustes", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_hint": "Agrega http://localhost como URI de redireccionamiento autorizado en Google Cloud Console. Funciona en cualquier servidor, incluso sin dominio público.", + "gdrive_code_title": "Pegar la URL o el código de autorización", + "gdrive_code_hint": "Tras autorizar, el navegador abrirá http://localhost y puede mostrar un error de conexión — es normal. Copia la URL de la barra de direcciones (ej. http://localhost/?code=4%2F0A...) y pégala aquí.", + "gdrive_code_submit": "Confirmar", + "gdrive_code_empty": "Pega primero la URL o el código de autorización", + "gdrive_redirect_uri_label": "URI de redirección (agregar en Google Cloud Console):", + "gdrive_oauth_authorize": "Autorizar con Google", + "gdrive_oauth_authorized": "Autorizado", + "gdrive_oauth_not_authorized": "Aún no autorizado", + "gdrive_oauth_window_opened": "Ventana abierta — autoriza y regresa aquí", + "gdrive_oauth_how_to": "Cómo configurar OAuth 2.0 (paso a paso)", + "gdrive_oauth_steps": "
  17. Ve a console.cloud.google.com y selecciona tu proyecto
  18. Habilita la API de Google Drive: API y servicios → Habilitar API → Google Drive API
  19. Ve a API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth
  20. Tipo de aplicación: Aplicación web; agrega la URL mostrada abajo como URI de redirección autorizado
  21. Copia el Client ID y el Client Secret en los campos de arriba y guarda
  22. Haz clic en Autorizar con Google: inicia sesión en tu cuenta de Google y concede acceso
  23. La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas
  24. " } }, "expiry": { diff --git a/translations/fr.json b/translations/fr.json index f37b12a..c0047be 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -759,6 +759,53 @@ "card_title": "♻️ Conseils zéro déchet", "card_hint": "Pendant la cuisson, affichez des conseils pour réutiliser les déchets produits à chaque étape (épluchures, eau de cuisson, etc.). Désactivé par défaut.", "label": "Afficher les conseils pendant la cuisson" + }, + "backup": { + "tab": "Sauvegarde", + "local_title": "Sauvegarde locale", + "local_hint": "Instantané quotidien de la base de données. Configurez le nombre de jours de rétention.", + "enabled": "Activer la sauvegarde automatique quotidienne", + "retention_days": "Rétention (jours)", + "retention_info": "Les sauvegardes sont conservées pendant", + "backup_now": "Sauvegarder maintenant", + "backing_up": "Sauvegarde en cours…", + "backed_up": "Sauvegarde terminée", + "backup_error": "Erreur de sauvegarde", + "last_backup": "Dernière sauvegarde", + "no_backup_yet": "Aucune sauvegarde créée", + "list_empty": "Aucune sauvegarde disponible", + "restore_btn": "Restaurer", + "restore_confirm": "Restaurer la sauvegarde", + "delete_btn": "Supprimer", + "delete_confirm": "Supprimer la sauvegarde", + "gdrive_title": "Google Drive", + "gdrive_hint": "Sauvegardez automatiquement sur Google Drive via OAuth 2.0. Aucune bibliothèque externe requise.", + "gdrive_enabled": "Activer la sauvegarde Google Drive", + "gdrive_folder_id": "ID du dossier Drive", + "gdrive_folder_id_hint": "Copiez l'ID depuis l'URL du dossier Drive : …/folders/ID", + "gdrive_retention_days": "Rétention Drive (jours, 0=tout garder)", + "gdrive_test": "Tester la connexion", + "gdrive_ok": "Connexion réussie !", + "gdrive_error": "Échec de la connexion", + "gdrive_push_now": "Téléverser sur Drive maintenant", + "gdrive_pushing": "Téléversement en cours…", + "gdrive_pushed": "Téléversé sur Drive", + "gdrive_wizard_hint": "Optionnel : sauvegarde quotidienne automatique sur Google Drive via OAuth 2.0.", + "gdrive_skip": "Passer — configurer plus tard dans Paramètres", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_hint": "Ajoute http://localhost comme URI de redirection autorisé dans la Google Cloud Console. Fonctionne sur n'importe quel serveur, même sans domaine public.", + "gdrive_code_title": "Coller l'URL ou le code d'autorisation", + "gdrive_code_hint": "Après autorisation, le navigateur ouvre http://localhost et peut afficher une erreur de connexion — c'est normal. Copie l'URL dans la barre d'adresse (ex. http://localhost/?code=4%2F0A...) et colle-la ici.", + "gdrive_code_submit": "Confirmer", + "gdrive_code_empty": "Coller d'abord l'URL ou le code d'autorisation", + "gdrive_redirect_uri_label": "URI de redirection (ajouter dans Google Cloud Console) :", + "gdrive_oauth_authorize": "Autoriser avec Google", + "gdrive_oauth_authorized": "Autorisé", + "gdrive_oauth_not_authorized": "Pas encore autorisé", + "gdrive_oauth_window_opened": "Fenêtre ouverte — autorisez et revenez ici", + "gdrive_oauth_how_to": "Configurer OAuth 2.0 (étape par étape)", + "gdrive_oauth_steps": "
  25. Allez sur console.cloud.google.com et sélectionnez votre projet
  26. Activez l’API Google Drive : API et services → Activer les API → Google Drive API
  27. Allez dans API et services → Identifiants → Créer des identifiants → ID client OAuth
  28. Type d’application : Application Web ; ajoutez l’URL affichée ci-dessous comme URI de redirection autorisé
  29. Copiez le Client ID et le Client Secret dans les champs ci-dessus et enregistrez
  30. Cliquez sur Autoriser avec Google : connectez-vous et accordez l’accès
  31. La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes
  32. " } }, "expiry": { diff --git a/translations/it.json b/translations/it.json index 660fdf7..0c364d2 100644 --- a/translations/it.json +++ b/translations/it.json @@ -762,6 +762,53 @@ "card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.", "label": "Mostra suggerimenti durante la cottura" }, + "backup": { + "tab": "Backup", + "local_title": "Backup Locale", + "local_hint": "Snapshot giornaliero del database. Configura quanti giorni di backup conservare.", + "enabled": "Backup automatico quotidiano", + "retention_days": "Giorni di retention", + "retention_info": "I backup vengono conservati per", + "backup_now": "Backup Ora", + "backing_up": "Backup in corso…", + "backed_up": "Backup completato", + "backup_error": "Errore backup", + "last_backup": "Ultimo backup", + "no_backup_yet": "Nessun backup ancora eseguito", + "list_empty": "Nessun backup disponibile", + "restore_btn": "Ripristina", + "restore_confirm": "Ripristinare il backup", + "delete_btn": "Elimina", + "delete_confirm": "Eliminare il backup", + "gdrive_title": "Google Drive", + "gdrive_hint": "Backup automatici su Google Drive via OAuth 2.0. Nessuna libreria esterna richiesta.", + "gdrive_enabled": "Abilita backup Google Drive", + "gdrive_folder_id": "ID Cartella Drive", + "gdrive_folder_id_hint": "Copia l'ID dalla URL della cartella Drive: …/folders/ID", + "gdrive_retention_days": "Retention Drive (giorni, 0=tutto)", + "gdrive_test": "Testa Connessione", + "gdrive_ok": "Connessione riuscita!", + "gdrive_error": "Connessione fallita", + "gdrive_push_now": "Carica Ora su Drive", + "gdrive_pushing": "Upload in corso…", + "gdrive_pushed": "Caricato su Drive", + "gdrive_wizard_hint": "Opzionale: backup giornaliero automatico su Google Drive via OAuth 2.0.", + "gdrive_skip": "Salta — configura dopo in Impostazioni", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_label": "Redirect URI (da aggiungere in Google Cloud Console):", + "gdrive_redirect_uri_hint": "Aggiungi http://localhost come URI di reindirizzamento autorizzato in Google Cloud Console. Funziona su qualsiasi server, anche senza dominio pubblico.", + "gdrive_oauth_authorize": "Autorizza con Google", + "gdrive_oauth_authorized": "Autorizzato", + "gdrive_oauth_not_authorized": "Non ancora autorizzato", + "gdrive_oauth_window_opened": "Finestra aperta — autorizza e torna qui", + "gdrive_oauth_how_to": "Come configurare OAuth 2.0 (passo dopo passo)", + "gdrive_oauth_steps": "
  33. Vai su console.cloud.google.com e seleziona il progetto
  34. Abilita la Google Drive API: API e servizi → Abilita API → Google Drive API
  35. Vai su API e servizi → Credenziali → Crea credenziali → ID client OAuth 2.0
  36. Tipo applicazione: Applicazione web; aggiungi http://localhost come URI di reindirizzamento autorizzato
  37. Copia Client ID e Client Secret nei campi qui sopra e salva
  38. Clicca Autorizza con Google, accedi e concedi l'accesso
  39. Il browser aprirà http://localhost (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sotto
  40. ", + "gdrive_code_title": "Incolla l'URL o il codice di autorizzazione", + "gdrive_code_hint": "Dopo aver autorizzato, il browser aprirà http://localhost e potrebbe mostrare un errore. Copia l'URL dalla barra degli indirizzi (es. http://localhost/?code=4%2F0A...) e incollalo qui.", + "gdrive_code_submit": "Conferma", + "gdrive_code_empty": "Incolla prima l'URL o il codice di autorizzazione" + }, "info": { "tab": "Info", "ai_title": "Gemini AI — Utilizzo Token",