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 @@ [](https://www.sqlite.org/) [](Dockerfile) [](translations/) -[](CHANGELOG.md) +[](CHANGELOG.md) [](https://github.com/dadaloop82/EverShelf/stargazers) [](https://github.com/dadaloop82/EverShelf/commits/main) [](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 '
No authorization code received.
'; + return; + } + $clientId = env('GDRIVE_CLIENT_ID', ''); + $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); + $redirectUri = _gdriveRedirectUri(); + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code', + ]), + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + curl_close($ch); + $data = json_decode($response, true); + header('Content-Type: text/html; charset=utf-8'); + if (!empty($data['refresh_token'])) { + _gdriveSetEnvVar('GDRIVE_REFRESH_TOKEN', $data['refresh_token']); + echo 'EverShelf can now back up to your Google Drive.
' + . 'This tab will close automatically.
' + . '' + . ''; + } else { + $err = htmlspecialchars($data['error_description'] ?? $data['error'] ?? 'Unknown error'); + http_response_code(400); + echo "$err
"; + } +} + +/** + * Obtain a short-lived Google API access token via OAuth 2.0 refresh token. + * Returns ['token' => string] on success, ['error' => string] on failure. + */ +function _gdriveGetToken(): ?string { return _gdriveGetTokenOAuth()['token'] ?? null; } +function _gdriveGetTokenEx(): array { return _gdriveGetTokenOAuth(); } + +/** + * Upload a file to Google Drive using multipart upload. + * Returns the Drive file ID on success, null on failure. + */ +/** Returns ['id' => string] on success or ['error' => string] on failure. */ +function _gdriveUploadFile(string $token, string $folderId, string $filePath, string $remoteName): array { + if (!file_exists($filePath)) return ['error' => 'Local backup file not found: ' . $filePath]; + $mimeType = 'application/x-sqlite3'; + $metadata = json_encode(['name' => $remoteName, 'parents' => [$folderId]]); + $fileContent = file_get_contents($filePath); + $boundary = 'es_backup_' . bin2hex(random_bytes(8)); + $body = "--$boundary\r\n" + . "Content-Type: application/json; charset=UTF-8\r\n\r\n" + . $metadata . "\r\n" + . "--$boundary\r\n" + . "Content-Type: $mimeType\r\n\r\n" + . $fileContent . "\r\n" + . "--$boundary--"; + + $ch = curl_init('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer $token", + "Content-Type: multipart/related; boundary=$boundary", + "Content-Length: " . strlen($body), + ], + CURLOPT_TIMEOUT => 120, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $curlErr = curl_error($ch); + curl_close($ch); + if (!$response) return ['error' => 'cURL upload failed: ' . $curlErr]; + $data = json_decode($response, true); + if (!empty($data['id'])) return ['id' => $data['id']]; + $apiErr = $data['error']['message'] ?? $data['error']['status'] ?? json_encode($data); + return ['error' => 'Drive API error: ' . $apiErr]; +} + +/** + * Delete Drive backups older than $retentionDays. + * Returns count of deleted files. + */ +function _gdrivePurgeOld(string $token, string $folderId, int $retentionDays): int { + if ($retentionDays <= 0) return 0; + $cutoff = date('c', strtotime("-{$retentionDays} days")); + $q = "'$folderId' in parents and name contains 'evershelf_' and trashed=false"; + $url = 'https://www.googleapis.com/drive/v3/files?' + . http_build_query(['q' => $q, 'fields' => 'files(id,name,createdTime)', 'pageSize' => '1000']); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + curl_close($ch); + if (!$response) return 0; + $data = json_decode($response, true); + $deleted = 0; + foreach ($data['files'] ?? [] as $file) { + if (!empty($file['createdTime']) && $file['createdTime'] < $cutoff) { + $ch = curl_init("https://www.googleapis.com/drive/v3/files/{$file['id']}"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], + CURLOPT_TIMEOUT => 15, + ]); + curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code === 204) $deleted++; + } + } + return $deleted; +} + +/** + * Full backup flow: create local snapshot, upload to Google Drive, purge old Drive files. + */ +function backupToGDrive(?PDO $db = null): array { + EverLog::info('backupToGDrive'); + if (env('GDRIVE_ENABLED', 'false') !== 'true') { + return ['success' => false, 'error' => 'Google Drive backup is not enabled']; + } + $folderId = env('GDRIVE_FOLDER_ID', ''); + if (empty($folderId)) { + return ['success' => false, 'error' => 'GDRIVE_FOLDER_ID not configured']; + } + + // 1. Create (or reuse recent) local backup + $local = createLocalBackup($db); + if (!$local['success']) return $local; + + // 2. Authenticate with Google + $tokResult = _gdriveGetTokenEx(); + if (empty($tokResult['token'])) { + return ['success' => false, 'error' => $tokResult['error'] ?? 'Google Drive authentication failed']; + } + $token = $tokResult['token']; + + // 3. Upload + $uploadResult = _gdriveUploadFile($token, $folderId, $local['path'], $local['filename']); + if (empty($uploadResult['id'])) { + return ['success' => false, 'error' => $uploadResult['error'] ?? 'Upload to Google Drive failed']; + } + $driveFileId = $uploadResult['id']; + + // 4. Purge old files on Drive + $retentionDays = max(0, (int)env('GDRIVE_RETENTION_DAYS', '30')); + $purgedRemote = $retentionDays > 0 ? _gdrivePurgeOld($token, $folderId, $retentionDays) : 0; + + EverLog::info('backupToGDrive ok', ['file' => $local['filename'], 'drive_id' => $driveFileId, 'purged_remote' => $purgedRemote]); + return [ + 'success' => true, + 'filename' => $local['filename'], + 'size_kb' => $local['size_kb'], + 'drive_file_id' => $driveFileId, + 'purged_local' => $local['purged'], + 'purged_remote' => $purgedRemote, + 'created_at' => $local['created_at'], + ]; +} + /** * Server-side Bring! cleanup: remove items from Bring! that the app auto-added * but are no longer flagged by smart shopping (stock is now adequate). @@ -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 `${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 = `✅ 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, '
')}
❌ Non riesco a leggere la data. ${result.raw_text ? '
Letto: ' + escapeHtml(result.raw_text) + '' : ''}
❌ ${t('scanner.expiry_read_fail')} ${result.raw_text ? '
' + t('scanner.expiry_raw_label') + ': ' + escapeHtml(result.raw_text) + '' : ''}
${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}
${t('settings.backup.gdrive_redirect_uri_label') || 'Redirect URI:'} http://localhost
Credenziali per l'integrazione con la lista della spesa Bring!
+Configura la lista della spesa integrata o collega Bring!.
+rimasto in magazzino (0 = solo quando esaurito)
+Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).
+Caricamento…
+Carica automaticamente il backup su Google Drive usando un Service Account.
+Copia l'ID dalla URL della cartella Drive: …/folders/ID
+http://localhost
+ Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa http://localhost.
+http://localhost/?code=4%2F0A...) und füge sie hier ein.",
+ "gdrive_code_submit": "Bestätigen",
+ "gdrive_code_empty": "Bitte zuerst die URL oder den Autorisierungscode einfügen",
+ "gdrive_redirect_uri_label": "Redirect-URI (in Google Cloud Console eintragen):",
+ "gdrive_oauth_authorize": "Mit Google autorisieren",
+ "gdrive_oauth_authorized": "Autorisiert",
+ "gdrive_oauth_not_authorized": "Noch nicht autorisiert",
+ "gdrive_oauth_window_opened": "Browserfenster geöffnet — autorisieren und zurückkehren",
+ "gdrive_oauth_how_to": "OAuth 2.0 einrichten (Schritt für Schritt)",
+ "gdrive_oauth_steps": "http://localhost/?code=4%2F0A...) and paste it here.",
+ "gdrive_code_submit": "Submit",
+ "gdrive_code_empty": "Paste the URL or authorization code first",
+ "gdrive_redirect_uri_label": "Redirect URI (add this in Google Cloud Console):",
+ "gdrive_oauth_authorize": "Authorize with Google",
+ "gdrive_oauth_authorized": "Authorized",
+ "gdrive_oauth_not_authorized": "Not authorized yet",
+ "gdrive_oauth_window_opened": "Browser window opened — authorize and come back",
+ "gdrive_oauth_how_to": "How to set up OAuth 2.0 (step by step)",
+ "gdrive_oauth_steps": "http://localhost (a connection error is expected): copy the URL from the address bar and paste it in the field that appears belowhttp://localhost/?code=4%2F0A...) y pégala aquí.",
+ "gdrive_code_submit": "Confirmar",
+ "gdrive_code_empty": "Pega primero la URL o el código de autorización",
+ "gdrive_redirect_uri_label": "URI de redirección (agregar en Google Cloud Console):",
+ "gdrive_oauth_authorize": "Autorizar con Google",
+ "gdrive_oauth_authorized": "Autorizado",
+ "gdrive_oauth_not_authorized": "Aún no autorizado",
+ "gdrive_oauth_window_opened": "Ventana abierta — autoriza y regresa aquí",
+ "gdrive_oauth_how_to": "Cómo configurar OAuth 2.0 (paso a paso)",
+ "gdrive_oauth_steps": "http://localhost/?code=4%2F0A...) et colle-la ici.",
+ "gdrive_code_submit": "Confirmer",
+ "gdrive_code_empty": "Coller d'abord l'URL ou le code d'autorisation",
+ "gdrive_redirect_uri_label": "URI de redirection (ajouter dans Google Cloud Console) :",
+ "gdrive_oauth_authorize": "Autoriser avec Google",
+ "gdrive_oauth_authorized": "Autorisé",
+ "gdrive_oauth_not_authorized": "Pas encore autorisé",
+ "gdrive_oauth_window_opened": "Fenêtre ouverte — autorisez et revenez ici",
+ "gdrive_oauth_how_to": "Configurer OAuth 2.0 (étape par étape)",
+ "gdrive_oauth_steps": "http://localhost (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sottohttp://localhost/?code=4%2F0A...) e incollalo qui.",
+ "gdrive_code_submit": "Conferma",
+ "gdrive_code_empty": "Incolla prima l'URL o il codice di autorizzazione"
+ },
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Utilizzo Token",
@@ -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.