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 '
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 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 "$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 `${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_redirect_uri_label') || 'Redirect URI:'} http://localhost
Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).
+Caricamento…
+Carica automaticamente il backup su Google Drive usando un Service Account.
+Copia l'ID dalla URL della cartella Drive: …/folders/ID
+http://localhost
+ Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa http://localhost.
+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": "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": "http://localhost (a connection error is expected): copy the URL from the address bar and paste it in the field that appears belowhttp://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": "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": "http://localhost (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sottohttp://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",