feat: Google Drive OAuth via http://localhost redirect (no public domain required)

- Switch redirect URI from server IP to http://localhost (works everywhere)
- Add manual code exchange flow: user copies URL from browser, pastes in app
- New PHP action gdrive_oauth_exchange to exchange auth code for refresh token
- Fix  null bug in gdrive_oauth_exchange (was read before initialization)
- Add #gdrive-code-section UI with input + submit button in index.html
- Update _gdriveAuthorize() to show code section and store redirect_uri
- Add _gdriveSubmitCode() JS function for manual code submission
- Update setup wizard and backup tab to show http://localhost as redirect URI
- Add 5 new translation keys (gdrive_redirect_uri_hint, gdrive_code_title,
  gdrive_code_hint, gdrive_code_submit, gdrive_code_empty) in all 5 languages
- Update gdrive_oauth_steps in all translations to reflect new flow
- Document Google Drive OAuth setup in README.md
- Dark mode: comprehensive fix for 30+ components with hardcoded light colors
This commit is contained in:
dadaloop82
2026-05-18 18:41:56 +00:00
parent 4515ff7246
commit 7364e75881
10 changed files with 1364 additions and 11 deletions
+18
View File
@@ -296,6 +296,24 @@ The included `backup.sh` creates local daily backups of your database:
0 3 * * * /path/to/evershelf/backup.sh 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 ## 🏗️ Architecture
+549 -4
View File
@@ -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('SHOPPING_NAME_CACHE_PATH', __DIR__ . '/../data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json'); define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json');
define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.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.) // 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 // 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)); define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
@@ -119,6 +121,12 @@ if (($_GET['action'] ?? '') === 'ping') {
exit; 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) ──── // ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ────
if (($_GET['action'] ?? '') === 'get_logs') { if (($_GET['action'] ?? '') === 'get_logs') {
require_once __DIR__ . '/logger.php'; require_once __DIR__ . '/logger.php';
@@ -690,6 +698,7 @@ try {
'save_settings', 'product_save', 'product_delete', 'product_merge', 'save_settings', 'product_save', 'product_delete', 'product_merge',
'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove', 'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove',
'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync', 'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync',
'backup_delete', 'backup_restore',
]; ];
if (in_array($action, $demoBlocked, true)) { if (in_array($action, $demoBlocked, true)) {
EverLog::warn('demo_mode blocked (403)'); EverLog::warn('demo_mode blocked (403)');
@@ -908,6 +917,98 @@ try {
dbCleanup(getDB()); dbCleanup(getDB());
break; 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': case 'gemini_product_hint':
geminiProductHint(); geminiProductHint();
break; 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 = $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']]); $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]); echo json_encode(['success' => true]);
} }
@@ -2915,14 +3021,24 @@ function getServerSettings(): void {
'price_currency' => env('PRICE_CURRENCY', 'EUR'), 'price_currency' => env('PRICE_CURRENCY', 'EUR'),
'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'), 'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'),
'recipe_retention_days' => (int)env('RECIPE_RETENTION_DAYS', '7'), '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'), '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 { function dbCleanup(?PDO $db = null): void {
$recipeDays = max(1, (int)env('RECIPE_RETENTION_DAYS', '7')); $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(); $pdo = $db ?? getDB();
try { try {
// Delete old recipes (generated recipe plans) // Delete old recipes (generated recipe plans)
@@ -2976,6 +3092,9 @@ function saveSettings(): void {
'tts_auth_header_name' => 'TTS_AUTH_HEADER_NAME', 'tts_auth_header_name' => 'TTS_AUTH_HEADER_NAME',
'tts_auth_header_value' => 'TTS_AUTH_HEADER_VALUE', 'tts_auth_header_value' => 'TTS_AUTH_HEADER_VALUE',
'tts_extra_fields' => 'TTS_EXTRA_FIELDS', '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 // Boolean keys
$boolMap = [ $boolMap = [
@@ -2991,6 +3110,8 @@ function saveSettings(): void {
'screensaver_enabled' => 'SCREENSAVER_ENABLED', 'screensaver_enabled' => 'SCREENSAVER_ENABLED',
'price_enabled' => 'PRICE_ENABLED', 'price_enabled' => 'PRICE_ENABLED',
'zerowaste_tips_enabled' => 'ZEROWASTE_TIPS_ENABLED', 'zerowaste_tips_enabled' => 'ZEROWASTE_TIPS_ENABLED',
'backup_enabled' => 'BACKUP_ENABLED',
'gdrive_enabled' => 'GDRIVE_ENABLED',
]; ];
// Integer keys // Integer keys
$intMap = [ $intMap = [
@@ -3000,6 +3121,8 @@ function saveSettings(): void {
'recipe_retention_days' => 'RECIPE_RETENTION_DAYS', 'recipe_retention_days' => 'RECIPE_RETENTION_DAYS',
'transaction_retention_days' => 'TRANSACTION_RETENTION_DAYS', 'transaction_retention_days' => 'TRANSACTION_RETENTION_DAYS',
'vacuum_expiry_extension_days'=> 'VACUUM_EXPIRY_EXTENSION_DAYS', 'vacuum_expiry_extension_days'=> 'VACUUM_EXPIRY_EXTENSION_DAYS',
'backup_retention_days' => 'BACKUP_RETENTION_DAYS',
'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS',
]; ];
// Float keys // Float keys
$floatMap = [ $floatMap = [
@@ -3031,7 +3154,7 @@ function saveSettings(): void {
if (array_key_exists('appliances', $input)) { if (array_key_exists('appliances', $input)) {
$envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances']; $envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances'];
} }
// Write .env file // Write .env file
$lines = []; $lines = [];
foreach ($envVars as $key => $val) { foreach ($envVars as $key => $val) {
@@ -6108,6 +6231,428 @@ function computeShoppingName(string $name, string $category = '', string $brand
return ucfirst($name); 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 '<html><body style="font-family:sans-serif;padding:2rem"><h2>&#10060; Error</h2><p>No authorization code received.</p></body></html>';
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 '<html><head><title>EverShelf &#10004;</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;background:#f0fdf4">'
. '<h2 style="color:#15803d">&#10004; Google Drive Authorized!</h2>'
. '<p>EverShelf can now back up to your Google Drive.</p>'
. '<p style="color:#94a3b8;font-size:0.9rem">This tab will close automatically.</p>'
. '<script>setTimeout(()=>{try{window.close()}catch(e){}},2500)</script>'
. '</body></html>';
} else {
$err = htmlspecialchars($data['error_description'] ?? $data['error'] ?? 'Unknown error');
http_response_code(400);
echo "<html><body style='font-family:sans-serif;padding:2rem'><h2>&#10060; Authorization failed</h2><p>$err</p></body></html>";
}
}
/**
* 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 * 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). * but are no longer flagged by smart shopping (stock is now adequate).
+157
View File
@@ -7133,6 +7133,7 @@ body.cooking-mode-active .app-header {
--bg: #0f172a; --bg: #0f172a;
--bg-card: #1e293b; --bg-card: #1e293b;
--bg-dark: #020617; --bg-dark: #020617;
--bg-secondary: #263448;
--text: #e2e8f0; --text: #e2e8f0;
--text-light: #94a3b8; --text-light: #94a3b8;
--text-muted: #64748b; --text-muted: #64748b;
@@ -7384,3 +7385,159 @@ body.cooking-mode-active .app-header {
color: var(--primary-light); color: var(--primary-light);
} }
/* @media prefers-color-scheme: auto handled in JS */ /* @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; }
+317 -7
View File
@@ -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. * 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. * 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 = '<span style="color:#ef4444">Error loading backup info</span>';
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 = `<strong>${t('settings.backup.last_backup') || 'Ultimo backup'}</strong>: ${ago} <span style="color:#94a3b8;font-size:0.78rem">(${name})</span>`;
} else {
lastInfoEl.innerHTML = `<em style="color:#f59e0b">${t('settings.backup.no_backup_yet') || 'Nessun backup ancora'}</em>`;
}
}
// Backup list
if (listEl) {
if (!data.backups || data.backups.length === 0) {
listEl.innerHTML = `<p class="settings-hint" style="text-align:center;padding:12px">${t('settings.backup.list_empty') || 'Nessun backup disponibile'}</p>`;
} else {
const rows = data.backups.map(b => {
const d = new Date(b.created_at);
const dateStr = d.toLocaleString();
return `<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border-color,#e2e8f0);font-size:0.83rem">
<span style="flex:1;color:var(--text-primary)">${b.filename}</span>
<span style="color:#94a3b8;white-space:nowrap">${b.size_kb} KB · ${dateStr}</span>
<button class="btn btn-small btn-secondary" onclick="_backupRestore('${b.filename}')" style="flex-shrink:0" title="${t('settings.backup.restore_btn') || 'Ripristina'}">${t('settings.backup.restore_btn') || '↩ Ripristina'}</button>
<button class="btn btn-small btn-danger" onclick="_backupDelete('${b.filename}')" style="flex-shrink:0" title="${t('settings.backup.delete_btn') || 'Elimina'}">🗑</button>
</div>`;
}).join('');
listEl.innerHTML = `<p style="font-size:0.78rem;color:#94a3b8;margin-bottom:6px">${t('settings.backup.retention_info') || ''} ${data.retention_days} ${t('settings.backup.retention_days') || 'gg'}</p>${rows}`;
}
}
} catch(e) {
if (lastInfoEl) lastInfoEl.innerHTML = '<span style="color:#ef4444">Error: ' + e.message + '</span>';
}
}
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() { async function _loadInfoTab() {
// Cancel any previous auto-refresh // 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(); await _renderInfoTab();
// Auto-refresh every 30s while Info tab is visible // Auto-refresh every 30s while Info tab is visible
_infoTabTimer = setInterval(_renderInfoTab, 30_000); _infoTabTimer = setInterval(_renderInfoTab, 30_000);
@@ -2685,6 +2927,15 @@ async function loadSettingsUI() {
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled; if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
const scaleUrlUiEl = document.getElementById('setting-scale-url'); const scaleUrlUiEl = document.getElementById('setting-scale-url');
if (scaleUrlUiEl) scaleUrlUiEl.value = s.scale_gateway_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) // Hide kiosk download banner if running inside Android WebView (kiosk mode)
const kioskBanner = document.getElementById('kiosk-download-banner'); const kioskBanner = document.getElementById('kiosk-download-banner');
if (kioskBanner && /; wv\)/.test(navigator.userAgent)) { if (kioskBanner && /; wv\)/.test(navigator.userAgent)) {
@@ -3092,6 +3343,22 @@ async function saveSettings() {
if (priceCurrencySaveEl) s.price_currency = priceCurrencySaveEl.value; if (priceCurrencySaveEl) s.price_currency = priceCurrencySaveEl.value;
const priceMonthsSaveEl = document.getElementById('setting-price-update-months'); const priceMonthsSaveEl = document.getElementById('setting-price-update-months');
if (priceMonthsSaveEl) s.price_update_months = parseInt(priceMonthsSaveEl.value, 10) || 3; 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); saveSettingsToStorage(s);
// Save ALL settings to server .env // Save ALL settings to server .env
@@ -3136,8 +3403,15 @@ async function saveSettings() {
price_currency: s.price_currency, price_currency: s.price_currency,
price_update_months: s.price_update_months, price_update_months: s.price_update_months,
recipe_retention_days: s.recipe_retention_days || 7, 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, 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); }, tokenHeader);
const statusEl = document.getElementById('settings-status'); const statusEl = document.getElementById('settings-status');
if (result.success) { if (result.success) {
@@ -14857,7 +15131,7 @@ document.addEventListener('DOMContentLoaded', () => {
// ===== SETUP WIZARD ===== // ===== SETUP WIZARD =====
let _setupStep = 0; let _setupStep = 0;
let _setupPendingSteps = []; 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. * 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); if (!s.gemini_key && !srv.gemini_key_set) missing.push(1);
// Step 2 — Bring! credentials (check both localStorage and server .env) // 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); 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; return missing;
} }
@@ -14930,6 +15206,30 @@ function _setupSteps() {
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')} ${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span> <span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')} ${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
` `
}, },
{
title: '☁️ Google Drive Backup',
desc: t('settings.backup.gdrive_wizard_hint') || 'Optional: automatically back up to Google Drive daily.',
render: () => `
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
<summary style="cursor:pointer;font-weight:600;font-size:0.85rem;color:var(--text-primary)">${t('settings.backup.gdrive_oauth_how_to') || '📋 Setup guide'}</summary>
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8">${t('settings.backup.gdrive_oauth_steps') || ''}</ol>
</details>
<div class="form-group">
<label>${t('settings.backup.gdrive_folder_id') || 'Folder ID Drive'}</label>
<input type="text" id="setup-gdrive-folder" class="form-input" placeholder="1ABCdef_xyz…" value="${_setupData.gdrive_folder_id}">
</div>
<div class="form-group">
<label>${t('settings.backup.gdrive_client_id') || 'Client ID'}</label>
<input type="text" id="setup-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com" value="${_setupData.gdrive_client_id}">
</div>
<div class="form-group">
<label>${t('settings.backup.gdrive_client_secret') || 'Client Secret'}</label>
<input type="password" id="setup-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…" value="${_setupData.gdrive_client_secret}">
</div>
<p class="settings-hint" style="font-size:0.78rem">${t('settings.backup.gdrive_redirect_uri_label') || 'Redirect URI:'} <code>http://localhost</code></p>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('settings.backup.gdrive_skip') || 'Skip — configure later in Settings'}</span>
`
},
{ {
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : _currentLang === 'fr' ? 'Tout est prêt !' : _currentLang === 'es' ? '¡Todo listo!' : 'All set!'), 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.' desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.'
@@ -14948,8 +15248,8 @@ function _setupSteps() {
function showSetupWizard(pendingSteps) { function showSetupWizard(pendingSteps) {
_setupPendingSteps = pendingSteps || _getMissingSetupSteps(); _setupPendingSteps = pendingSteps || _getMissingSetupSteps();
if (_setupPendingSteps.length === 0) return; if (_setupPendingSteps.length === 0) return;
// Append the "done" step (3) at the end // Append the "done" step (4) at the end
_setupPendingSteps.push(3); _setupPendingSteps.push(4);
_setupStep = 0; _setupStep = 0;
// Pre-fill _setupData from existing settings so we don't lose them // Pre-fill _setupData from existing settings so we don't lose them
const s = getSettings(); const s = getSettings();
@@ -15012,6 +15312,13 @@ function _setupCollectCurrent() {
const pass = document.getElementById('setup-bring-password'); const pass = document.getElementById('setup-bring-password');
if (email) _setupData.bring_email = email.value.trim(); if (email) _setupData.bring_email = email.value.trim();
if (pass) _setupData.bring_password = pass.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.gemini_key) envPayload.gemini_key = _setupData.gemini_key;
if (_setupData.bring_email) envPayload.bring_email = _setupData.bring_email; if (_setupData.bring_email) envPayload.bring_email = _setupData.bring_email;
if (_setupData.bring_password) envPayload.bring_password = _setupData.bring_password; 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 { try {
if (Object.keys(envPayload).length > 0) { if (Object.keys(envPayload).length > 0) {
await api('save_settings', {}, 'POST', envPayload); await api('save_settings', {}, 'POST', envPayload);
+88
View File
@@ -841,6 +841,7 @@
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button> <button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button> <button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button> <button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info"></button> <button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info"></button>
</div> </div>
<div class="settings-panels"> <div class="settings-panels">
@@ -1342,6 +1343,93 @@
</div> </div>
<!-- Language Tab --> <!-- Language Tab -->
<!-- Backup Tab -->
<div class="settings-panel" id="tab-backup">
<!-- Local Backup -->
<div class="settings-card">
<h4 data-i18n="settings.backup.local_title">💾 Backup Locale</h4>
<p class="settings-hint" data-i18n="settings.backup.local_hint">Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).</p>
<div id="backup-last-info" style="margin-bottom:12px;padding:10px 12px;background:var(--bg-secondary,#f8fafc);border-radius:8px;font-size:0.83rem;color:var(--text-secondary)">
<span data-i18n="settings.info.loading">Caricamento…</span>
</div>
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
<label data-i18n="settings.backup.retention_days" style="flex-shrink:0">Retention (giorni):</label>
<input type="number" id="setting-backup-retention-days" class="form-input" style="width:80px" min="1" max="90" value="3">
</div>
<button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button>
<div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div>
<!-- List of backups -->
<div id="backup-list-container" style="margin-top:14px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- Google Drive -->
<div class="settings-card">
<h4 data-i18n="settings.backup.gdrive_title">☁️ Google Drive</h4>
<p class="settings-hint" data-i18n="settings.backup.gdrive_hint">Carica automaticamente il backup su Google Drive usando un Service Account.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span data-i18n="settings.backup.gdrive_enabled">Abilita backup Google Drive</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-gdrive-enabled" onchange="saveSettings()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div id="gdrive-config-section">
<!-- Folder ID (shared between both methods) -->
<div class="form-group">
<label data-i18n="settings.backup.gdrive_folder_id">ID Cartella Drive</label>
<input type="text" id="setting-gdrive-folder-id" class="form-input" placeholder="1ABCdef_xyz…">
<p class="settings-hint" data-i18n="settings.backup.gdrive_folder_id_hint">Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong></p>
</div>
<!-- OAuth 2.0 section -->
<div id="gdrive-oauth-section">
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
<summary style="cursor:pointer;font-weight:600;font-size:0.83rem" data-i18n="settings.backup.gdrive_oauth_how_to">📋 Come configurare OAuth 2.0 (passo dopo passo)</summary>
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8" data-i18n-html="settings.backup.gdrive_oauth_steps"></ol>
</details>
<div class="form-group">
<label data-i18n="settings.backup.gdrive_client_id">Client ID</label>
<input type="text" id="setting-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com">
</div>
<div class="form-group">
<label data-i18n="settings.backup.gdrive_client_secret">Client Secret</label>
<input type="password" id="setting-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…">
</div>
<div class="form-group" style="background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px;font-size:0.82rem">
<span data-i18n="settings.backup.gdrive_redirect_uri_label">Redirect URI (aggiungi in Google Cloud Console):</span>
<code id="gdrive-redirect-uri-display" style="display:block;margin-top:4px;word-break:break-all;color:var(--text-primary);font-size:0.78rem">http://localhost</code>
<p class="settings-hint" style="margin-top:6px;margin-bottom:0" data-i18n="settings.backup.gdrive_redirect_uri_hint">Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa <strong>http://localhost</strong>.</p>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px">
<button class="btn btn-secondary" onclick="_gdriveAuthorize()" id="btn-gdrive-authorize" data-i18n="settings.backup.gdrive_oauth_authorize">🔑 Autorizza con Google</button>
<span id="gdrive-oauth-token-status" style="font-size:0.83rem"></span>
</div>
<!-- Manual code entry (appears after clicking Authorize) -->
<div id="gdrive-code-section" style="display:none;margin-top:12px;padding:12px 14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;border:1px solid var(--border)">
<p style="font-size:0.82rem;margin-bottom:8px;font-weight:600" data-i18n="settings.backup.gdrive_code_title">Incolla l'URL o il codice di autorizzazione</p>
<p class="settings-hint" style="margin-bottom:8px" data-i18n="settings.backup.gdrive_code_hint">Dopo aver autorizzato su Google, il browser proverà ad aprire <code>http://localhost</code> e mostrerà un errore. Copia l'intero URL dalla barra degli indirizzi e incollalo qui sotto.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="text" id="gdrive-code-input" class="form-input" style="flex:1;min-width:0" placeholder="http://localhost/?code=4%2F0A… oppure solo il codice">
<button class="btn btn-primary" onclick="_gdriveSubmitCode()" id="btn-gdrive-submit-code" data-i18n="settings.backup.gdrive_code_submit">Conferma</button>
</div>
</div>
</div>
<!-- Retention + action buttons (shared) -->
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
<label data-i18n="settings.backup.gdrive_retention_days" style="flex-shrink:0">Retention Drive (giorni, 0=tutto):</label>
<input type="number" id="setting-gdrive-retention-days" class="form-input" style="width:80px" min="0" max="365" value="30">
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<button class="btn btn-secondary" onclick="_gdriveTest()" id="btn-gdrive-test" data-i18n="settings.backup.gdrive_test">🔗 Testa Connessione</button>
<button class="btn btn-accent" onclick="_gdrivePushNow()" id="btn-gdrive-push" data-i18n="settings.backup.gdrive_push_now">☁️ Carica Ora su Drive</button>
</div>
<div id="gdrive-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
</div>
</div>
<!-- Info Tab --> <!-- Info Tab -->
<div class="settings-panel" id="tab-info"> <div class="settings-panel" id="tab-info">
<!-- Gemini AI Usage card --> <!-- Gemini AI Usage card -->
+47
View File
@@ -762,6 +762,53 @@
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.", "card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
"label": "Tipps beim Kochen anzeigen" "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/<strong>ID</strong>",
"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 <strong>http://localhost</strong> 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. <code>http://localhost/?code=4%2F0A...</code>) 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": "<li>Gehe zu <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> und wähle dein Projekt</li><li>Aktiviere die <strong>Google Drive API</strong>: <em>APIs &amp; Dienste → APIs aktivieren → Google Drive API</em></li><li>Gehe zu <em>APIs &amp; Dienste → Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID</em></li><li>Anwendungstyp: <strong>Webanwendung</strong>; füge die unten angezeigte URL als <em>Autorisierter Weiterleitungs-URI</em> hinzu</li><li>Kopiere <strong>Client-ID</strong> und <strong>Client-Secret</strong> in die Felder oben und speichere</li><li>Klicke auf <strong>Mit Google autorisieren</strong>: melde dich an und erteile den Zugriff</li><li>Das Fenster schließt sich automatisch und Backups sind bereit</li>"
},
"info": { "info": {
"tab": "Info", "tab": "Info",
"ai_title": "Gemini AI — Token-Nutzung", "ai_title": "Gemini AI — Token-Nutzung",
+47
View File
@@ -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.", "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" "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/<strong>ID</strong>",
"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 <strong>http://localhost</strong> 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. <code>http://localhost/?code=4%2F0A...</code>) 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": "<li>Go to <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> and select your project</li><li>Enable the <strong>Google Drive API</strong>: <em>APIs &amp; Services → Enable APIs → Google Drive API</em></li><li>Go to <em>APIs &amp; Services → Credentials → Create Credentials → OAuth client ID</em></li><li>Application type: <strong>Web application</strong>; add <strong>http://localhost</strong> as an <em>Authorized redirect URI</em></li><li>Copy the <strong>Client ID</strong> and <strong>Client Secret</strong> into the fields above and save</li><li>Click <strong>Authorize with Google</strong>, sign in and grant access</li><li>The browser will open <code>http://localhost</code> (a connection error is expected): copy the URL from the address bar and paste it in the field that appears below</li>"
},
"info": { "info": {
"tab": "Info", "tab": "Info",
"ai_title": "Gemini AI — Token Usage", "ai_title": "Gemini AI — Token Usage",
+47
View File
@@ -759,6 +759,53 @@
"card_title": "♻️ Consejos sin desperdicios", "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.", "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" "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/<strong>ID</strong>",
"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 <strong>http://localhost</strong> 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. <code>http://localhost/?code=4%2F0A...</code>) 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": "<li>Ve a <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> y selecciona tu proyecto</li><li>Habilita la <strong>API de Google Drive</strong>: <em>API y servicios → Habilitar API → Google Drive API</em></li><li>Ve a <em>API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth</em></li><li>Tipo de aplicación: <strong>Aplicación web</strong>; agrega la URL mostrada abajo como <em>URI de redirección autorizado</em></li><li>Copia el <strong>Client ID</strong> y el <strong>Client Secret</strong> en los campos de arriba y guarda</li><li>Haz clic en <strong>Autorizar con Google</strong>: inicia sesión en tu cuenta de Google y concede acceso</li><li>La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas</li>"
} }
}, },
"expiry": { "expiry": {
+47
View File
@@ -759,6 +759,53 @@
"card_title": "♻️ Conseils zéro déchet", "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.", "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" "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/<strong>ID</strong>",
"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 <strong>http://localhost</strong> 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. <code>http://localhost/?code=4%2F0A...</code>) 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": "<li>Allez sur <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> et sélectionnez votre projet</li><li>Activez l<strong>API Google Drive</strong> : <em>API et services → Activer les API → Google Drive API</em></li><li>Allez dans <em>API et services → Identifiants → Créer des identifiants → ID client OAuth</em></li><li>Type dapplication : <strong>Application Web</strong> ; ajoutez lURL affichée ci-dessous comme <em>URI de redirection autorisé</em></li><li>Copiez le <strong>Client ID</strong> et le <strong>Client Secret</strong> dans les champs ci-dessus et enregistrez</li><li>Cliquez sur <strong>Autoriser avec Google</strong> : connectez-vous et accordez laccès</li><li>La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes</li>"
} }
}, },
"expiry": { "expiry": {
+47
View File
@@ -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.", "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" "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/<strong>ID</strong>",
"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 <strong>http://localhost</strong> 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": "<li>Vai su <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> e seleziona il progetto</li><li>Abilita la <strong>Google Drive API</strong>: <em>API e servizi → Abilita API → Google Drive API</em></li><li>Vai su <em>API e servizi → Credenziali → Crea credenziali → ID client OAuth 2.0</em></li><li>Tipo applicazione: <strong>Applicazione web</strong>; aggiungi <strong>http://localhost</strong> come <em>URI di reindirizzamento autorizzato</em></li><li>Copia <strong>Client ID</strong> e <strong>Client Secret</strong> nei campi qui sopra e salva</li><li>Clicca <strong>Autorizza con Google</strong>, accedi e concedi l'accesso</li><li>Il browser aprirà <code>http://localhost</code> (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sotto</li>",
"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. <code>http://localhost/?code=4%2F0A...</code>) e incollalo qui.",
"gdrive_code_submit": "Conferma",
"gdrive_code_empty": "Incolla prima l'URL o il codice di autorizzazione"
},
"info": { "info": {
"tab": "Info", "tab": "Info",
"ai_title": "Gemini AI — Utilizzo Token", "ai_title": "Gemini AI — Utilizzo Token",