diff --git a/.env.example b/.env.example index 3c7baeb..e51acb2 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,41 @@ PRICE_CURRENCY=EUR # PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3) PRICE_UPDATE_MONTHS=3 +# ── Cleanup / retention ────────────────────────────────────────────────────── +# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days +RECIPE_RETENTION_DAYS=7 +# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days. +# Smart Shopping uses this history to compute purchase frequencies. +# WARNING: values below 30 will cause the shopping list to appear nearly empty. +# Minimum enforced at runtime: 30 days. +TRANSACTION_RETENTION_DAYS=90 + +# ── Local Backup ───────────────────────────────────────────────────────────── +# BACKUP_ENABLED: run a daily incremental backup via cron (true/false) +BACKUP_ENABLED=true +# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1) +BACKUP_RETENTION_DAYS=3 + +# ── Google Drive Backup ─────────────────────────────────────────────────────── +# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account) +GDRIVE_ENABLED=false +# +# Setup steps: +# 1. Create a Google Cloud project and enable the Drive API +# 2. Create a Service Account and download the JSON key +# 3. Create a Drive folder and share it with the service account email +# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path) +# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL) +# +# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key +GDRIVE_SERVICE_ACCOUNT_JSON= +# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file +GDRIVE_SERVICE_ACCOUNT_FILE= +# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored +GDRIVE_FOLDER_ID= +# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all) +GDRIVE_RETENTION_DAYS=30 + # ── Security ───────────────────────────────────────────────────────────────── # SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes. # Leave empty to allow anyone with access to the server to change settings. diff --git a/CHANGELOG.md b/CHANGELOG.md index 65bfa3f..257ded7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.24] - 2026-05-21 + +### Fixed +- **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE. +- **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView. +- **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit. +- **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page. +- **SQLite database locked during inventory update** — `updateInventory()` made 3–4 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110). +- **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent. +- **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log. +- **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection. +- **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit. +- **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load. + +### Added +- **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105). +- **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107). + +### Changed +- **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically. + ## [1.7.23] - 2026-05-18 ### Added diff --git a/README.md b/README.md index 3a3d128..3520912 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/) -[![Version](https://img.shields.io/badge/version-1.7.19-brightgreen.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.24-brightgreen.svg)](CHANGELOG.md) [![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors) @@ -39,7 +39,7 @@ ## ✨ Features > ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry** -> A new **Generali** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place. +> A new **General** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place. > Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small. > Vacuum-sealed products get an extended grace period (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days) before being flagged as expired. > Auto theme now follows **time of day** (dark 20:00–07:00) instead of the OS setting, making it server-friendly. @@ -100,7 +100,7 @@ ### 🌙 Appearance - **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash -- **Global settings tab** — A dedicated **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel +- **Global settings tab** — A dedicated **⚙️ General** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel ### �️ Database Maintenance - **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact @@ -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 @@ -421,6 +439,54 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g --- +## 🤝 Contributing + +EverShelf is a community project and contributions of any size are welcome! + +### Easiest way to start — translate EverShelf into your language + +Translations are just JSON files. No coding, no setup — fork → edit → PR. + +``` +translations/ +├── it.json ✅ Italian (base) +├── en.json ✅ English +├── de.json ✅ German +├── fr.json ✅ French +├── es.json ✅ Spanish +├── pt.json ❌ Portuguese — wanted! +├── nl.json ❌ Dutch — wanted! +└── ... ❌ Your language here! +``` + +👉 See [issue #93](https://github.com/dadaloop82/EverShelf/issues/93) to claim a language. + +### Other ways to contribute + +| What | Skill needed | +|---|---| +| 🐛 Report a bug | None | +| 📖 Improve the wiki | Markdown | +| 🌍 Add a translation | JSON editing | +| 🎨 Fix a CSS/UI issue | CSS / HTML | +| ⚙️ Implement a feature | PHP / JS | +| ⭐ Star the repo | Clicking | + +👉 Browse [`help wanted`](https://github.com/dadaloop82/EverShelf/labels/help%20wanted) issues for good starting points. + +Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide (branch naming, code style, how to run locally). + +--- + +## 💬 Community + +Join the conversation in [GitHub Discussions](https://github.com/dadaloop82/EverShelf/discussions): +- **Vote on upcoming features** — tell us what to build next +- **Show your setup** — share your kitchen kiosk +- **Ask questions** — get help from the community + +--- + ## 📄 License This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details. diff --git a/api/cron_smart_shopping.php b/api/cron_smart_shopping.php index 6fdbce9..2bb8fcb 100644 --- a/api/cron_smart_shopping.php +++ b/api/cron_smart_shopping.php @@ -87,11 +87,45 @@ try { ob_end_clean(); echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done' . ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd' - . ', tx >' . env('TRANSACTION_RETENTION_DAYS','7') . 'd' . ")\n"; + . ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n"; } catch (Throwable $ce) { echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n"; } + // ── Daily incremental backup ────────────────────────────────────────── + // Create a local backup at most once every 23 h; also push to Google Drive + // if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even + // though the cron runs every 5 minutes. + if (env('BACKUP_ENABLED', 'true') === 'true') { + try { + $lastBackupTs = 0; + if (file_exists(BACKUP_LAST_TS_PATH)) { + $lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: []; + $lastBackupTs = (int)($lastData['ts'] ?? 0); + } + if (time() - $lastBackupTs >= 82800) { // 23 h + $backupResult = createLocalBackup($db); + if ($backupResult['success']) { + echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename'] + . ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n"; + if (env('GDRIVE_ENABLED', 'false') === 'true') { + $gResult = backupToGDrive($db); + if ($gResult['success']) { + echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK' + . ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n"; + } else { + echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n"; + } + } + } else { + echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n"; + } + } + } catch (Throwable $be) { + echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n"; + } + } + } catch (Throwable $e) { $msg = $e->getMessage(); echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n"; diff --git a/api/database.php b/api/database.php index 5d3877a..e8cccda 100644 --- a/api/database.php +++ b/api/database.php @@ -48,6 +48,13 @@ function getDB(): PDO { ? new LoggingPDO('sqlite:' . DB_PATH) : new PDO('sqlite:' . DB_PATH); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + // Set a busy timeout to prevent "database is locked" errors under high concurrency. + // This gives SQLite up to 5 seconds to acquire a lock before throwing an exception. + $db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite. + // For SQLite, we use PRAGMA busy_timeout. + $db->exec('PRAGMA journal_mode = WAL;'); + $db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds + $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $db->exec("PRAGMA journal_mode=WAL"); $db->exec("PRAGMA foreign_keys=ON"); @@ -244,6 +251,22 @@ function migrateDB(PDO $db): void { // Ensure composite indexes exist (added in v1.7.5 for performance) $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)"); + + // Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal + $shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll(); + if (empty($shopTables)) { + $db->exec(" + CREATE TABLE shopping_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + raw_name TEXT NOT NULL DEFAULT '', + specification TEXT NOT NULL DEFAULT '', + added_at INTEGER DEFAULT (strftime('%s','now')), + sort_order INTEGER DEFAULT 0 + ) + "); + $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))"); + } } /** diff --git a/api/index.php b/api/index.php index 20fafd2..867fae2 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'; @@ -391,7 +399,7 @@ if (($_GET['action'] ?? '') === 'health_check') { 'ok' => $freeBytes === false || $freeBytes > 50*1048576, 'value' => $freeMB !== null ? $freeMB.' MB liberi' : null, 'optional' => true, - 'hint' => $freeBytes !== false && $freeBytes <= 50*1048576 ? 'Meno di 50 MB liberi — libera spazio sul disco' : null, + 'hint' => $freeBytes !== false && $freeBytes <= 50*1048576 ? 'Less than 50 MB free — free up disk space' : null, ]; // ── 8. SQLite database ──────────────────────────────────────────────────── @@ -419,11 +427,11 @@ if (($_GET['action'] ?? '') === 'health_check') { $checks['db_legacy'] = [ 'ok' => !$hasLegacy, 'optional' => true, - 'hint' => $hasLegacy ? 'Trovato vecchio dispensa.db — il file è ormai obsoleto, puoi eliminarlo manualmente' : null, + 'hint' => $hasLegacy ? 'Legacy dispensa.db found — the file is obsolete, you can delete it manually' : null, ]; if ($isFresh) { - $checks['db_connect'] = ['ok' => true, 'fresh' => true, 'value' => 'nuovo impianto']; + $checks['db_connect'] = ['ok' => true, 'fresh' => true, 'value' => 'fresh install']; $checks['db_tables'] = ['ok' => true, 'fresh' => true]; $checks['db_integrity'] = ['ok' => true, 'fresh' => true]; $checks['db_wal'] = ['ok' => true, 'fresh' => true, 'optional' => true]; @@ -441,7 +449,7 @@ if (($_GET['action'] ?? '') === 'health_check') { $checks['db_connect'] = ['ok' => true, 'value' => basename($dbPath)]; } catch (\Throwable $e) { $checks['db_connect'] = ['ok' => false, 'error' => $e->getMessage(), - 'hint' => 'Impossibile aprire il database — verifica permessi su data/evershelf.db']; + 'hint' => 'Cannot open the database — check permissions on data/evershelf.db']; } if ($dbConnOk && $pdo) { @@ -452,7 +460,7 @@ if (($_GET['action'] ?? '') === 'health_check') { $checks['db_tables'] = [ 'ok' => empty($missing), 'missing' => $missing, - 'hint' => !empty($missing) ? 'Tabelle mancanti: ' . implode(', ', $missing) . ' — esegui una chiamata API per auto-inizializzare il DB' : null, + 'hint' => !empty($missing) ? 'Missing tables: ' . implode(', ', $missing) . ' — call any API endpoint to auto-initialize the DB' : null, ]; // Integrity @@ -466,7 +474,7 @@ if (($_GET['action'] ?? '') === 'health_check') { // WAL $wal = $pdo->query("PRAGMA journal_mode")->fetchColumn(); $checks['db_wal'] = ['ok' => $wal === 'wal', 'value' => $wal, 'optional' => true, - 'hint' => $wal !== 'wal' ? 'Modalità journal non ottimale — sarà corretta automaticamente al primo avvio' : null]; + 'hint' => $wal !== 'wal' ? 'Journal mode not optimal — will be corrected automatically on next startup' : null]; // Size & rows $checks['db_size'] = ['ok' => true, 'value' => round(filesize($dbPath)/1024).' KB', 'optional' => true]; @@ -474,7 +482,7 @@ if (($_GET['action'] ?? '') === 'health_check') { $checks['db_row_count'] = ['ok' => true, 'value' => $cnt.' prodotti in inventario', 'optional' => true]; } else { foreach (['db_tables', 'db_integrity'] as $k) - $checks[$k] = ['ok' => false, 'hint' => 'Impossibile verificare — connessione DB fallita']; + $checks[$k] = ['ok' => false, 'hint' => 'Cannot verify — DB connection failed']; foreach (['db_wal', 'db_size', 'db_row_count'] as $k) $checks[$k] = ['ok' => false, 'optional' => true]; } @@ -492,18 +500,39 @@ if (($_GET['action'] ?? '') === 'health_check') { $geminiKey = $envGet('GEMINI_API_KEY'); if (!empty($geminiKey)) { $checks['gemini_key'] = ['ok' => strlen($geminiKey) > 20, 'optional' => true, - 'hint' => strlen($geminiKey) <= 20 ? 'Chiave Gemini AI sembra troppo corta — verifica il valore in .env' : null]; + 'hint' => strlen($geminiKey) <= 20 ? 'Gemini AI key looks too short — check the value in .env' : null]; } else { $checks['gemini_key'] = ['ok' => true, 'optional' => true, - 'value' => 'non configurata', 'hint' => 'Configura GEMINI_API_KEY in .env per abilitare le funzioni AI']; + 'value' => 'not configured', 'hint' => 'Set GEMINI_API_KEY in .env to enable AI features']; } // ── 11. Bring! — solo se EMAIL+PASSWORD sono impostate ─────────────────── // Se non configurata, l'utente ha scelto di non usarla → nessun check, nessun warning. $bringEmail = $envGet('BRING_EMAIL'); $bringPassword = $envGet('BRING_PASSWORD'); - $bringEnabled = !empty($bringEmail) && !empty($bringPassword); - // If Bring! not configured, skip entirely — not a warning, it's a user choice + $shoppingMode = $envGet('SHOPPING_MODE') ?: 'native'; + $bringEnabled = !empty($bringEmail) && !empty($bringPassword) && $shoppingMode === 'bring'; + if ($bringEnabled) { + $checks['bring_credentials'] = ['ok' => true, 'optional' => true]; + // Token file is created automatically on first shopping list access — not an error if missing + $bringTokenFile = $dataDir . '/bring_token.json'; + $bringTokenOk = true; // default: fine (missing = not yet obtained, will auto-create) + $bringTokenHint = null; + if (file_exists($bringTokenFile)) { + $bringData = @json_decode(@file_get_contents($bringTokenFile), true); + $hasToken = !empty($bringData['access_token'] ?? ($bringData['accessToken'] ?? '')); + $expired = isset($bringData['expires']) && $bringData['expires'] < time(); + if (!$hasToken && !$expired) { + // File exists but token field missing — corrupt + $bringTokenOk = false; + $bringTokenHint = 'Bring! token file present but appears invalid — delete data/bring_token.json to regenerate'; + } + // Expired token is OK: it will be refreshed automatically + } + // Missing token file = first launch, will be created automatically → no warning + $checks['bring_token'] = ['ok' => $bringTokenOk, 'optional' => true, 'hint' => $bringTokenHint]; + } + // If Bring! not configured or SHOPPING_MODE != bring, skip entirely — not a warning, it is a deliberate user choice // ── 12. TTS — solo se TTS_ENABLED ──────────────────────────────────────── if ($envGet('TTS_ENABLED') === 'true') { @@ -511,7 +540,7 @@ if (($_GET['action'] ?? '') === 'health_check') { $checks['tts_url'] = [ 'ok' => !empty($ttsUrl), 'optional' => true, - 'hint' => empty($ttsUrl) ? 'TTS_ENABLED=true ma TTS_URL non configurata' : null, + 'hint' => empty($ttsUrl) ? 'TTS_ENABLED=true but TTS_URL not configured' : null, ]; } @@ -521,7 +550,7 @@ if (($_GET['action'] ?? '') === 'health_check') { $checks['scale_gateway'] = [ 'ok' => !empty($scaleUrl), 'optional' => true, - 'hint' => empty($scaleUrl) ? 'SCALE_ENABLED=true ma SCALE_GATEWAY_URL non configurata' : null, + 'hint' => empty($scaleUrl) ? 'SCALE_ENABLED=true but SCALE_GATEWAY_URL not configured' : null, ]; } @@ -546,7 +575,7 @@ if (($_GET['action'] ?? '') === 'health_check') { curl_close($ch); $internetOk = $httpCode > 0 || $curlErrNo === 0; $checks['internet'] = ['ok' => $internetOk, 'optional' => true, - 'hint' => !$internetOk ? 'Impossibile raggiungere i server Gemini — le funzioni AI non funzioneranno senza connessione internet' : null]; + 'hint' => !$internetOk ? 'Cannot reach Gemini servers — AI features will not work without an internet connection' : null]; } // ── Compute overall result ──────────────────────────────────────────────── @@ -653,6 +682,7 @@ $_writeActions = [ 'inventory_add','inventory_use','inventory_update','inventory_remove', 'product_save','product_delete','product_merge', 'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names', + 'shopping_add','shopping_remove', 'dismiss_anomaly','save_settings', ]; if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($rateLimitAction, $_writeActions, true)) { @@ -690,6 +720,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)'); @@ -827,6 +858,19 @@ try { case 'bring_suggest': bringSuggestItems($db); break; + // Shopping abstraction layer (delegates to internal DB or Bring!) + case 'shopping_list': + shoppingGetList($db); + break; + case 'shopping_add': + shoppingAdd($db); + break; + case 'shopping_remove': + shoppingRemove($db); + break; + case 'shopping_suggest': + bringSuggestItems($db); + break; case 'smart_shopping': smartShoppingCached($db); break; @@ -908,6 +952,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; @@ -2116,36 +2252,50 @@ function updateInventory(PDO $db): void { $fields[] = "updated_at = CURRENT_TIMESTAMP"; $params[] = $id; - $stmt = $db->prepare("UPDATE inventory SET " . implode(', ', $fields) . " WHERE id = ?"); - $stmt->execute($params); + // Wrap all writes in a single transaction to avoid concurrent lock failures. + $db->beginTransaction(); + try { + $stmt = $db->prepare("UPDATE inventory SET " . implode(', ', $fields) . " WHERE id = ?"); + $stmt->execute($params); - // Record a compensating transaction so anomaly detection stays accurate - if (isset($input['quantity']) && $prevRow) { - $oldQty = (float)$prevRow['quantity']; - $newQty = (float)$input['quantity']; - $diff = round($newQty - $oldQty, 6); - $loc = $input['location'] ?? $prevRow['location']; - $pid = (int)$prevRow['product_id']; - if (abs($diff) > 0.001) { - $txType = $diff > 0 ? 'in' : 'out'; - $txQty = abs($diff); - $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, '[Correzione manuale]')") - ->execute([$pid, $txType, $txQty, $loc]); + // Record a compensating transaction so anomaly detection stays accurate + if (isset($input['quantity']) && $prevRow) { + $oldQty = (float)$prevRow['quantity']; + $newQty = (float)$input['quantity']; + $diff = round($newQty - $oldQty, 6); + $loc = $input['location'] ?? $prevRow['location']; + $pid = (int)$prevRow['product_id']; + if (abs($diff) > 0.001) { + $txType = $diff > 0 ? 'in' : 'out'; + $txQty = abs($diff); + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, '[Manual correction]')") + ->execute([$pid, $txType, $txQty, $loc]); + } } + + // Update unit on the product if provided + if (isset($input['unit']) && isset($input['product_id'])) { + $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$input['unit'], $input['product_id']]); + } + + // Update package info if provided + if (isset($input['package_unit']) && isset($input['product_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']]); + } + + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + throw $e; } - // Update unit on the product if provided - if (isset($input['unit']) && isset($input['product_id'])) { - $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$input['unit'], $input['product_id']]); + // Real-time Bring! sync: done after commit so DB lock is not held during HTTP call + if (isset($input['quantity']) && $prevRow && abs((float)$input['quantity'] - (float)$prevRow['quantity']) > 0.001) { + try { bringQuickSyncProduct($db, (int)$prevRow['product_id']); } catch (Throwable $e) {} } - - // Update package info if provided - if (isset($input['package_unit']) && isset($input['product_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']]); - } - + echo json_encode(['success' => true]); } @@ -2254,7 +2404,7 @@ function listTransactions(PDO $db): void { $productId = $_GET['product_id'] ?? ''; $query = " - SELECT t.*, p.name, p.brand, p.unit + SELECT t.*, p.name, p.brand, p.unit, p.default_quantity, p.package_unit FROM transactions t JOIN products p ON t.product_id = p.id "; @@ -2331,7 +2481,7 @@ function undoTransaction(PDO $db): void { } } // Log counter-transaction - $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, '[Annullato]')")->execute([$productId, $quantity, $location]); + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, '[Undone]')")->execute([$productId, $quantity, $location]); } elseif ($type === 'out' || $type === 'waste') { // Reverse a USE: add quantity back to inventory @@ -2345,7 +2495,7 @@ function undoTransaction(PDO $db): void { $db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)")->execute([$productId, $location, $quantity]); } // Log counter-transaction - $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'in', ?, ?, '[Annullato]')")->execute([$productId, $quantity, $location]); + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'in', ?, ?, '[Undone]')")->execute([$productId, $quantity, $location]); } // Mark original as undone @@ -2915,14 +3065,31 @@ 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')), + // Shopping list + 'shopping_enabled' => env('SHOPPING_ENABLED', 'true') === 'true', + 'shopping_mode' => env('SHOPPING_MODE', 'internal'), + 'shopping_smart_suggestions' => env('SHOPPING_SMART_SUGGESTIONS', 'true') === 'true', + 'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true', + 'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'), + 'dark_mode' => env('DARK_MODE', 'auto'), ]); } 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 +3143,11 @@ 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', + 'shopping_mode' => 'SHOPPING_MODE', + 'dark_mode' => 'DARK_MODE', ]; // Boolean keys $boolMap = [ @@ -2991,6 +3163,11 @@ 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', + 'shopping_enabled' => 'SHOPPING_ENABLED', + 'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS', + 'shopping_forecast' => 'SHOPPING_FORECAST', ]; // Integer keys $intMap = [ @@ -3000,6 +3177,9 @@ 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', + 'shopping_auto_add_threshold' => 'SHOPPING_AUTO_ADD_THRESHOLD', ]; // Float keys $floatMap = [ @@ -3031,7 +3211,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) { @@ -3599,7 +3779,7 @@ function geminiChat(PDO $db): void { $langName = recipeLangName($lang); if (empty($message)) { - echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']); + echo json_encode(['success' => false, 'error' => 'Empty message']); return; } @@ -3703,7 +3883,7 @@ PROMPT; $httpCode = $result['http_code']; if ($httpCode !== 200) { - $errMsg = $result['data']['error']['message'] ?? 'Errore API Gemini'; + $errMsg = $result['data']['error']['message'] ?? 'Gemini API error'; echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } @@ -3711,7 +3891,7 @@ PROMPT; $reply = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; if (empty($reply)) { - echo json_encode(['success' => false, 'error' => 'Risposta vuota da Gemini']); + echo json_encode(['success' => false, 'error' => 'Empty response from Gemini']); return; } @@ -4371,6 +4551,7 @@ PROMPT; } // Convert qty_number to inventory unit if mismatch detected + $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { // Weight conversions (both should be 'g' now, but handle legacy 'kg') if ($recipeUnit === 'g' && $invUnit === 'kg') { @@ -4382,22 +4563,31 @@ PROMPT; $qtyNum = $recipeVal / 1000; } elseif ($recipeUnit === 'ml' && $invUnit === 'ml') { $qtyNum = $recipeVal; - // g/ml → pz/conf (approximate to nearest piece) - } elseif ($invUnit === 'pz' || $invUnit === 'conf') { + // g/ml → conf with weight/volume pkg_unit: keep in sub-units so JS modal works + } elseif ($invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') + && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { + // Keep qty_number in sub-units; JS handles g↔conf conversion + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + $confAlreadyInSubUnit = true; + } else { + // conf without weight pkg_unit: fractional conf + $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; + } + // g/ml → pz (approximate to nearest piece) + } elseif ($invUnit === 'pz') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); if ($defQty > 0) { - // Convert recipe grams/ml to pieces using default_quantity - $qtyNum = $recipeVal / $defQty; - $qtyNum = max(0.25, round($qtyNum * 4) / 4); // round to nearest quarter + $qtyNum = max(0.25, round(($recipeVal / $defQty) * 4) / 4); } else { - // No default_quantity: AI was told to use pieces but sent grams. - // If the original qty_number looks like a piece count (≤ invQty and ≤ 100) - // keep it; otherwise fall back to 1. $origQtyNum = (float)($ing['qty_number'] ?? 0); if ($origQtyNum >= 1 && $origQtyNum <= $invQty && $origQtyNum <= 100) { - $qtyNum = $origQtyNum; // already a plausible piece count + $qtyNum = $origQtyNum; } else { - $qtyNum = 1; // safe minimum: 1 piece + $qtyNum = 1; } } } @@ -4409,6 +4599,17 @@ PROMPT; } } + // Conf+weight post-normalisation: if qty_number wasn't already set to + // sub-units above, and it looks like a fractional conf value (≤ available + // conf count), convert to grams so the JS modal shows correct grams. + if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } + } // Sanity check: qty_number should not exceed available if ($qtyNum > $invQty) { $qtyNum = $invQty; // cap to available @@ -4771,16 +4972,31 @@ function _enrichChatIngredients(array &$ingredients, array $items): void { elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz'; elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf'; } + $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { if ($recipeUnit === 'g' && $invUnit === 'g') $qtyNum = $recipeVal; elseif ($recipeUnit === 'g' && $invUnit === 'kg') $qtyNum = $recipeVal / 1000; elseif ($recipeUnit === 'ml' && $invUnit === 'ml') $qtyNum = $recipeVal; elseif ($recipeUnit === 'ml' && $invUnit === 'l') $qtyNum = $recipeVal / 1000; - elseif ($invUnit === 'pz' || $invUnit === 'conf') { + elseif ($invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { + $qtyNum = $recipeVal; $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; $confAlreadyInSubUnit = true; + } else { $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; } + } elseif ($invUnit === 'pz') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : max(1, round($recipeVal / 100)); } } + // Conf+weight: normalise fractional conf to sub-units + if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } + } if ($qtyNum > $invQty) $qtyNum = $invQty; if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal; $ing['qty_number'] = round($qtyNum, 3); @@ -5291,17 +5507,32 @@ PROMPT; elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz'; elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf'; } + $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { if ($recipeUnit === 'g' && $invUnit === 'kg') $qtyNum = $recipeVal / 1000; elseif ($recipeUnit === 'g' && $invUnit === 'g') $qtyNum = $recipeVal; elseif ($recipeUnit === 'ml' && $invUnit === 'l') $qtyNum = $recipeVal / 1000; elseif ($recipeUnit === 'ml' && $invUnit === 'ml') $qtyNum = $recipeVal; - elseif ($invUnit === 'pz' || $invUnit === 'conf') { + elseif ($invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { + $qtyNum = $recipeVal; $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; $confAlreadyInSubUnit = true; + } else { $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; } + } elseif ($invUnit === 'pz') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); if ($defQty > 0) { $qtyNum = $recipeVal / $defQty; $qtyNum = max(0.25, round($qtyNum * 4) / 4); } else $qtyNum = max(1, round($recipeVal / 100)); } } + // Conf+weight: normalise fractional conf to sub-units + if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } + } if ($qtyNum > $invQty) $qtyNum = $invQty; if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal; $ing['qty_number'] = round($qtyNum, 3); @@ -5371,7 +5602,7 @@ PROMPT; $httpCode = $result['http_code']; if ($httpCode !== 200) { - $errMsg = $result['data']['error']['message'] ?? 'Errore API Gemini'; + $errMsg = $result['data']['error']['message'] ?? 'Gemini API error'; echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } @@ -5386,7 +5617,7 @@ PROMPT; $identified = json_decode($text, true); if (!$identified || empty($identified['name'])) { - echo json_encode(['success' => false, 'error' => 'Impossibile identificare il prodotto', 'raw' => $text]); + echo json_encode(['success' => false, 'error' => 'Cannot identify the product', 'raw' => $text]); return; } @@ -6108,6 +6339,447 @@ function computeShoppingName(string $name, string $category = '', string $brand return ucfirst($name); } +/** + * Real-time shopping sync for a single product. + * Called after inventory changes (use/update/add) to keep the shopping list in sync immediately. + * Delegates to Bring! or internal DB depending on SHOPPING_MODE. + */ +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); + + $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']); + + if (isShoppingBringMode()) { + // Delegate to Bring! + $auth = bringAuth(); + if (!$auth) return; + $listUUID = $auth['bringListUUID']; + $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) { + $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) { + 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]); + } + } else { + // Internal mode + $threshold = (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'); + $stmtCheck = $db->prepare("SELECT id FROM shopping_list WHERE lower(name) = lower(?)"); + $stmtCheck->execute([$genericName]); + $onList = (bool)$stmtCheck->fetch(); + + if ($totalQty <= $threshold && !$onList) { + $spec = $genericName !== $prod['name'] + ? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') + : ($prod['brand'] ?: ''); + $db->prepare("INSERT OR IGNORE INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)") + ->execute([$genericName, $prod['name'], $spec]); + EverLog::info('shoppingQuickSync: added to internal list', ['product_id' => $productId, 'name' => $genericName]); + } elseif ($totalQty > $threshold && $onList) { + $db->prepare("DELETE FROM shopping_list WHERE lower(name) = lower(?)")->execute([$genericName]); + EverLog::info('shoppingQuickSync: removed from internal list', ['product_id' => $productId, 'name' => $genericName]); + } + } +} + +// ===== LOCAL BACKUP ===== + +/** + * Create a timestamped local backup of evershelf.db. + * WAL-checkpointed before copy. Purges backups older than BACKUP_RETENTION_DAYS. + */ +function createLocalBackup(?PDO $db = null): array { + EverLog::info('createLocalBackup'); + $backupDir = BACKUP_DIR; + if (!is_dir($backupDir) && !mkdir($backupDir, 0755, true)) { + return ['success' => false, 'error' => 'Cannot create backup directory']; + } + + $dbFile = __DIR__ . '/../data/evershelf.db'; + if (!file_exists($dbFile)) { + return ['success' => false, 'error' => 'Database file not found']; + } + + // WAL checkpoint: flush WAL into main DB file before copying + try { + $pdo = $db ?? getDB(); + $pdo->exec('PRAGMA wal_checkpoint(FULL)'); + } catch (Throwable $e) { /* non-fatal */ } + + $date = date('Y-m-d_Hi'); + $filename = "evershelf_{$date}.db"; + $destPath = "$backupDir/$filename"; + + if (!copy($dbFile, $destPath)) { + return ['success' => false, 'error' => 'Failed to copy database file']; + } + + // Purge local backups older than retention + $retentionDays = max(1, (int)env('BACKUP_RETENTION_DAYS', '3')); + $cutoff = strtotime("-{$retentionDays} days"); + $purged = 0; + foreach (glob("$backupDir/evershelf_*.db") ?: [] as $f) { + if ($f !== $destPath && filemtime($f) < $cutoff) { + unlink($f); + $purged++; + } + } + + $sizeKb = (int)round(filesize($destPath) / 1024); + $result = [ + 'success' => true, + 'filename' => $filename, + 'path' => $destPath, + 'size_kb' => $sizeKb, + 'purged' => $purged, + 'created_at' => date('c'), + ]; + + // Update last-backup timestamp file + file_put_contents(BACKUP_LAST_TS_PATH, json_encode(['ts' => time(), 'filename' => $filename, 'size_kb' => $sizeKb])); + + return $result; +} + +/** + * List local backup files with metadata. + */ +function listLocalBackups(): array { + $backupDir = BACKUP_DIR; + $backups = []; + foreach (glob("$backupDir/evershelf_*.db") ?: [] as $f) { + $backups[] = [ + 'filename' => basename($f), + 'size_kb' => (int)round(filesize($f) / 1024), + 'created_at' => date('c', filemtime($f)), + ]; + } + usort($backups, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); + + $lastTs = []; + if (file_exists(BACKUP_LAST_TS_PATH)) { + $lastTs = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: []; + } + + return [ + 'success' => true, + 'backups' => $backups, + 'last_backup_ts' => $lastTs['ts'] ?? null, + 'last_backup_file'=> $lastTs['filename'] ?? null, + 'retention_days' => max(1, (int)env('BACKUP_RETENTION_DAYS', '3')), + ]; +} + +/** + * Delete a specific local backup file. + */ +function deleteLocalBackup(string $filename): array { + if (!preg_match('/^evershelf_\d{4}-\d{2}-\d{2}_\d{4}\.db$/', $filename)) { + return ['success' => false, 'error' => 'Invalid backup filename']; + } + $path = BACKUP_DIR . '/' . $filename; + if (!file_exists($path)) { + return ['success' => false, 'error' => 'File not found']; + } + return unlink($path) ? ['success' => true] : ['success' => false, 'error' => 'Failed to delete file']; +} + +/** + * Restore a local backup: replaces the current evershelf.db. + * Clears WAL/SHM files and invalidates smart shopping cache. + */ +function restoreLocalBackup(string $filename, PDO $db): array { + if (!preg_match('/^evershelf_\d{4}-\d{2}-\d{2}_\d{4}\.db$/', $filename)) { + return ['success' => false, 'error' => 'Invalid backup filename']; + } + $backupPath = BACKUP_DIR . '/' . $filename; + if (!file_exists($backupPath)) { + return ['success' => false, 'error' => 'Backup file not found']; + } + $dbPath = __DIR__ . '/../data/evershelf.db'; + + // Flush WAL before replacing DB + try { $db->exec('PRAGMA wal_checkpoint(FULL)'); } catch (Throwable $e) {} + + if (!copy($backupPath, $dbPath)) { + return ['success' => false, 'error' => 'Failed to restore backup']; + } + // Remove stale WAL/SHM so next connection starts clean + @unlink($dbPath . '-wal'); + @unlink($dbPath . '-shm'); + // Invalidate dependent caches + @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); + + EverLog::info('restoreLocalBackup', ['filename' => $filename]); + return ['success' => true, 'message' => 'Restore complete — reload the page to see the restored data.']; +} + +// ===== GOOGLE DRIVE BACKUP ===== + +/** Write / overwrite a single key in the .env file (used by OAuth callback). */ +function _gdriveSetEnvVar(string $key, string $value): void { + $envFile = __DIR__ . '/../.env'; + $envVars = loadEnv(); + $envVars[$key] = $value; + $lines = []; + foreach ($envVars as $k => $v) { $lines[] = "$k=$v"; } + file_put_contents($envFile, implode("\n", $lines) . "\n"); +} + +/** + * Build the OAuth 2.0 redirect URI for the server-side callback. + * Used only for _gdriveHandleOAuthCallback (legacy flow). + * The interactive auth URL now uses GDRIVE_REDIRECT_URI or http://localhost instead. + */ +function _gdriveRedirectUri(): string { + $override = env('GDRIVE_REDIRECT_URI', ''); + if (!empty($override)) return $override; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + return "$scheme://$host/api/index.php?action=gdrive_oauth_callback"; +} + +/** + * Get an access token using a stored OAuth 2.0 refresh token. + */ +function _gdriveGetTokenOAuth(): array { + $clientId = env('GDRIVE_CLIENT_ID', ''); + $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); + $refreshToken = env('GDRIVE_REFRESH_TOKEN', ''); + if (!$clientId || !$clientSecret) { + return ['error' => 'GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET are required for OAuth']; + } + if (!$refreshToken) { + return ['error' => 'Not authorized yet — click "Authorize with Google" first']; + } + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]), + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $curlErr = curl_error($ch); + curl_close($ch); + if (!$response) return ['error' => 'cURL failed: ' . $curlErr]; + $data = json_decode($response, true); + if (!empty($data['access_token'])) return ['token' => $data['access_token']]; + return ['error' => 'OAuth refresh error: ' . ($data['error_description'] ?? $data['error'] ?? $response)]; +} + +/** + * Handle the OAuth 2.0 callback: exchange the code for tokens, store refresh_token. + * Returns HTML (not JSON) — must be called before Content-Type header is sent. + */ +function _gdriveHandleOAuthCallback(): void { + $code = $_GET['code'] ?? ''; + if (empty($code)) { + http_response_code(400); + header('Content-Type: text/html; charset=utf-8'); + echo '

❌ Error

No authorization code received.

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

✔ Google Drive Authorized!

' + . '

EverShelf can now back up to your Google Drive.

' + . '

This tab will close automatically.

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

❌ Authorization failed

$err

"; + } +} + +/** + * Obtain a short-lived Google API access token via OAuth 2.0 refresh token. + * Returns ['token' => string] on success, ['error' => string] on failure. + */ +function _gdriveGetToken(): ?string { return _gdriveGetTokenOAuth()['token'] ?? null; } +function _gdriveGetTokenEx(): array { return _gdriveGetTokenOAuth(); } + +/** + * Upload a file to Google Drive using multipart upload. + * Returns the Drive file ID on success, null on failure. + */ +/** Returns ['id' => string] on success or ['error' => string] on failure. */ +function _gdriveUploadFile(string $token, string $folderId, string $filePath, string $remoteName): array { + if (!file_exists($filePath)) return ['error' => 'Local backup file not found: ' . $filePath]; + $mimeType = 'application/x-sqlite3'; + $metadata = json_encode(['name' => $remoteName, 'parents' => [$folderId]]); + $fileContent = file_get_contents($filePath); + $boundary = 'es_backup_' . bin2hex(random_bytes(8)); + $body = "--$boundary\r\n" + . "Content-Type: application/json; charset=UTF-8\r\n\r\n" + . $metadata . "\r\n" + . "--$boundary\r\n" + . "Content-Type: $mimeType\r\n\r\n" + . $fileContent . "\r\n" + . "--$boundary--"; + + $ch = curl_init('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer $token", + "Content-Type: multipart/related; boundary=$boundary", + "Content-Length: " . strlen($body), + ], + CURLOPT_TIMEOUT => 120, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $curlErr = curl_error($ch); + curl_close($ch); + if (!$response) return ['error' => 'cURL upload failed: ' . $curlErr]; + $data = json_decode($response, true); + if (!empty($data['id'])) return ['id' => $data['id']]; + $apiErr = $data['error']['message'] ?? $data['error']['status'] ?? json_encode($data); + return ['error' => 'Drive API error: ' . $apiErr]; +} + +/** + * Delete Drive backups older than $retentionDays. + * Returns count of deleted files. + */ +function _gdrivePurgeOld(string $token, string $folderId, int $retentionDays): int { + if ($retentionDays <= 0) return 0; + $cutoff = date('c', strtotime("-{$retentionDays} days")); + $q = "'$folderId' in parents and name contains 'evershelf_' and trashed=false"; + $url = 'https://www.googleapis.com/drive/v3/files?' + . http_build_query(['q' => $q, 'fields' => 'files(id,name,createdTime)', 'pageSize' => '1000']); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + curl_close($ch); + if (!$response) return 0; + $data = json_decode($response, true); + $deleted = 0; + foreach ($data['files'] ?? [] as $file) { + if (!empty($file['createdTime']) && $file['createdTime'] < $cutoff) { + $ch = curl_init("https://www.googleapis.com/drive/v3/files/{$file['id']}"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], + CURLOPT_TIMEOUT => 15, + ]); + curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code === 204) $deleted++; + } + } + return $deleted; +} + +/** + * Full backup flow: create local snapshot, upload to Google Drive, purge old Drive files. + */ +function backupToGDrive(?PDO $db = null): array { + EverLog::info('backupToGDrive'); + if (env('GDRIVE_ENABLED', 'false') !== 'true') { + return ['success' => false, 'error' => 'Google Drive backup is not enabled']; + } + $folderId = env('GDRIVE_FOLDER_ID', ''); + if (empty($folderId)) { + return ['success' => false, 'error' => 'GDRIVE_FOLDER_ID not configured']; + } + + // 1. Create (or reuse recent) local backup + $local = createLocalBackup($db); + if (!$local['success']) return $local; + + // 2. Authenticate with Google + $tokResult = _gdriveGetTokenEx(); + if (empty($tokResult['token'])) { + return ['success' => false, 'error' => $tokResult['error'] ?? 'Google Drive authentication failed']; + } + $token = $tokResult['token']; + + // 3. Upload + $uploadResult = _gdriveUploadFile($token, $folderId, $local['path'], $local['filename']); + if (empty($uploadResult['id'])) { + return ['success' => false, 'error' => $uploadResult['error'] ?? 'Upload to Google Drive failed']; + } + $driveFileId = $uploadResult['id']; + + // 4. Purge old files on Drive + $retentionDays = max(0, (int)env('GDRIVE_RETENTION_DAYS', '30')); + $purgedRemote = $retentionDays > 0 ? _gdrivePurgeOld($token, $folderId, $retentionDays) : 0; + + EverLog::info('backupToGDrive ok', ['file' => $local['filename'], 'drive_id' => $driveFileId, 'purged_remote' => $purgedRemote]); + return [ + 'success' => true, + 'filename' => $local['filename'], + 'size_kb' => $local['size_kb'], + 'drive_file_id' => $driveFileId, + 'purged_local' => $local['purged'], + 'purged_remote' => $purgedRemote, + 'created_at' => $local['created_at'], + ]; +} + /** * Server-side Bring! cleanup: remove items from Bring! that the app auto-added * but are no longer flagged by smart shopping (stock is now adequate). @@ -6290,7 +6962,7 @@ function bringGetList(): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringGetList'); - echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate. Aggiungi BRING_EMAIL e BRING_PASSWORD al file .env']); + echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured. Add BRING_EMAIL and BRING_PASSWORD to .env']); return; } @@ -6301,14 +6973,14 @@ function bringGetList(): void { if ($lists && isset($lists['lists'][0]['listUuid'])) { $listUUID = $lists['lists'][0]['listUuid']; } else { - echo json_encode(['success' => false, 'error' => 'Nessuna lista Bring! trovata']); + echo json_encode(['success' => false, 'error' => 'No Bring! list found']); return; } } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data) { - echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); + echo json_encode(['success' => false, 'error' => 'Error fetching the list']); return; } @@ -6368,7 +7040,7 @@ function bringAddItems(): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringAddItems'); - echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } @@ -6377,7 +7049,7 @@ function bringAddItems(): void { $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; if (empty($listUUID)) { - echo json_encode(['success' => false, 'error' => 'Lista non trovata']); + echo json_encode(['success' => false, 'error' => 'List not found']); return; } @@ -6446,7 +7118,7 @@ function bringRemoveItem(): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringRemoveItem'); - echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } @@ -6455,7 +7127,7 @@ function bringRemoveItem(): void { $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; if (empty($name) || empty($listUUID)) { - echo json_encode(['success' => false, 'error' => 'Parametri mancanti']); + echo json_encode(['success' => false, 'error' => 'Missing parameters']); return; } @@ -6494,19 +7166,19 @@ function bringCleanSpecs(): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringCleanSpecs'); - echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { - echo json_encode(['success' => false, 'error' => 'Lista non trovata']); + echo json_encode(['success' => false, 'error' => 'List not found']); return; } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data || !isset($data['purchase'])) { - echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); + echo json_encode(['success' => false, 'error' => 'Error fetching the list']); return; } @@ -6605,17 +7277,17 @@ function bringMigrateNames(PDO $db): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringMigrateNames'); - echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { - echo json_encode(['success' => false, 'error' => 'Lista non trovata']); + echo json_encode(['success' => false, 'error' => 'List not found']); return; } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data || !isset($data['purchase'])) { - echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); + echo json_encode(['success' => false, 'error' => 'Error fetching the list']); return; } @@ -6969,36 +7641,25 @@ function smartShopping(PDO $db): void { } if ($coveredByEquivalent) continue; - if ($isFrequent && $isRecent && $buyCount >= 2) { - // Frequently used, recently active, AND bought multiple times → critical - $urgency = 'critical'; - $reasons[] = 'Esaurito'; - $score += 100; - if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; } - } elseif ($isFrequent && $isRecent && $buyCount == 1 && $useCount >= 3) { - // Bought once but used ≥3 times → proven consumption pattern → high - $urgency = 'high'; - $reasons[] = 'Esaurito'; - $score += 75; - if ($useCount >= 5) { $score += 10; $reasons[] = "Uso frequente ({$useCount}x)"; } - } elseif ($isFrequent && $isRecent && $buyCount == 1) { - // Frequent use, bought once, <3 uses — not yet proven → medium - $urgency = 'medium'; - $reasons[] = 'Esaurito'; - $score += 45; - } elseif ($isRegular && $isRecent && ($useCount >= 3 || $buyCount >= 2)) { - // Regularly used, recently active → high - $urgency = 'high'; - $reasons[] = 'Esaurito'; - $score += 70; - } elseif ($isRecent && $buyCount >= 2) { - // At least bought a couple times recently → low - $urgency = 'low'; - $reasons[] = 'Esaurito'; - $score += 30; + // For DEPLETED products: recency is misleading — the product may not have been + // "used recently" precisely because it ran out. Base urgency on usage rate only. + $reasons[] = 'Esaurito'; + if ($isFrequent && $useCount >= 5) { + $urgency = 'critical'; $score += 120; + $reasons[] = "Uso frequente ({$useCount}x)"; + } elseif ($isFrequent && $useCount >= 2) { + $urgency = 'critical'; $score += 100; + } elseif ($isFrequent) { + // usesPerMonth >= 1.5 but few recorded uses (new product) → high + $urgency = 'high'; $score += 75; + } elseif ($isRegular && ($useCount >= 3 || $buyCount >= 2)) { + $urgency = 'high'; $score += 65; + } elseif ($isRegular) { + $urgency = 'medium'; $score += 45; + } elseif ($useCount >= 2 || $buyCount >= 2) { + $urgency = 'low'; $score += 30; } else { - // Rarely used or not used recently — skip - continue; + $urgency = 'low'; $score += 10; } } @@ -7496,6 +8157,82 @@ function bringSuggestItems(PDO $db): void { ], JSON_UNESCAPED_UNICODE); } +// ===== SHOPPING ABSTRACTION (internal DB or Bring!) ===== + +function isShoppingBringMode(): bool { + return env('SHOPPING_MODE', 'internal') === 'bring' + && !empty(env('BRING_EMAIL')) + && !empty(env('BRING_PASSWORD')); +} + +function shoppingGetList(PDO $db): void { + if (isShoppingBringMode()) { + bringGetList(); + return; + } + $items = $db->query( + "SELECT name, raw_name, specification FROM shopping_list ORDER BY sort_order ASC, added_at ASC" + )->fetchAll(); + $purchase = array_map(fn($r) => [ + 'name' => $r['name'], + 'rawName' => $r['raw_name'] ?: $r['name'], + 'specification' => $r['specification'], + ], $items); + echo json_encode([ + 'success' => true, + 'listUUID' => 'internal-list', + 'purchase' => $purchase, + 'recently' => [], + ], JSON_UNESCAPED_UNICODE); +} + +function shoppingAdd(PDO $db): void { + if (isShoppingBringMode()) { + bringAddItems(); + return; + } + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $items = $input['items'] ?? []; + $added = 0; $updated = 0; $skipped = 0; + foreach ($items as $item) { + $name = trim($item['name'] ?? ''); + if ($name === '') continue; + $rawName = trim($item['rawName'] ?? $item['raw_name'] ?? $name); + $spec = $item['specification'] ?? ''; + $updateSpec = !empty($item['update_spec']); + $stmt = $db->prepare("SELECT id, specification FROM shopping_list WHERE lower(name) = lower(?)"); + $stmt->execute([$name]); + $existing = $stmt->fetch(); + if ($existing) { + if ($updateSpec && $existing['specification'] !== $spec) { + $db->prepare("UPDATE shopping_list SET specification=?, raw_name=? WHERE id=?")->execute([$spec, $rawName, $existing['id']]); + $updated++; + } else { + $skipped++; + } + } else { + $db->prepare("INSERT INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)")->execute([$name, $rawName, $spec]); + $added++; + } + } + echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => []]); +} + +function shoppingRemove(PDO $db): void { + if (isShoppingBringMode()) { + bringRemoveItem(); + return; + } + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $name = trim($input['name'] ?? ''); + if ($name === '') { + echo json_encode(['success' => false, 'error' => 'Missing name']); + return; + } + $db->prepare("DELETE FROM shopping_list WHERE lower(name) = lower(?)")->execute([$name]); + echo json_encode(['success' => true]); +} + // ===== SHARED APP DATA FUNCTIONS ===== function appSettingsGet(PDO $db): void { 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 86dcd75..d7b4494 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -455,7 +455,11 @@ function _scaleAutoFillUse(msg) { // Determine target unit let unit; - if (_useConfMode && _useConfMode._activeUnit === 'sub') { + if (_useConfMode) { + // Scale always reads weight (g/ml) — auto-switch to sub-unit mode if still in conf mode + if (_useConfMode._activeUnit !== 'sub') { + switchUseUnit('sub'); + } unit = (_useConfMode.packageUnit || '').toLowerCase(); } else { unit = _useNormalUnit; @@ -944,7 +948,9 @@ function updateScaleReadButtons() { } const btnUse = document.getElementById('btn-scale-use'); if (btnUse) { - btnUse.style.display = (ready && (_useNormalUnit === 'g' || _useNormalUnit === 'ml')) ? '' : 'none'; + const canUseByWeight = _useNormalUnit === 'g' || _useNormalUnit === 'ml' || + (_useConfMode && (_useConfMode.packageUnit === 'g' || _useConfMode.packageUnit === 'ml')); + btnUse.style.display = (ready && canUseByWeight) ? '' : 'none'; } // Live box: visible when scale enabled + connected + on use page + compatible unit const liveBox = document.getElementById('scale-live-box'); @@ -1050,8 +1056,12 @@ if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en'; // Apply theme IMMEDIATELY to prevent flash of unstyled content (function _earlyTheme() { try { - const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}'); - const mode = s.dark_mode || 'auto'; + // Use dedicated key (server-synced); fall back to old full-settings object for back-compat + let mode = localStorage.getItem('evershelf_dark_mode'); + if (!mode) { + const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}'); + mode = s.dark_mode || 'auto'; + } const h = new Date().getHours(); const dark = mode === 'on' || (mode === 'auto' && (h >= 20 || h < 7)); document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); @@ -1887,7 +1897,8 @@ async function flipCamera() { const current = s.camera_facing || 'environment'; const next = current === 'environment' ? 'user' : 'environment'; s.camera_facing = next; - try { localStorage.setItem('evershelf_settings', JSON.stringify(s)); } catch(_) {} + _settingsCache = s; + _saveSettingToServer({ camera_facing: next }); showToast(next === 'user' ? t('scan.flip_front') : t('scan.flip_back'), 'info'); stopScanner(); setTimeout(() => initScanner(), 150); @@ -2060,9 +2071,9 @@ let _settingsDirty = false; function getSettings() { if (!_settingsCache) { - try { - _settingsCache = JSON.parse(localStorage.getItem('evershelf_settings') || '{}'); - } catch(e) { _settingsCache = {}; } + // Settings come from server — do NOT read from localStorage (per-device storage). + // _settingsCache is populated by _applySyncedSettings() on app init. + _settingsCache = {}; } const s = _settingsCache; // Build recipe_prefs array from individual booleans @@ -2079,12 +2090,19 @@ function getSettings() { function saveSettingsToStorage(settings) { _settingsCache = settings; - localStorage.setItem('evershelf_settings', JSON.stringify(settings)); - // Persist to DB + // Cache dark_mode in localStorage ONLY as a hint for the pre-render _earlyTheme() IIFE + // (prevents flash before server fetch). Authoritative value is in server .env. + try { localStorage.setItem('evershelf_dark_mode', settings.dark_mode || 'auto'); } catch(_) {} + // Persist user-prefs subset to DB _settingsDirty = true; _debouncedSyncSettings(); } +/** Save one or more settings directly to server .env (partial update). */ +async function _saveSettingToServer(data) { + try { await api('save_settings', {}, 'POST', data); } catch(e) { /* offline */ } +} + const _debouncedSyncSettings = debounce(function() { if (!_settingsDirty) return; _settingsDirty = false; @@ -2125,13 +2143,12 @@ async function syncSettingsFromDB() { const s = getSettings(); s.meal_plan = srv.meal_plan; _settingsCache = s; - localStorage.setItem('evershelf_settings', JSON.stringify(s)); if (document.getElementById('meal-plan-grid')) renderMealPlanEditor(); } // tts_voice preference (best-effort cross-device — falls back if voice unavailable) if (srv.tts_voice) { const s = getSettings(); - if (!s.tts_voice) { s.tts_voice = srv.tts_voice; _settingsCache = s; localStorage.setItem('evershelf_settings', JSON.stringify(s)); } + if (!s.tts_voice) { s.tts_voice = srv.tts_voice; _settingsCache = s; } } // ── User data previously stored in localStorage, now server-synced ── @@ -2174,7 +2191,7 @@ async function syncSettingsFromDB() { } /** - * Apply server settings object into localStorage cache. + * Apply server settings object into in-memory cache (_settingsCache). * Called both from _initApp (to reuse an already-fetched response) and syncSettingsFromDB. */ function _applySyncedSettings(serverSettings) { @@ -2192,7 +2209,10 @@ function _applySyncedSettings(serverSettings) { 'tts_engine','tts_rate','tts_pitch','tts_auth_header_name','tts_auth_header_value','tts_extra_fields', 'screensaver_enabled','screensaver_timeout', 'price_enabled','price_country','price_currency','price_update_months', - 'zerowaste_tips_enabled']; + 'zerowaste_tips_enabled', + 'shopping_enabled','shopping_mode','shopping_smart_suggestions', + 'shopping_forecast','shopping_auto_add_threshold', + 'dark_mode']; let changed = false; for (const key of serverKeys) { if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') { @@ -2202,19 +2222,262 @@ function _applySyncedSettings(serverSettings) { } if (changed) { _settingsCache = s; - localStorage.setItem('evershelf_settings', JSON.stringify(s)); + // Update localStorage hint for _earlyTheme() IIFE on next load + try { localStorage.setItem('evershelf_dark_mode', s.dark_mode || 'auto'); } catch(_) {} } } -let _infoTabTimer = null; +let _infoTabTimer = null; +let _backupTabTimer = null; /** * Load the Info tab: Gemini token usage + cost, log size, DB size, log level. * Called on tab click; auto-refreshes every 30s while the tab is open. */ +// ── Backup Tab ──────────────────────────────────────────────────────────────── + +async function _loadBackupTab() { + if (_backupTabTimer) { clearInterval(_backupTabTimer); _backupTabTimer = null; } + await _renderBackupTab(); + // Pull server settings to populate inputs if not yet loaded + try { + const ss = await api('get_settings'); + if (ss) { + const bkRetEl = document.getElementById('setting-backup-retention-days'); + if (bkRetEl) { bkRetEl.value = ss.backup_retention_days || 3; bkRetEl.dataset.loaded = '1'; } + const gdriveEnEl = document.getElementById('setting-gdrive-enabled'); + if (gdriveEnEl) gdriveEnEl.checked = !!ss.gdrive_enabled; + const gdriveFolderEl = document.getElementById('setting-gdrive-folder-id'); + if (gdriveFolderEl) { gdriveFolderEl.value = ss.gdrive_folder_id || ''; gdriveFolderEl.dataset.loaded = '1'; } + const gdriveRetEl = document.getElementById('setting-gdrive-retention-days'); + if (gdriveRetEl) { gdriveRetEl.value = ss.gdrive_retention_days || 30; gdriveRetEl.dataset.loaded = '1'; } + // Pre-fill client_id (never show secret back) + if (ss.gdrive_client_id_set) { + const ciEl = document.getElementById('setting-gdrive-client-id'); + if (ciEl && !ciEl.value) ciEl.placeholder = '● ● ● already configured ● ● ●'; + } + // OAuth token status + const oauthStatusEl = document.getElementById('gdrive-oauth-token-status'); + if (oauthStatusEl) { + oauthStatusEl.textContent = ss.gdrive_refresh_token_set + ? ('✅ ' + (t('settings.backup.gdrive_oauth_authorized') || 'Authorized')) + : ('⚠️ ' + (t('settings.backup.gdrive_oauth_not_authorized') || 'Not authorized yet')); + oauthStatusEl.style.color = ss.gdrive_refresh_token_set ? '#15803d' : '#b45309'; + } + // Redirect URI for OAuth setup — always http://localhost for self-hosted compat + // (can be overridden server-side via GDRIVE_REDIRECT_URI env var) + const rdEl = document.getElementById('gdrive-redirect-uri-display'); + if (rdEl) rdEl.textContent = 'http://localhost'; + } + } catch(e) { /* non-critical */ } +} + +async function _renderBackupTab() { + const lastInfoEl = document.getElementById('backup-last-info'); + const listEl = document.getElementById('backup-list-container'); + try { + const data = await api('backup_list'); + if (!data || !data.success) { + if (lastInfoEl) lastInfoEl.innerHTML = 'Error loading backup info'; + return; + } + // Last backup info + if (lastInfoEl) { + if (data.last_backup_ts) { + const secsAgo = Math.floor(Date.now() / 1000) - data.last_backup_ts; + let ago; + if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') || 'adesso' : `${secsAgo}s fa`; + else if (secsAgo < 3600) ago = `${Math.floor(secsAgo / 60)} min fa`; + else if (secsAgo < 86400) ago = `${Math.floor(secsAgo / 3600)}h fa`; + else ago = `${Math.floor(secsAgo / 86400)}gg fa`; + const name = data.last_backup_file || ''; + lastInfoEl.innerHTML = `${t('settings.backup.last_backup') || 'Ultimo backup'}: ${ago} (${name})`; + } else { + lastInfoEl.innerHTML = `${t('settings.backup.no_backup_yet') || 'Nessun backup ancora'}`; + } + } + // Backup list + if (listEl) { + if (!data.backups || data.backups.length === 0) { + listEl.innerHTML = `

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

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

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

${rows}`; + } + } + } catch(e) { + if (lastInfoEl) lastInfoEl.innerHTML = 'Error: ' + e.message + ''; + } +} + +async function _backupNow() { + const btn = document.getElementById('btn-backup-now'); + const statusEl = document.getElementById('backup-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = t('settings.backup.backing_up') || '⏳ Backup in corso…'; statusEl.style.display = 'block'; } + try { + const r = await api('backup_now'); + if (r && r.success) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${r.filename} (${r.size_kb} KB)`; } + await _renderBackupTab(); + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } finally { + if (btn) btn.disabled = false; + if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 5000); + } +} + +async function _backupDelete(filename) { + if (!confirm(`${t('settings.backup.delete_confirm') || 'Eliminare il backup'} ${filename}?`)) return; + const r = await api('backup_delete', {}, 'POST', { filename }); + if (r && r.success) await _renderBackupTab(); + else alert(`❌ ${r?.error || 'Error deleting backup'}`); +} + +async function _backupRestore(filename) { + if (!confirm(`${t('settings.backup.restore_confirm') || 'Ripristinare il backup'} "${filename}"?\n\n⚠️ ATTENZIONE: tutti i dati attuali verranno SOSTITUITI. Questa azione è irreversibile.`)) return; + const statusEl = document.getElementById('backup-status'); + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Ripristino in corso…'; statusEl.style.display = 'block'; } + try { + const r = await api('backup_restore', {}, 'POST', { filename }); + if (r && r.success) { + alert(`✅ ${r.message || 'Ripristino completato!'}\n\nLa pagina verrà ricaricata.`); + location.reload(); + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } +} + +async function _gdriveTest() { + const btn = document.getElementById('btn-gdrive-test'); + const statusEl = document.getElementById('gdrive-test-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Test connessione…'; statusEl.style.display = 'block'; } + try { + // Save current settings first so the server has the latest JSON/folder + await saveSettings(); + const r = await api('gdrive_test'); + if (r && r.success) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${t('settings.backup.gdrive_ok') || 'Connessione riuscita!'}`; } + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } finally { + if (btn) btn.disabled = false; + if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 6000); + } +} + +async function _gdrivePushNow() { + const btn = document.getElementById('btn-gdrive-push'); + const statusEl = document.getElementById('gdrive-test-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = t('settings.backup.gdrive_pushing') || '⏳ Upload in corso…'; statusEl.style.display = 'block'; } + try { + await saveSettings(); + const r = await api('gdrive_push'); + if (r && r.success) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${r.filename} → Drive (purged: ${r.purged_remote || 0})`; } + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } + } finally { + if (btn) btn.disabled = false; + if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 6000); + } +} + +async function _gdriveAuthorize() { + const btn = document.getElementById('btn-gdrive-authorize'); + if (btn) btn.disabled = true; + try { + await saveSettings(); + const r = await api('gdrive_oauth_url'); + if (r && r.success) { + window.open(r.url, '_blank', 'width=600,height=700,noopener'); + // Store redirect_uri used so gdrive_oauth_exchange can match it + window._gdriveLastRedirectUri = r.redirect_uri || 'http://localhost'; + // Show manual code input section + const codeSection = document.getElementById('gdrive-code-section'); + if (codeSection) codeSection.style.display = ''; + const statusEl = document.getElementById('gdrive-oauth-token-status'); + if (statusEl) { + statusEl.textContent = t('settings.backup.gdrive_oauth_window_opened') || '🔑 Authorization page opened — authorize and paste the URL below'; + statusEl.style.color = '#2563eb'; + } + } else { + alert('❌ ' + (r?.error || 'Failed to get OAuth URL')); + } + } catch(e) { + alert('❌ ' + e.message); + } finally { + if (btn) btn.disabled = false; + } +} + +async function _gdriveSubmitCode() { + const inputEl = document.getElementById('gdrive-code-input'); + const btn = document.getElementById('btn-gdrive-submit-code'); + const raw = (inputEl?.value || '').trim(); + if (!raw) { alert(t('settings.backup.gdrive_code_empty') || 'Paste the URL or code first'); return; } + + // Accept either a full URL (extract code param) or just the bare code + let code = raw; + try { + const u = new URL(raw); + const c = u.searchParams.get('code'); + if (c) code = c; + } catch(e) { /* not a URL, use as-is */ } + + if (btn) btn.disabled = true; + try { + const r = await api('gdrive_oauth_exchange', null, 'POST', { + code, + redirect_uri: window._gdriveLastRedirectUri || 'http://localhost' + }); + if (r && r.success) { + const statusEl = document.getElementById('gdrive-oauth-token-status'); + if (statusEl) { + statusEl.textContent = '✅ ' + (t('settings.backup.gdrive_oauth_authorized') || 'Authorized'); + statusEl.style.color = '#15803d'; + } + const codeSection = document.getElementById('gdrive-code-section'); + if (codeSection) codeSection.style.display = 'none'; + if (inputEl) inputEl.value = ''; + } else { + alert('❌ ' + (r?.error || 'Code exchange failed')); + } + } catch(e) { + alert('❌ ' + e.message); + } finally { + if (btn) btn.disabled = false; + } +} + async function _loadInfoTab() { // Cancel any previous auto-refresh - if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; } + if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; } + if (_backupTabTimer) { clearInterval(_backupTabTimer); _backupTabTimer = null; } await _renderInfoTab(); // Auto-refresh every 30s while Info tab is visible _infoTabTimer = setInterval(_renderInfoTab, 30_000); @@ -2610,7 +2873,9 @@ async function loadSettingsUI() { 'meal_plan_enabled', 'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type', 'tts_content_type','tts_payload_key', - 'price_enabled','price_country','price_currency','price_update_months']; + 'price_enabled','price_country','price_currency','price_update_months', + 'shopping_enabled','shopping_mode','shopping_smart_suggestions', + 'shopping_forecast','shopping_auto_add_threshold']; // Note: gemini_key is never sent from server; settings_token_set is metadata only const settingsTokenRequired = !!serverSettings.settings_token_set; const tokenHintEl = document.getElementById('settings-token-status-hint'); @@ -2624,7 +2889,6 @@ async function loadSettingsUI() { } if (changed) { _settingsCache = s; - localStorage.setItem('evershelf_settings', JSON.stringify(s)); // Re-populate UI with merged values document.getElementById('setting-gemini-key').value = s.gemini_key || ''; document.getElementById('setting-bring-email').value = s.bring_email || ''; @@ -2661,6 +2925,8 @@ async function loadSettingsUI() { if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia'; if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR'; if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3; + // Shopping settings (server merge) + _applyShoppingSettingsUI(s); } } catch(e) { /* offline, use local */ } // Price settings @@ -2685,6 +2951,17 @@ 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; + // Shopping settings + _applyShoppingSettingsUI(s); // Hide kiosk download banner if running inside Android WebView (kiosk mode) const kioskBanner = document.getElementById('kiosk-download-banner'); if (kioskBanner && /; wv\)/.test(navigator.userAgent)) { @@ -2744,7 +3021,7 @@ function _openKioskNativeSettings() { _kioskBridge.openNativeSettings(); } catch(e) { // Older APK without openNativeSettings bridge — inform user to update - showToast(t('settings.kiosk.native_update_hint') || 'Aggiorna l\'app kiosk per usare questa funzione', 'warning', 4000); + showToast(t('settings.kiosk.native_update_hint'), 'warning', 4000); } } @@ -2853,16 +3130,31 @@ https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf function _injectKioskOverlay() { if (typeof _kioskBridge === 'undefined') return; - // Always mark header as kiosk-mode (idempotent) — must happen even if buttons - // were already injected by the native onPageFinished Kotlin callback. + // Always mark header as kiosk-mode (idempotent). const appHeader = document.querySelector('.app-header'); if (appHeader) appHeader.classList.add('kiosk-mode'); + // Permanently hide the native Android settings button. + // Kiosk configuration is accessible ONLY through the web settings page (⚙️ below). + try { _kioskBridge.setNativeSettingsVisible(false); } catch (_) {} + const btnStyle = 'background:rgba(255,255,255,0.2);border:none;color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;'; - // If the Kotlin onPageFinished already injected #_kiosk_overlay (with only ✕ and ↻), - // nothing more to do — the native Android btnSettings (top-right) opens SettingsActivity. - if (document.getElementById('_kiosk_overlay')) return; + // If the Kotlin onPageFinished already injected #_kiosk_overlay (with ✕ and ↻), + // just add the ⚙️ button if missing (do not duplicate the other buttons). + const existing = document.getElementById('_kiosk_overlay'); + if (existing) { + if (!document.getElementById('_kiosk_settings_btn')) { + const sBtn = document.createElement('button'); + sBtn.id = '_kiosk_settings_btn'; + sBtn.textContent = '⚙️'; + sBtn.title = t('settings.title') || 'Impostazioni'; + sBtn.style.cssText = btnStyle; + sBtn.addEventListener('click', (e) => { e.stopPropagation(); showPage('settings'); }); + existing.appendChild(sBtn); + } + return; + } const headerLeft = document.getElementById('header-left'); if (!headerLeft) return; @@ -2893,12 +3185,17 @@ function _injectKioskOverlay() { _kioskBridge.hardReload(); }); - // NOTE: No ⚙️ button here — the native Android settings button (top-right, injected by - // Kotlin) opens SettingsActivity (server URL, BLE scale, screensaver). Do NOT call - // setNativeSettingsVisible(false) — that would hide the only way to reconfigure the kiosk. + // Settings button — only web settings, native button is permanently hidden + const settingsBtn = document.createElement('button'); + settingsBtn.id = '_kiosk_settings_btn'; + settingsBtn.textContent = '⚙️'; + settingsBtn.title = t('settings.title') || 'Impostazioni'; + settingsBtn.style.cssText = btnStyle; + settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); showPage('settings'); }); wrap.appendChild(exitBtn); wrap.appendChild(refBtn); + wrap.appendChild(settingsBtn); headerLeft.appendChild(wrap); } @@ -3018,6 +3315,37 @@ function removeAppliance(idx) { renderAppliances(s.appliances); } +function _applyShoppingSettingsUI(s) { + const enabledEl = document.getElementById('setting-shopping-enabled'); + if (enabledEl) enabledEl.checked = s.shopping_enabled !== false; + const mode = s.shopping_mode || 'internal'; + document.querySelectorAll('input[name="shopping-mode"]').forEach(r => { r.checked = (r.value === mode); }); + const bringSection = document.getElementById('bring-subsection'); + if (bringSection) bringSection.style.display = mode === 'bring' ? '' : 'none'; + const suggestEl = document.getElementById('setting-shopping-smart-suggestions'); + if (suggestEl) suggestEl.checked = s.shopping_smart_suggestions !== false; + const forecastEl = document.getElementById('setting-shopping-forecast'); + if (forecastEl) forecastEl.checked = s.shopping_forecast !== false; + const autoAddEl = document.getElementById('setting-shopping-auto-add'); + if (autoAddEl) autoAddEl.value = s.shopping_auto_add_threshold || 0; +} + +function onShoppingEnabledChange() { + const s = getSettings(); + s.shopping_enabled = document.getElementById('setting-shopping-enabled').checked; + saveSettingsToStorage(s); + _saveSettingToServer({ shopping_enabled: s.shopping_enabled }); +} + +function onShoppingModeChange(value) { + const bringSection = document.getElementById('bring-subsection'); + if (bringSection) bringSection.style.display = value === 'bring' ? '' : 'none'; + const s = getSettings(); + s.shopping_mode = value; + saveSettingsToStorage(s); + _saveSettingToServer({ shopping_mode: value }); +} + async function saveSettings() { const s = getSettings(); // Only update gemini_key if user actually typed something; preserve existing key otherwise @@ -3092,6 +3420,33 @@ 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; + // Shopping settings + const shoppingEnabledEl = document.getElementById('setting-shopping-enabled'); + if (shoppingEnabledEl) s.shopping_enabled = shoppingEnabledEl.checked; + const shoppingModeEl = document.querySelector('input[name="shopping-mode"]:checked'); + if (shoppingModeEl) s.shopping_mode = shoppingModeEl.value; + const shoppingSuggestEl = document.getElementById('setting-shopping-smart-suggestions'); + if (shoppingSuggestEl) s.shopping_smart_suggestions = shoppingSuggestEl.checked; + const shoppingForecastEl = document.getElementById('setting-shopping-forecast'); + if (shoppingForecastEl) s.shopping_forecast = shoppingForecastEl.checked; + const shoppingAutoAddEl = document.getElementById('setting-shopping-auto-add'); + if (shoppingAutoAddEl) s.shopping_auto_add_threshold = parseInt(shoppingAutoAddEl.value, 10) || 0; + // 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 +3491,20 @@ 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 } : {}), + shopping_enabled: s.shopping_enabled !== false, + shopping_mode: s.shopping_mode || 'internal', + shopping_smart_suggestions: s.shopping_smart_suggestions !== false, + shopping_forecast: s.shopping_forecast !== false, + shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0, }, tokenHeader); const statusEl = document.getElementById('settings-status'); if (result.success) { @@ -3197,14 +3564,15 @@ function togglePasswordVisibility(inputId) { // ===== API HELPER ===== async function api(action, params = {}, method = 'GET', body = null, extraHeaders = {}) { - // In demo mode, all Bring! write operations are no-ops + // In demo mode, all shopping write operations are no-ops if (_demoMode) { - const BRING_WRITE_ACTIONS = ['bring_add', 'bring_remove', 'bring_migrate_names', 'bring_set_spec']; + const BRING_WRITE_ACTIONS = ['bring_add', 'bring_remove', 'bring_migrate_names', 'bring_set_spec', + 'shopping_add', 'shopping_remove']; if (BRING_WRITE_ACTIONS.includes(action)) { return { success: true, added: 0, removed: 0, skipped: 0, _demo: true }; } - // bring_list returns the in-memory demo list - if (action === 'bring_list') { + // shopping_list / bring_list return the in-memory demo list + if (action === 'shopping_list' || action === 'bring_list') { return { success: true, purchase: shoppingItems, listUUID: 'demo-list', _demo: true }; } } @@ -5451,8 +5819,6 @@ function showItemDetail(inventoryId, productId) { function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; clearMoveModalTimer(); - // Restore the native kiosk settings button when the modal closes. - try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {} _cancelScaleAutoConfirm(false); _scaleRecipeAutoFillPaused = false; _scaleUserDismissed = false; @@ -7972,7 +8338,7 @@ async function submitAdd(e) { // try a client-side fuzzy remove using the already-loaded shoppingItems const match = _findSimilarItem(currentProduct.name, shoppingItems); if (match) { - api('bring_remove', {}, 'POST', { + api('shopping_remove', {}, 'POST', { name: match.name, rawName: match.rawName || '', listUUID: shoppingListUUID @@ -8230,9 +8596,29 @@ async function loadUseInventoryInfo() { // Show unit switch unitSwitch.style.display = 'flex'; document.getElementById('use-unit-sub').textContent = subLabel; - - // Default to sub-unit mode - switchUseUnit('sub'); + + // If scale is active, start in sub-unit (g/ml) mode — scale always reads weight. + // Otherwise default to conf so the user thinks in packages. + const _scaleActiveNow = getSettings().scale_enabled && getSettings().scale_gateway_url && _scaleConnected; + switchUseUnit(_scaleActiveNow ? 'sub' : 'conf'); + + // Fraction shortcut buttons for conf mode (½, 1, 2 packages) + const existingConfFrac = document.getElementById('conf-fraction-btns'); + if (existingConfFrac) existingConfFrac.remove(); + const confFracDiv = document.createElement('div'); + confFracDiv.id = 'conf-fraction-btns'; + confFracDiv.className = 'pz-fraction-btns'; + const maxConf = Math.min(4, Math.ceil(_useConfMode.totalConf)); + const confFracs = [0.25, 0.5, 1]; + if (maxConf >= 2) confFracs.push(2); + confFracDiv.innerHTML = `
${ + confFracs.filter(f => f <= _useConfMode.totalConf + 0.01).map(f => { + const label = f === 0.25 ? '¼' : f === 0.5 ? '½' : f; + return ``; + }).join('') + }
`; + document.querySelector('#page-use .use-partial').appendChild(confFracDiv); + // Trigger a live-box refresh with the latest reading if on scale if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight); } else { @@ -8284,6 +8670,10 @@ function switchUseUnit(mode) { const qtyInput = document.getElementById('use-quantity'); const hint = document.getElementById('use-partial-hint'); + // Show/hide fraction buttons depending on mode + const confFracBtns = document.getElementById('conf-fraction-btns'); + const pzFracBtns = document.getElementById('pz-fraction-btns'); + if (mode === 'sub') { subBtn.classList.add('active'); confBtn.classList.remove('active'); @@ -8293,17 +8683,28 @@ function switchUseUnit(mode) { qtyInput.step = 'any'; qtyInput.min = 1; hint.textContent = t('recipes.quantity_in_total', { unit: _useConfMode.subLabel, total: `${Math.round(_useConfMode.totalSub)}${_useConfMode.subLabel}` }); + if (confFracBtns) confFracBtns.style.display = 'none'; } else { confBtn.classList.add('active'); subBtn.classList.remove('active'); _useConfMode._activeUnit = 'conf'; - qtyInput.value = 1; + qtyInput.value = Math.min(1, _useConfMode.totalConf); // start at 1 or max if < 1 qtyInput.step = 'any'; - qtyInput.min = 0.1; + qtyInput.min = 0.25; hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) }); + if (confFracBtns) confFracBtns.style.display = ''; } } +function setConfFraction(f) { + const input = document.getElementById('use-quantity'); + if (!input) return; + input.value = Math.min(f, _useConfMode?.totalConf ?? f); + document.querySelectorAll('#conf-fraction-btns .frac-btn').forEach(b => + b.classList.toggle('active', parseFloat(b.dataset.frac) === f) + ); +} + function getSubUnitStep(pkgUnit) { switch (pkgUnit) { case 'ml': return 50; @@ -8595,7 +8996,7 @@ function showLowStockBringPrompt(result, afterCallback) { try { const payload = { items: [{ name: shoppingName, specification: spec }] }; if (shoppingListUUID) payload.listUUID = shoppingListUUID; - const data = await api('bring_add', {}, 'POST', payload); + const data = await api('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'); } @@ -8685,7 +9086,7 @@ async function addLowStockToBring() { window._lowStockSpec = null; const payload = { items: [{ name: bringName, specification: spec }] }; if (shoppingListUUID) payload.listUUID = shoppingListUUID; - const data = await api('bring_add', {}, 'POST', payload); + const data = await api('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { // Pin as user-added so cleanup never auto-removes it const pinned = Object.assign({}, _pinnedBringCache || {}); @@ -8791,8 +9192,6 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu `; document.getElementById('modal-overlay').style.display = 'flex'; - // Hide the native kiosk settings button while the modal is open (prevents touch bleed-through) - try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {} startMoveModalCountdown('btn-move-stay', () => { _saveVacuumAndStay(openedId || 0); }); } @@ -9691,7 +10090,7 @@ function toggleShoppingTag(itemIdx, tag) { if (tag === 'urgente' && shoppingListUUID) { const isNowUrgent = existing.includes('urgente'); const newSpec = isNowUrgent ? t('shopping.urgency_spec_critical') : ''; - api('bring_add', {}, 'POST', { + api('shopping_add', {}, 'POST', { items: [{ name: item.name, specification: newSpec, update_spec: true }], listUUID: shoppingListUUID, }).catch(() => {}); @@ -9719,7 +10118,7 @@ async function confirmShoppingItemFound() { _spesaScanTarget = null; document.getElementById('shopping-scan-target-banner').style.display = 'none'; try { - const r = await api('bring_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID }); + const r = await api('shopping_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID }); if (r.success) { const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase()); if (idx >= 0) shoppingItems.splice(idx, 1); @@ -9839,7 +10238,7 @@ async function autoAddCriticalItems() { if (toAdd.length === 0) return; const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) })); try { - const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); + const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); if (result.success && result.added > 0) { // Track these as auto-added so cleanupObsoleteBringItems can safely remove them later _markAutoAddedBring(itemsToAdd.map(i => i.name)); @@ -10234,7 +10633,7 @@ async function cleanupObsoleteBringItems() { const removedNames = []; for (const item of toRemove) { try { - const r = await api('bring_remove', {}, 'POST', { + const r = await api('shopping_remove', {}, 'POST', { name: item.name, rawName: item.rawName || '', listUUID: shoppingListUUID @@ -10660,7 +11059,7 @@ async function addSmartToBring() { showLoading(true); try { - const result = await api('bring_add', {}, 'POST', { + const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID, }); @@ -10694,7 +11093,7 @@ async function loadShoppingCount() { const el = document.getElementById('stat-spesa'); if (el) el.classList.add('stat-loading'); try { - const data = await api('bring_list'); + const data = await api('shopping_list'); if (el) { if (data.success && data.purchase) { el.textContent = data.purchase.length; @@ -10823,7 +11222,7 @@ async function autoSyncUrgencySpecs() { } if (toUpdate.length === 0) return; try { - await api('bring_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID }); + await api('shopping_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID }); } catch (e) { /* ignore - sync is best-effort */ } } @@ -10840,7 +11239,7 @@ async function loadShoppingList() { loadShoppingList._bgCall = false; if (isBackgroundCall) { try { - const data = await api('bring_list'); + const data = await api('shopping_list'); if (data.success) { const newItems = data.purchase || []; const newNames = new Set(newItems.map(i => i.name.toLowerCase())); @@ -10890,7 +11289,7 @@ async function loadShoppingList() { } try { - const data = await api('bring_list'); + const data = await api('shopping_list'); statusEl.style.display = 'none'; if (!data.success) { @@ -11127,7 +11526,7 @@ async function removeBringItem(idx) { const item = shoppingItems[idx]; if (!item) return; try { - const data = await api('bring_remove', {}, 'POST', { + const data = await api('shopping_remove', {}, 'POST', { name: item.name, rawName: item.rawName || '', listUUID: shoppingListUUID @@ -11154,7 +11553,7 @@ async function generateSuggestions() { suggestionsEl.style.display = 'none'; try { - const data = await api('bring_suggest', {}, 'POST', {}); + const data = await api('shopping_suggest', {}, 'POST', {}); btn.disabled = false; btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`; @@ -11304,7 +11703,7 @@ async function addSelectedSuggestions() { return { name: s.name }; }); - const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID }); + const data = await api('shopping_add', {}, 'POST', { items, listUUID: shoppingListUUID }); if (data.success) { let msg = data.added === 1 ? t('shopping.bring_added_one') : t('shopping.bring_added_many').replace('{n}', data.added); @@ -11452,14 +11851,14 @@ async function analyzeExpiryImage(dataUrl) { if (expiryInput) { expiryInput.value = result.expiry_date; } - statusDiv.innerHTML = `

✅ Data trovata: ${formatDate(result.expiry_date)}

`; + statusDiv.innerHTML = `

✅ ${t('scanner.expiry_found')}: ${formatDate(result.expiry_date)}

`; // Close modal after delay setTimeout(() => closeExpiryScanner(), 1500); } else if (result.error === 'no_api_key') { statusDiv.innerHTML = `

${t('ai.no_api_key').replace(/\n/g, '
')}

`; } else { - statusDiv.innerHTML = `

❌ Non riesco a leggere la data. ${result.raw_text ? '
Letto: ' + escapeHtml(result.raw_text) + '' : ''}

+ statusDiv.innerHTML = `

❌ ${t('scanner.expiry_read_fail')} ${result.raw_text ? '
' + t('scanner.expiry_raw_label') + ': ' + escapeHtml(result.raw_text) + '' : ''}

`; } } catch (err) { @@ -11586,7 +11985,10 @@ async function loadLog(more = false) { html += `${icon}`; html += `
`; html += `
${escapeHtml(tx.name)}${brand}${undone ? ` ${t('log.undone_badge')}` : ''}
`; - html += `
${typeLabel} ${tx.type !== 'bring' ? (tx.quantity + ' ' + (tx.unit || '')) + ' · ' : ''}${locStr}${notes} · ${timeStr}
`; + const txQtyStr = tx.type !== 'bring' + ? formatQuantity(parseFloat(tx.quantity), tx.unit, tx.default_quantity, tx.package_unit) + ' · ' + : ''; + html += `
${typeLabel} ${txQtyStr}${locStr}${notes} · ${timeStr}
`; html += recipeNote; html += `
`; if (canUndo) { @@ -12393,11 +12795,13 @@ async function submitRecipeUse(useAll) { setTimeout(() => showToast(t('recipes.finished_added_bring_toast'), 'info'), 1500); } - // Check low stock → Bring! prompt, then offer move + // Check low stock → shopping prompt, then offer move const moveCallback = result.remaining > 0 ? () => setTimeout(() => { - const ingData = _cachedRecipe?.recipe?.ingredients?.[_recipeUseContext?.idx]; - const wasVacuum = !!(ingData?.vacuum_sealed); + // Get vacuum state from the actual inventory item at this location + const cachedItems = _recipeUseContext?.items || []; + const itemAtLoc = cachedItems.find(i => i.location === location); + const wasVacuum = !!(itemAtLoc?.vacuum_sealed); showRecipeMoveModal(productId, location, result.remaining, result.opened_id, wasVacuum); }, 300) : null; @@ -12417,6 +12821,21 @@ async function submitRecipeUse(useAll) { } function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum) { + // Set context for recording the choice + _pendingMoveCtx = { productId, fromLoc, openedId }; + + // If a preference exists, skip the modal entirely + const prefMoveLoc = _getPreferredMoveLoc(productId, fromLoc); + if (prefMoveLoc) { + if (prefMoveLoc === fromLoc) { + closeModal(); + } else { + confirmRecipeMove(productId, fromLoc, prefMoveLoc, openedId, wasVacuum); + } + _pendingMoveCtx = null; + return; + } + const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc); const locButtons = otherLocs.map(([k, v]) => `` @@ -12435,18 +12854,24 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)

${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}

${locButtons}
${vacuumRow} - + `; document.getElementById('modal-overlay').style.display = 'flex'; - // Hide the native kiosk settings button while the modal is open (prevents touch bleed-through) - try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {} - startMoveModalCountdown('btn-move-stay', () => { closeModal(); }); + startMoveModalCountdown('btn-move-stay', () => { _recipeMoveCancelStay(productId, fromLoc, openedId || 0); }); } -async function confirmRecipeMove(productId, fromLoc, toLoc, openedId) { +function _recipeMoveCancelStay(productId, fromLoc, openedId) { + _recordMoveLocChoice(productId, fromLoc, fromLoc); + _pendingMoveCtx = null; + closeModal(); +} + +async function confirmRecipeMove(productId, fromLoc, toLoc, openedId, forcedVacuum) { clearMoveModalTimer(); - const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0; + _recordMoveLocChoice(productId, fromLoc, toLoc); + _pendingMoveCtx = null; + const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0); closeModal(); try { if (openedId) { @@ -12671,6 +13096,8 @@ function startCookingMode() { _cookingTTS = true; document.getElementById('cooking-title').textContent = _cookingRecipe.title || ''; document.getElementById('cooking-tts-btn').textContent = '🔊'; + // Unlock the AudioContext now while we have a user gesture (the Start button tap) + _ensureAudioUnlocked(); // Tools bar const toolsBar = document.getElementById('cooking-tools-bar'); if (toolsBar) { @@ -13222,13 +13649,13 @@ let _cookingTimers = []; // { id, label, total, seconds, running, inter let _cookingTimerIdCounter = 0; let _cookingSuggestedSeconds = 0; let _cookingSuggestedLabel = ''; +let _sharedAudioCtx = null; // pre-unlocked AudioContext (created on user gesture) function _playCookingTimerSound(type = 'done') { try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; const ctx = new Ctx(); - const now = ctx.currentTime; const pattern = type === 'warning' ? [{ f: 880, d: 0.08, o: 0.00 }, { f: 1046, d: 0.10, o: 0.14 }] : [ @@ -13237,36 +13664,52 @@ function _playCookingTimerSound(type = 'done') { { f: 1318, d: 0.14, o: 0.38 } ]; - for (const p of pattern) { - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = 'sine'; - osc.frequency.value = p.f; - gain.gain.setValueAtTime(0.0001, now + p.o); - gain.gain.exponentialRampToValueAtTime(0.12, now + p.o + 0.02); - gain.gain.exponentialRampToValueAtTime(0.0001, now + p.o + p.d); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(now + p.o); - osc.stop(now + p.o + p.d + 0.02); - } + const doPlay = () => { + const now = ctx.currentTime; + for (const p of pattern) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = p.f; + gain.gain.setValueAtTime(0.0001, now + p.o); + gain.gain.exponentialRampToValueAtTime(0.12, now + p.o + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, now + p.o + p.d); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now + p.o); + osc.stop(now + p.o + p.d + 0.02); + } + const endAt = now + Math.max(...pattern.map(p => p.o + p.d)) + 0.08; + setTimeout(() => { try { ctx.close(); } catch (_) { /* ignore */ } }, Math.max(120, Math.round((endAt - now) * 1000))); + }; - const endAt = now + Math.max(...pattern.map(p => p.o + p.d)) + 0.08; - setTimeout(() => { try { ctx.close(); } catch (_) { /* ignore */ } }, Math.max(120, Math.round((endAt - now) * 1000))); + // AudioContext starts suspended on mobile/Android after autoplay policy — + // must call resume() before scheduling nodes, even outside a user gesture. + if (ctx.state === 'suspended') { + ctx.resume().then(doPlay).catch(() => {}); + } else { + doPlay(); + } } catch (_) { /* ignore */ } } function _notifyCookingTimer(type, label) { const key = type === 'warning' ? 'cooking.timer_warning_tts' : 'cooking.timer_expired_tts'; const msg = t(key).replace('{label}', label || t('cooking.timer')); + + // Always play the beep (uses pre-unlocked shared AudioContext) + _playCookingTimerSound(type === 'warning' ? 'warning' : 'done'); + + // Timer alerts always speak — they are alarms, not step narration. + // Do NOT gate on _cookingTTS; that toggle is for step-by-step reading only. + // Also include the kiosk native TTS bridge which works even when + // window.speechSynthesis is absent on older Android WebView. const s = getSettings(); const hasBrowserTts = typeof window !== 'undefined' && 'speechSynthesis' in window; - const hasCustomTts = (s.tts_engine === 'custom' && !!s.tts_url); - - if (_cookingTTS && (hasBrowserTts || hasCustomTts)) { + const hasCustomTts = s.tts_engine === 'custom' && !!s.tts_url; + const hasKioskTts = typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function'; + if (hasBrowserTts || hasCustomTts || hasKioskTts) { speakCookingStep(msg); - } else { - _playCookingTimerSound(type === 'warning' ? 'warning' : 'done'); } } @@ -13411,6 +13854,7 @@ function addSuggestedCookingTimer() { } function addCookingTimer(seconds, label) { + _ensureAudioUnlocked(); // unlock AudioContext on this user gesture const id = ++_cookingTimerIdCounter; _cookingTimers.push({ id, label, total: seconds, seconds, running: false, interval: null }); renderTimersBar(); @@ -13472,8 +13916,12 @@ function _cookingTimerDoneById(id) { timer.running = false; timer.seconds = 0; + // Show the done state in the card before removing it + _updateTimerCard(id); + _updateScreenFlash(); _notifyCookingTimer('done', timer.label); - removeCookingTimer(id); // auto-cancel finished timer (do not continue past 00:00) + // Keep the done card visible for 3 s so the user sees which timer finished + setTimeout(() => removeCookingTimer(id), 3000); } function _updateTimerCard(id) { @@ -14361,7 +14809,7 @@ async function loadScreensaverData() { const [statsRes, invRes, bringRes] = await Promise.all([ api('stats'), api('inventory_list'), - api('bring_list').catch(() => null) + api('shopping_list').catch(() => null) ]); _screensaverData = { stats: statsRes, @@ -14857,7 +15305,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 +15328,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 +15380,30 @@ function _setupSteps() { ${t('btn.cancel')} — ${_currentLang === 'it' ? 'configura dopo' : 'configure later'} ` }, + { + title: '☁️ Google Drive Backup', + desc: t('settings.backup.gdrive_wizard_hint') || 'Optional: automatically back up to Google Drive daily.', + render: () => ` +
+ ${t('settings.backup.gdrive_oauth_how_to') || '📋 Setup guide'} +
    ${t('settings.backup.gdrive_oauth_steps') || ''}
+
+
+ + +
+
+ + +
+
+ + +
+

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

+ ${t('settings.backup.gdrive_skip') || 'Skip — configure later in Settings'} + ` + }, { title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : _currentLang === 'fr' ? 'Tout est prêt !' : _currentLang === 'es' ? '¡Todo listo!' : 'All set!'), desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.' @@ -14948,8 +15422,8 @@ function _setupSteps() { function showSetupWizard(pendingSteps) { _setupPendingSteps = pendingSteps || _getMissingSetupSteps(); if (_setupPendingSteps.length === 0) return; - // Append the "done" step (3) at the end - _setupPendingSteps.push(3); + // Append the "done" step (4) at the end + _setupPendingSteps.push(4); _setupStep = 0; // Pre-fill _setupData from existing settings so we don't lose them const s = getSettings(); @@ -15012,6 +15486,13 @@ function _setupCollectCurrent() { const pass = document.getElementById('setup-bring-password'); if (email) _setupData.bring_email = email.value.trim(); if (pass) _setupData.bring_password = pass.value.trim(); + } else if (realIndex === 3) { + const folderEl = document.getElementById('setup-gdrive-folder'); + const clientIdEl = document.getElementById('setup-gdrive-client-id'); + const clientSecretEl = document.getElementById('setup-gdrive-client-secret'); + if (folderEl) _setupData.gdrive_folder_id = folderEl.value.trim(); + if (clientIdEl) _setupData.gdrive_client_id = clientIdEl.value.trim(); + if (clientSecretEl) _setupData.gdrive_client_secret = clientSecretEl.value.trim(); } } @@ -15052,6 +15533,9 @@ async function _finishSetup() { if (_setupData.gemini_key) envPayload.gemini_key = _setupData.gemini_key; if (_setupData.bring_email) envPayload.bring_email = _setupData.bring_email; if (_setupData.bring_password) envPayload.bring_password = _setupData.bring_password; + if (_setupData.gdrive_folder_id) envPayload.gdrive_folder_id = _setupData.gdrive_folder_id; + if (_setupData.gdrive_client_id) { envPayload.gdrive_client_id = _setupData.gdrive_client_id; envPayload.gdrive_enabled = true; } + if (_setupData.gdrive_client_secret) envPayload.gdrive_client_secret = _setupData.gdrive_client_secret; try { if (Object.keys(envPayload).length > 0) { await api('save_settings', {}, 'POST', envPayload); @@ -15506,7 +15990,7 @@ async function _backgroundBringSync() { try { const [bringData, smartData] = await Promise.all([ - api('bring_list').catch(() => null), + api('shopping_list').catch(() => null), api('smart_shopping').catch(() => null), ]); @@ -15583,12 +16067,12 @@ async function _backgroundBringSync() { const allChanges = [...toAdd, ...toUpdate]; if (allChanges.length > 0) { - await api('bring_add', {}, 'POST', { items: allChanges, listUUID }); + await api('shopping_add', {}, 'POST', { items: allChanges, listUUID }); logOperation('bg_bring_sync', { added: toAdd.map(i=>i.name), updated: toUpdate.map(i=>i.name) }); } if (toRemove.length > 0) { - await api('bring_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID }); + await api('shopping_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID }); logOperation('bg_bring_remove', { removed: toRemove }); } diff --git a/data/backup_last_ts.json b/data/backup_last_ts.json new file mode 100644 index 0000000..fbe528e --- /dev/null +++ b/data/backup_last_ts.json @@ -0,0 +1 @@ +{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444} \ No newline at end of file diff --git a/evershelf-kiosk/app/build.gradle.kts b/evershelf-kiosk/app/build.gradle.kts index 9f00586..a152b2a 100644 --- a/evershelf-kiosk/app/build.gradle.kts +++ b/evershelf-kiosk/app/build.gradle.kts @@ -5,14 +5,14 @@ plugins { android { namespace = "it.dadaloop.evershelf.kiosk" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "it.dadaloop.evershelf.kiosk" minSdk = 24 - targetSdk = 34 - versionCode = 17 - versionName = "1.7.16" + targetSdk = 35 + versionCode = 18 + versionName = "1.7.17" } signingConfigs { diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index 769e3a5..b276849 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -774,7 +774,13 @@ class KioskActivity : AppCompatActivity() { val q = DownloadManager.Query().setFilterById(downloadId) val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q) var ok = false - if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL + var dmStatus = -1 + var dmReason = -1 + if (c.moveToFirst()) { + dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)) + ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL + } c.close() if (ok) { pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1 @@ -784,7 +790,12 @@ class KioskActivity : AppCompatActivity() { pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1 setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2) runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) } - ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl") + ErrorReporter.reportMessage( + "install_download_failed", + "DownloadManager returned failure for URL: $apkUrl", + mapOf("dm_status" to dmStatus, "dm_reason" to dmReason, + "device" to buildDeviceLabel()) + ) } } } @@ -868,6 +879,11 @@ class KioskActivity : AppCompatActivity() { val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) // Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1) // on some OEM/Android versions even when the package name is correct. + // setInstallReason is required on Android 14+ (API 34+) for PackageInstaller + // to accept self-updates; without it Android 16 returns STATUS_FAILURE=1. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER) + } val sessionId = pi.createSession(params) val session = pi.openSession(sessionId) try { diff --git a/index.html b/index.html index f5eb4a3..db97ea7 100644 --- a/index.html +++ b/index.html @@ -833,7 +833,7 @@
- + @@ -841,6 +841,7 @@ +
@@ -954,9 +955,36 @@
+
-

🛒 Bring! Shopping List

-

Credenziali per l'integrazione con la lista della spesa Bring!

+

🛒 Lista della spesa

+

Configura la lista della spesa integrata o collega Bring!.

+
+ +
+
+ +
+ + +
+
+
+ + + +
+

Assistenza AI

+
+ +
+
+ +
+
+ +
+ + + +
+

rimasto in magazzino (0 = solo quando esaurito)

+
+

💰 Stima Prezzi (AI)

@@ -1342,6 +1401,93 @@
+ +
+ +
+

💾 Backup Locale

+

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

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

Caricamento…

+
+
+ +
+

☁️ Google Drive

+

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

+
+ +
+
+ +
+ + +

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

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

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

    +
    +
    + + +
    + + +
    + +
    + + +
    +
    + + +
    + +
    +
    +
    +
    diff --git a/manifest.json b/manifest.json index 9e559d3..9767f69 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "name": "EverShelf", "short_name": "EverShelf", "description": "Gestione completa della dispensa di casa con scansione barcode", - "version": "1.7.23", + "version": "1.7.24", "start_url": "/evershelf/", "display": "standalone", "background_color": "#f0f4e8", diff --git a/translations/de.json b/translations/de.json index 4e0738e..6746df1 100644 --- a/translations/de.json +++ b/translations/de.json @@ -762,6 +762,53 @@ "card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.", "label": "Tipps beim Kochen anzeigen" }, + "backup": { + "tab": "Backup", + "local_title": "Lokales Backup", + "local_hint": "Täglicher Datenbank-Snapshot. Konfiguriere, wie viele Tage Backups aufbewahrt werden.", + "enabled": "Tägliches automatisches Backup aktivieren", + "retention_days": "Aufbewahrung (Tage)", + "retention_info": "Backups werden aufbewahrt für", + "backup_now": "Jetzt sichern", + "backing_up": "Sicherung läuft…", + "backed_up": "Sicherung abgeschlossen", + "backup_error": "Sicherungsfehler", + "last_backup": "Letztes Backup", + "no_backup_yet": "Noch kein Backup erstellt", + "list_empty": "Keine Backups verfügbar", + "restore_btn": "Wiederherstellen", + "restore_confirm": "Backup wiederherstellen", + "delete_btn": "Löschen", + "delete_confirm": "Backup löschen", + "gdrive_title": "Google Drive", + "gdrive_hint": "Backups automatisch via OAuth 2.0 auf Google Drive hochladen. Keine externen Bibliotheken erforderlich.", + "gdrive_enabled": "Google Drive Backup aktivieren", + "gdrive_folder_id": "Drive-Ordner-ID", + "gdrive_folder_id_hint": "Kopiere die ID aus der Drive-Ordner-URL: …/folders/ID", + "gdrive_retention_days": "Drive-Aufbewahrung (Tage, 0=alles behalten)", + "gdrive_test": "Verbindung testen", + "gdrive_ok": "Verbindung erfolgreich!", + "gdrive_error": "Verbindung fehlgeschlagen", + "gdrive_push_now": "Jetzt auf Drive hochladen", + "gdrive_pushing": "Wird hochgeladen…", + "gdrive_pushed": "Auf Drive hochgeladen", + "gdrive_wizard_hint": "Optional: täglich automatisch via OAuth 2.0 auf Google Drive sichern.", + "gdrive_skip": "Überspringen — später in Einstellungen konfigurieren", + "gdrive_client_id": "Client-ID", + "gdrive_client_secret": "Client-Secret", + "gdrive_redirect_uri_hint": "Füge http://localhost als autorisierten Weiterleitungs-URI in der Google Cloud Console hinzu. Funktioniert auf jedem Server, auch ohne öffentliche Domain.", + "gdrive_code_title": "Autorisierungs-URL oder Code einfügen", + "gdrive_code_hint": "Nach der Autorisierung öffnet der Browser http://localhost und zeigt möglicherweise einen Verbindungsfehler — das ist normal. Kopiere die URL aus der Adressleiste (z.B. http://localhost/?code=4%2F0A...) und füge sie hier ein.", + "gdrive_code_submit": "Bestätigen", + "gdrive_code_empty": "Bitte zuerst die URL oder den Autorisierungscode einfügen", + "gdrive_redirect_uri_label": "Redirect-URI (in Google Cloud Console eintragen):", + "gdrive_oauth_authorize": "Mit Google autorisieren", + "gdrive_oauth_authorized": "Autorisiert", + "gdrive_oauth_not_authorized": "Noch nicht autorisiert", + "gdrive_oauth_window_opened": "Browserfenster geöffnet — autorisieren und zurückkehren", + "gdrive_oauth_how_to": "OAuth 2.0 einrichten (Schritt für Schritt)", + "gdrive_oauth_steps": "
  1. Gehe zu console.cloud.google.com und wähle dein Projekt
  2. Aktiviere die Google Drive API: APIs & Dienste → APIs aktivieren → Google Drive API
  3. Gehe zu APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID
  4. Anwendungstyp: Webanwendung; füge die unten angezeigte URL als Autorisierter Weiterleitungs-URI hinzu
  5. Kopiere Client-ID und Client-Secret in die Felder oben und speichere
  6. Klicke auf Mit Google autorisieren: melde dich an und erteile den Zugriff
  7. Das Fenster schließt sich automatisch und Backups sind bereit
  8. " + }, "info": { "tab": "Info", "ai_title": "Gemini AI — Token-Nutzung", @@ -802,7 +849,22 @@ "currency_title": "Währung", "currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird." }, - "tab_general": "Allgemein" + "tab_general": "Allgemein", + "shopping": { + "tab": "Einkaufsliste", + "title": "Einkaufsliste", + "hint": "Konfiguriere die integrierte Einkaufsliste oder verbinde Bring!.", + "enable_label": "Einkaufsliste aktivieren", + "mode_label": "Anbieter", + "mode_internal": "Intern (ohne Bring!)", + "mode_bring": "Bring! (externe App)", + "bring_section_title": "Bring!-Konfiguration", + "ai_section_title": "KI-Unterstützung", + "smart_suggestions_label": "KI-Vorschläge", + "forecast_label": "Prognose für bald leere Produkte", + "auto_add_label": "Automatisch hinzufügen wenn", + "auto_add_suffix": "im Lager verbleibend (0 = nur wenn leer)" + } }, "expiry": { "today": "HEUTE", @@ -1034,7 +1096,10 @@ "retake_btn": "🔄 Erneut aufnehmen", "camera_error_hint": "Stelle sicher, dass du HTTPS verwendest und Kameraberechtigungen erteilt hast.
    Du kannst den Barcode manuell eingeben oder die KI-Identifikation verwenden.", "no_barcode": "Kein Barcode", - "save_new_btn": "🆕 Keines davon — als neu speichern" + "save_new_btn": "🆕 Keines davon — als neu speichern", + "expiry_found": "Datum gefunden", + "expiry_read_fail": "Datum konnte nicht gelesen werden.", + "expiry_raw_label": "Erkannt" }, "lowstock": { "title": "⚠️ Wird knapp!", diff --git a/translations/en.json b/translations/en.json index 4e79ea5..92826c4 100644 --- a/translations/en.json +++ b/translations/en.json @@ -762,6 +762,53 @@ "card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.", "label": "Show tips during cooking" }, + "backup": { + "tab": "Backup", + "local_title": "Local Backup", + "local_hint": "Daily database snapshot. Configure how many days of backups to keep.", + "enabled": "Enable daily automatic backup", + "retention_days": "Retention (days)", + "retention_info": "Backups are kept for", + "backup_now": "Backup Now", + "backing_up": "Backing up…", + "backed_up": "Backup complete", + "backup_error": "Backup error", + "last_backup": "Last backup", + "no_backup_yet": "No backup has been created yet", + "list_empty": "No backups available", + "restore_btn": "Restore", + "restore_confirm": "Restore backup", + "delete_btn": "Delete", + "delete_confirm": "Delete backup", + "gdrive_title": "Google Drive", + "gdrive_hint": "Automatically back up to Google Drive via OAuth 2.0. No external libraries required.", + "gdrive_enabled": "Enable Google Drive backup", + "gdrive_folder_id": "Drive Folder ID", + "gdrive_folder_id_hint": "Copy the ID from the Drive folder URL: …/folders/ID", + "gdrive_retention_days": "Drive retention (days, 0=keep all)", + "gdrive_test": "Test Connection", + "gdrive_ok": "Connection successful!", + "gdrive_error": "Connection failed", + "gdrive_push_now": "Upload to Drive Now", + "gdrive_pushing": "Uploading…", + "gdrive_pushed": "Uploaded to Drive", + "gdrive_wizard_hint": "Optional: automatically back up to Google Drive daily via OAuth 2.0.", + "gdrive_skip": "Skip — configure later in Settings", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_hint": "Add http://localhost as an authorized redirect URI in Google Cloud Console. This works on any server, even without a public domain.", + "gdrive_code_title": "Paste the authorization URL or code", + "gdrive_code_hint": "After authorizing, the browser will open http://localhost and may show a connection error — that is expected. Copy the URL from the address bar (e.g. http://localhost/?code=4%2F0A...) and paste it here.", + "gdrive_code_submit": "Submit", + "gdrive_code_empty": "Paste the URL or authorization code first", + "gdrive_redirect_uri_label": "Redirect URI (add this in Google Cloud Console):", + "gdrive_oauth_authorize": "Authorize with Google", + "gdrive_oauth_authorized": "Authorized", + "gdrive_oauth_not_authorized": "Not authorized yet", + "gdrive_oauth_window_opened": "Browser window opened — authorize and come back", + "gdrive_oauth_how_to": "How to set up OAuth 2.0 (step by step)", + "gdrive_oauth_steps": "
  9. Go to console.cloud.google.com and select your project
  10. Enable the Google Drive API: APIs & Services → Enable APIs → Google Drive API
  11. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID
  12. Application type: Web application; add http://localhost as an Authorized redirect URI
  13. Copy the Client ID and Client Secret into the fields above and save
  14. Click Authorize with Google, sign in and grant access
  15. The browser will open http://localhost (a connection error is expected): copy the URL from the address bar and paste it in the field that appears below
  16. " + }, "info": { "tab": "Info", "ai_title": "Gemini AI — Token Usage", @@ -802,7 +849,22 @@ "currency_title": "Currency", "currency_hint": "The currency used for all costs and prices in the app." }, - "tab_general": "General" + "tab_general": "General", + "shopping": { + "tab": "Shopping list", + "title": "Shopping list", + "hint": "Configure the built-in shopping list or connect Bring!.", + "enable_label": "Enable shopping list", + "mode_label": "Provider", + "mode_internal": "Built-in (no Bring!)", + "mode_bring": "Bring! (external app)", + "bring_section_title": "Bring! configuration", + "ai_section_title": "AI assistance", + "smart_suggestions_label": "AI suggestions", + "forecast_label": "Forecast low-stock products", + "auto_add_label": "Auto-add to list when", + "auto_add_suffix": "remaining in stock (0 = only when empty)" + } }, "expiry": { "today": "TODAY", @@ -1034,7 +1096,10 @@ "retake_btn": "🔄 Retake", "camera_error_hint": "Ensure you use HTTPS and have granted camera permissions.
    You can enter the barcode manually or use AI identification.", "no_barcode": "No barcode", - "save_new_btn": "🆕 None of these — save as new" + "save_new_btn": "🆕 None of these — save as new", + "expiry_found": "Date found", + "expiry_read_fail": "Cannot read the date.", + "expiry_raw_label": "Read" }, "lowstock": { "title": "⚠️ Running low!", diff --git a/translations/es.json b/translations/es.json index 583c8b3..7d0bf68 100644 --- a/translations/es.json +++ b/translations/es.json @@ -759,6 +759,68 @@ "card_title": "♻️ Consejos sin desperdicios", "card_hint": "Durante la cocción, muestra consejos sobre cómo reutilizar los restos generados en cada paso (peladuras, agua de cocción, etc.). Desactivado por defecto.", "label": "Mostrar consejos durante la cocción" + }, + "backup": { + "tab": "Copia de seguridad", + "local_title": "Copia local", + "local_hint": "Instantánea diaria de la base de datos. Configura cuántos días de copias de seguridad conservar.", + "enabled": "Activar copia de seguridad diaria automática", + "retention_days": "Retención (días)", + "retention_info": "Las copias se conservan durante", + "backup_now": "Hacer copia ahora", + "backing_up": "Haciendo copia…", + "backed_up": "Copia completada", + "backup_error": "Error en la copia", + "last_backup": "Última copia", + "no_backup_yet": "Aún no se ha creado ninguna copia", + "list_empty": "No hay copias disponibles", + "restore_btn": "Restaurar", + "restore_confirm": "Restaurar la copia", + "delete_btn": "Eliminar", + "delete_confirm": "Eliminar la copia", + "gdrive_title": "Google Drive", + "gdrive_hint": "Copias de seguridad automáticas en Google Drive via OAuth 2.0. No se requieren bibliotecas externas.", + "gdrive_enabled": "Activar copia en Google Drive", + "gdrive_folder_id": "ID de carpeta de Drive", + "gdrive_folder_id_hint": "Copia el ID desde la URL de la carpeta de Drive: …/folders/ID", + "gdrive_retention_days": "Retención en Drive (días, 0=mantener todo)", + "gdrive_test": "Probar conexión", + "gdrive_ok": "Conexión exitosa!", + "gdrive_error": "Conexión fallida", + "gdrive_push_now": "Subir a Drive ahora", + "gdrive_pushing": "Subiendo…", + "gdrive_pushed": "Subido a Drive", + "gdrive_wizard_hint": "Opcional: copia de seguridad diaria automática en Google Drive via OAuth 2.0.", + "gdrive_skip": "Omitir — configurar después en Ajustes", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_hint": "Agrega http://localhost como URI de redireccionamiento autorizado en Google Cloud Console. Funciona en cualquier servidor, incluso sin dominio público.", + "gdrive_code_title": "Pegar la URL o el código de autorización", + "gdrive_code_hint": "Tras autorizar, el navegador abrirá http://localhost y puede mostrar un error de conexión — es normal. Copia la URL de la barra de direcciones (ej. http://localhost/?code=4%2F0A...) y pégala aquí.", + "gdrive_code_submit": "Confirmar", + "gdrive_code_empty": "Pega primero la URL o el código de autorización", + "gdrive_redirect_uri_label": "URI de redirección (agregar en Google Cloud Console):", + "gdrive_oauth_authorize": "Autorizar con Google", + "gdrive_oauth_authorized": "Autorizado", + "gdrive_oauth_not_authorized": "Aún no autorizado", + "gdrive_oauth_window_opened": "Ventana abierta — autoriza y regresa aquí", + "gdrive_oauth_how_to": "Cómo configurar OAuth 2.0 (paso a paso)", + "gdrive_oauth_steps": "
  17. Ve a console.cloud.google.com y selecciona tu proyecto
  18. Habilita la API de Google Drive: API y servicios → Habilitar API → Google Drive API
  19. Ve a API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth
  20. Tipo de aplicación: Aplicación web; agrega la URL mostrada abajo como URI de redirección autorizado
  21. Copia el Client ID y el Client Secret en los campos de arriba y guarda
  22. Haz clic en Autorizar con Google: inicia sesión en tu cuenta de Google y concede acceso
  23. La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas
  24. " + }, + "shopping": { + "tab": "Lista de la compra", + "title": "Lista de la compra", + "hint": "Configura la lista de la compra integrada o conecta Bring!.", + "enable_label": "Activar lista de la compra", + "mode_label": "Proveedor", + "mode_internal": "Integrado (sin Bring!)", + "mode_bring": "Bring! (app externa)", + "bring_section_title": "Configuración de Bring!", + "ai_section_title": "Asistencia IA", + "smart_suggestions_label": "Sugerencias IA", + "forecast_label": "Previsión de productos por agotar", + "auto_add_label": "Añadir automáticamente cuando", + "auto_add_suffix": "restante en stock (0 = solo cuando se agota)" } }, "expiry": { diff --git a/translations/fr.json b/translations/fr.json index f37b12a..a7f549d 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -759,6 +759,68 @@ "card_title": "♻️ Conseils zéro déchet", "card_hint": "Pendant la cuisson, affichez des conseils pour réutiliser les déchets produits à chaque étape (épluchures, eau de cuisson, etc.). Désactivé par défaut.", "label": "Afficher les conseils pendant la cuisson" + }, + "backup": { + "tab": "Sauvegarde", + "local_title": "Sauvegarde locale", + "local_hint": "Instantané quotidien de la base de données. Configurez le nombre de jours de rétention.", + "enabled": "Activer la sauvegarde automatique quotidienne", + "retention_days": "Rétention (jours)", + "retention_info": "Les sauvegardes sont conservées pendant", + "backup_now": "Sauvegarder maintenant", + "backing_up": "Sauvegarde en cours…", + "backed_up": "Sauvegarde terminée", + "backup_error": "Erreur de sauvegarde", + "last_backup": "Dernière sauvegarde", + "no_backup_yet": "Aucune sauvegarde créée", + "list_empty": "Aucune sauvegarde disponible", + "restore_btn": "Restaurer", + "restore_confirm": "Restaurer la sauvegarde", + "delete_btn": "Supprimer", + "delete_confirm": "Supprimer la sauvegarde", + "gdrive_title": "Google Drive", + "gdrive_hint": "Sauvegardez automatiquement sur Google Drive via OAuth 2.0. Aucune bibliothèque externe requise.", + "gdrive_enabled": "Activer la sauvegarde Google Drive", + "gdrive_folder_id": "ID du dossier Drive", + "gdrive_folder_id_hint": "Copiez l'ID depuis l'URL du dossier Drive : …/folders/ID", + "gdrive_retention_days": "Rétention Drive (jours, 0=tout garder)", + "gdrive_test": "Tester la connexion", + "gdrive_ok": "Connexion réussie !", + "gdrive_error": "Échec de la connexion", + "gdrive_push_now": "Téléverser sur Drive maintenant", + "gdrive_pushing": "Téléversement en cours…", + "gdrive_pushed": "Téléversé sur Drive", + "gdrive_wizard_hint": "Optionnel : sauvegarde quotidienne automatique sur Google Drive via OAuth 2.0.", + "gdrive_skip": "Passer — configurer plus tard dans Paramètres", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_hint": "Ajoute http://localhost comme URI de redirection autorisé dans la Google Cloud Console. Fonctionne sur n'importe quel serveur, même sans domaine public.", + "gdrive_code_title": "Coller l'URL ou le code d'autorisation", + "gdrive_code_hint": "Après autorisation, le navigateur ouvre http://localhost et peut afficher une erreur de connexion — c'est normal. Copie l'URL dans la barre d'adresse (ex. http://localhost/?code=4%2F0A...) et colle-la ici.", + "gdrive_code_submit": "Confirmer", + "gdrive_code_empty": "Coller d'abord l'URL ou le code d'autorisation", + "gdrive_redirect_uri_label": "URI de redirection (ajouter dans Google Cloud Console) :", + "gdrive_oauth_authorize": "Autoriser avec Google", + "gdrive_oauth_authorized": "Autorisé", + "gdrive_oauth_not_authorized": "Pas encore autorisé", + "gdrive_oauth_window_opened": "Fenêtre ouverte — autorisez et revenez ici", + "gdrive_oauth_how_to": "Configurer OAuth 2.0 (étape par étape)", + "gdrive_oauth_steps": "
  25. Allez sur console.cloud.google.com et sélectionnez votre projet
  26. Activez l’API Google Drive : API et services → Activer les API → Google Drive API
  27. Allez dans API et services → Identifiants → Créer des identifiants → ID client OAuth
  28. Type d’application : Application Web ; ajoutez l’URL affichée ci-dessous comme URI de redirection autorisé
  29. Copiez le Client ID et le Client Secret dans les champs ci-dessus et enregistrez
  30. Cliquez sur Autoriser avec Google : connectez-vous et accordez l’accès
  31. La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes
  32. " + }, + "shopping": { + "tab": "Liste de courses", + "title": "Liste de courses", + "hint": "Configurez la liste de courses intégrée ou connectez Bring!.", + "enable_label": "Activer la liste de courses", + "mode_label": "Fournisseur", + "mode_internal": "Intégré (sans Bring!)", + "mode_bring": "Bring! (application externe)", + "bring_section_title": "Configuration Bring!", + "ai_section_title": "Assistance IA", + "smart_suggestions_label": "Suggestions IA", + "forecast_label": "Prévision des produits bientôt épuisés", + "auto_add_label": "Ajouter automatiquement quand", + "auto_add_suffix": "restant en stock (0 = seulement quand épuisé)" } }, "expiry": { diff --git a/translations/it.json b/translations/it.json index e7f885f..fb9a996 100644 --- a/translations/it.json +++ b/translations/it.json @@ -762,6 +762,53 @@ "card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.", "label": "Mostra suggerimenti durante la cottura" }, + "backup": { + "tab": "Backup", + "local_title": "Backup Locale", + "local_hint": "Snapshot giornaliero del database. Configura quanti giorni di backup conservare.", + "enabled": "Backup automatico quotidiano", + "retention_days": "Giorni di retention", + "retention_info": "I backup vengono conservati per", + "backup_now": "Backup Ora", + "backing_up": "Backup in corso…", + "backed_up": "Backup completato", + "backup_error": "Errore backup", + "last_backup": "Ultimo backup", + "no_backup_yet": "Nessun backup ancora eseguito", + "list_empty": "Nessun backup disponibile", + "restore_btn": "Ripristina", + "restore_confirm": "Ripristinare il backup", + "delete_btn": "Elimina", + "delete_confirm": "Eliminare il backup", + "gdrive_title": "Google Drive", + "gdrive_hint": "Backup automatici su Google Drive via OAuth 2.0. Nessuna libreria esterna richiesta.", + "gdrive_enabled": "Abilita backup Google Drive", + "gdrive_folder_id": "ID Cartella Drive", + "gdrive_folder_id_hint": "Copia l'ID dalla URL della cartella Drive: …/folders/ID", + "gdrive_retention_days": "Retention Drive (giorni, 0=tutto)", + "gdrive_test": "Testa Connessione", + "gdrive_ok": "Connessione riuscita!", + "gdrive_error": "Connessione fallita", + "gdrive_push_now": "Carica Ora su Drive", + "gdrive_pushing": "Upload in corso…", + "gdrive_pushed": "Caricato su Drive", + "gdrive_wizard_hint": "Opzionale: backup giornaliero automatico su Google Drive via OAuth 2.0.", + "gdrive_skip": "Salta — configura dopo in Impostazioni", + "gdrive_client_id": "Client ID", + "gdrive_client_secret": "Client Secret", + "gdrive_redirect_uri_label": "Redirect URI (da aggiungere in Google Cloud Console):", + "gdrive_redirect_uri_hint": "Aggiungi http://localhost come URI di reindirizzamento autorizzato in Google Cloud Console. Funziona su qualsiasi server, anche senza dominio pubblico.", + "gdrive_oauth_authorize": "Autorizza con Google", + "gdrive_oauth_authorized": "Autorizzato", + "gdrive_oauth_not_authorized": "Non ancora autorizzato", + "gdrive_oauth_window_opened": "Finestra aperta — autorizza e torna qui", + "gdrive_oauth_how_to": "Come configurare OAuth 2.0 (passo dopo passo)", + "gdrive_oauth_steps": "
  33. Vai su console.cloud.google.com e seleziona il progetto
  34. Abilita la Google Drive API: API e servizi → Abilita API → Google Drive API
  35. Vai su API e servizi → Credenziali → Crea credenziali → ID client OAuth 2.0
  36. Tipo applicazione: Applicazione web; aggiungi http://localhost come URI di reindirizzamento autorizzato
  37. Copia Client ID e Client Secret nei campi qui sopra e salva
  38. Clicca Autorizza con Google, accedi e concedi l'accesso
  39. Il browser aprirà http://localhost (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sotto
  40. ", + "gdrive_code_title": "Incolla l'URL o il codice di autorizzazione", + "gdrive_code_hint": "Dopo aver autorizzato, il browser aprirà http://localhost e potrebbe mostrare un errore. Copia l'URL dalla barra degli indirizzi (es. http://localhost/?code=4%2F0A...) e incollalo qui.", + "gdrive_code_submit": "Conferma", + "gdrive_code_empty": "Incolla prima l'URL o il codice di autorizzazione" + }, "info": { "tab": "Info", "ai_title": "Gemini AI — Utilizzo Token", @@ -802,7 +849,22 @@ "currency_title": "Valuta", "currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app." }, - "tab_general": "Generali" + "tab_general": "Generali", + "shopping": { + "tab": "Lista spesa", + "title": "Lista della spesa", + "hint": "Configura la lista della spesa integrata o collega Bring!.", + "enable_label": "Abilita lista della spesa", + "mode_label": "Provider", + "mode_internal": "Interno (senza Bring!)", + "mode_bring": "Bring! (app esterna)", + "bring_section_title": "Configurazione Bring!", + "ai_section_title": "Assistenza AI", + "smart_suggestions_label": "Suggerimenti AI", + "forecast_label": "Previsione prodotti in esaurimento", + "auto_add_label": "Aggiungi automaticamente quando", + "auto_add_suffix": "rimasto in magazzino (0 = solo quando esaurito)" + } }, "expiry": { "today": "OGGI", @@ -1034,7 +1096,10 @@ "retake_btn": "🔄 Riscatta", "camera_error_hint": "Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.
    Puoi inserire il barcode manualmente o usare l'identificazione AI.", "no_barcode": "Senza barcode", - "save_new_btn": "🆕 Non è nessuno di questi — salva come nuovo" + "save_new_btn": "🆕 Non è nessuno di questi — salva come nuovo", + "expiry_found": "Data trovata", + "expiry_read_fail": "Non riesco a leggere la data.", + "expiry_raw_label": "Letto" }, "lowstock": { "title": "⚠️ Sta per finire!",