From fa0442e2f66ec8dd8717787bf27f04987aef1750 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 19 May 2026 16:05:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20native=20shopping=20list=20=E2=80=94=20?= =?UTF-8?q?decouple=20from=20Bring!=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New shopping_list SQLite table (migration in migrateDB) - shoppingGetList/Add/Remove — delegates to Bring! or internal DB based on SHOPPING_MODE env var (default: internal) - isShoppingBringMode() guard: requires mode=bring + BRING credentials - bringQuickSyncProduct updated to support both modes - All bring_* JS calls replaced with shopping_* (bring_migrate_names kept) - New settings tab 'Lista spesa' (tab-bring) with: - Enable/disable shopping list toggle - Provider radio: internal vs Bring! - Bring! sub-section (shown only when mode=bring) - AI smart suggestions toggle - Forecast toggle - Auto-add threshold (qty slider) - Price estimation section - _applyShoppingSettingsUI, onShoppingEnabledChange, onShoppingModeChange - SHOPPING_* env vars documented in .env.example - cron_smart_shopping respects SHOPPING_MODE and SHOPPING_SMART_SUGGESTIONS - Translations: 12 new keys in all 5 languages (it/en/de/fr/es) - DB busy_timeout=5000ms + WAL pragma in getDB() (fixes #95) --- .env.example | 35 +++++++ api/cron_smart_shopping.php | 36 +++++++- api/database.php | 23 +++++ api/index.php | 176 ++++++++++++++++++++++++++++++------ assets/js/app.js | 96 +++++++++++++++----- data/backup_last_ts.json | 1 + index.html | 64 ++++++++++++- translations/de.json | 17 +++- translations/en.json | 17 +++- translations/es.json | 15 +++ translations/fr.json | 15 +++ translations/it.json | 17 +++- 12 files changed, 454 insertions(+), 58 deletions(-) create mode 100644 data/backup_last_ts.json 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/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 4fce5a5..4f23226 100644 --- a/api/index.php +++ b/api/index.php @@ -661,6 +661,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)) { @@ -836,6 +837,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; @@ -3031,6 +3045,12 @@ function getServerSettings(): void { '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'), ]); } @@ -3095,6 +3115,7 @@ function saveSettings(): void { 'gdrive_folder_id' => 'GDRIVE_FOLDER_ID', 'gdrive_client_id' => 'GDRIVE_CLIENT_ID', 'gdrive_client_secret' => 'GDRIVE_CLIENT_SECRET', + 'shopping_mode' => 'SHOPPING_MODE', ]; // Boolean keys $boolMap = [ @@ -3112,6 +3133,9 @@ function saveSettings(): void { '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 = [ @@ -3122,7 +3146,8 @@ function saveSettings(): void { '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', + 'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS', + 'shopping_auto_add_threshold' => 'SHOPPING_AUTO_ADD_THRESHOLD', ]; // Float keys $floatMap = [ @@ -6232,48 +6257,67 @@ function computeShoppingName(string $name, string $category = '', string $brand } /** - * Real-time Bring! sync for a single product. - * Called after inventory changes (use/update/add) to keep Bring! in sync immediately - * instead of waiting for the next cron cycle. + * 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); - $auth = bringAuth(); - if (!$auth) return; - $listUUID = $auth['bringListUUID']; - $stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prod = $stmt->fetch(); if (!$prod) return; $genericName = $prod['shopping_name'] ?: computeShoppingName($prod['name'], '', $prod['brand']); - $bringName = italianToBring($genericName); - $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); - if (!$listData || !isset($listData['purchase'])) return; + if (isShoppingBringMode()) { + // Delegate to Bring! + $auth = bringAuth(); + if (!$auth) return; + $listUUID = $auth['bringListUUID']; + $bringName = italianToBring($genericName); - $onBring = false; - foreach ($listData['purchase'] as $item) { - if (strcasecmp($item['name'] ?? '', $bringName) === 0) { $onBring = true; break; } - } + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if (!$listData || !isset($listData['purchase'])) return; - if ($totalQty <= 0 && !$onBring) { - // Out of stock — add to Bring! - $spec = $genericName !== $prod['name'] - ? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') . ' · 🛒 Esaurito' - : ($prod['brand'] ? $prod['brand'] . ' · ' : '') . '🛒 Esaurito'; - bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", - http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec])); - EverLog::info('bringQuickSync: added to Bring!', ['product_id' => $productId, 'name' => $bringName]); - } elseif ($totalQty > 0 && $onBring) { - // Back in stock — remove from Bring! - bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", - http_build_query(['uuid' => $listUUID, 'remove' => $bringName])); - EverLog::info('bringQuickSync: removed from Bring!', ['product_id' => $productId, 'name' => $bringName]); + $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]); + } } } @@ -8041,6 +8085,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/js/app.js b/assets/js/app.js index c2fa663..e574c5f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2903,6 +2903,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 @@ -2936,6 +2938,8 @@ async function loadSettingsUI() { 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)) { @@ -3269,6 +3273,35 @@ 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); +} + +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); +} + async function saveSettings() { const s = getSettings(); // Only update gemini_key if user actually typed something; preserve existing key otherwise @@ -3354,6 +3387,17 @@ async function saveSettings() { 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(); @@ -3412,6 +3456,11 @@ async function saveSettings() { 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) { @@ -3471,14 +3520,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 }; } } @@ -8246,7 +8296,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 @@ -8869,7 +8919,7 @@ function showLowStockBringPrompt(result, afterCallback) { try { const payload = { items: [{ name: shoppingName, specification: spec }] }; if (shoppingListUUID) payload.listUUID = shoppingListUUID; - const data = await api('bring_add', {}, 'POST', payload); + const data = await api('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'); } @@ -8959,7 +9009,7 @@ async function addLowStockToBring() { window._lowStockSpec = null; const payload = { items: [{ name: bringName, specification: spec }] }; if (shoppingListUUID) payload.listUUID = shoppingListUUID; - const data = await api('bring_add', {}, 'POST', payload); + const data = await api('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { // Pin as user-added so cleanup never auto-removes it const pinned = Object.assign({}, _pinnedBringCache || {}); @@ -9965,7 +10015,7 @@ function toggleShoppingTag(itemIdx, tag) { if (tag === 'urgente' && shoppingListUUID) { const isNowUrgent = existing.includes('urgente'); const newSpec = isNowUrgent ? t('shopping.urgency_spec_critical') : ''; - api('bring_add', {}, 'POST', { + api('shopping_add', {}, 'POST', { items: [{ name: item.name, specification: newSpec, update_spec: true }], listUUID: shoppingListUUID, }).catch(() => {}); @@ -9993,7 +10043,7 @@ async function confirmShoppingItemFound() { _spesaScanTarget = null; document.getElementById('shopping-scan-target-banner').style.display = 'none'; try { - const r = await api('bring_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID }); + const r = await api('shopping_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID }); if (r.success) { const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase()); if (idx >= 0) shoppingItems.splice(idx, 1); @@ -10113,7 +10163,7 @@ async function autoAddCriticalItems() { if (toAdd.length === 0) return; const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) })); try { - const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); + const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); if (result.success && result.added > 0) { // Track these as auto-added so cleanupObsoleteBringItems can safely remove them later _markAutoAddedBring(itemsToAdd.map(i => i.name)); @@ -10508,7 +10558,7 @@ async function cleanupObsoleteBringItems() { const removedNames = []; for (const item of toRemove) { try { - const r = await api('bring_remove', {}, 'POST', { + const r = await api('shopping_remove', {}, 'POST', { name: item.name, rawName: item.rawName || '', listUUID: shoppingListUUID @@ -10934,7 +10984,7 @@ async function addSmartToBring() { showLoading(true); try { - const result = await api('bring_add', {}, 'POST', { + const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID, }); @@ -10968,7 +11018,7 @@ async function loadShoppingCount() { const el = document.getElementById('stat-spesa'); if (el) el.classList.add('stat-loading'); try { - const data = await api('bring_list'); + const data = await api('shopping_list'); if (el) { if (data.success && data.purchase) { el.textContent = data.purchase.length; @@ -11097,7 +11147,7 @@ async function autoSyncUrgencySpecs() { } if (toUpdate.length === 0) return; try { - await api('bring_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID }); + await api('shopping_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID }); } catch (e) { /* ignore - sync is best-effort */ } } @@ -11114,7 +11164,7 @@ async function loadShoppingList() { loadShoppingList._bgCall = false; if (isBackgroundCall) { try { - const data = await api('bring_list'); + const data = await api('shopping_list'); if (data.success) { const newItems = data.purchase || []; const newNames = new Set(newItems.map(i => i.name.toLowerCase())); @@ -11164,7 +11214,7 @@ async function loadShoppingList() { } try { - const data = await api('bring_list'); + const data = await api('shopping_list'); statusEl.style.display = 'none'; if (!data.success) { @@ -11401,7 +11451,7 @@ async function removeBringItem(idx) { const item = shoppingItems[idx]; if (!item) return; try { - const data = await api('bring_remove', {}, 'POST', { + const data = await api('shopping_remove', {}, 'POST', { name: item.name, rawName: item.rawName || '', listUUID: shoppingListUUID @@ -11428,7 +11478,7 @@ async function generateSuggestions() { suggestionsEl.style.display = 'none'; try { - const data = await api('bring_suggest', {}, 'POST', {}); + const data = await api('shopping_suggest', {}, 'POST', {}); btn.disabled = false; btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`; @@ -11578,7 +11628,7 @@ async function addSelectedSuggestions() { return { name: s.name }; }); - const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID }); + const data = await api('shopping_add', {}, 'POST', { items, listUUID: shoppingListUUID }); if (data.success) { let msg = data.added === 1 ? t('shopping.bring_added_one') : t('shopping.bring_added_many').replace('{n}', data.added); @@ -14635,7 +14685,7 @@ async function loadScreensaverData() { const [statsRes, invRes, bringRes] = await Promise.all([ api('stats'), api('inventory_list'), - api('bring_list').catch(() => null) + api('shopping_list').catch(() => null) ]); _screensaverData = { stats: statsRes, @@ -15816,7 +15866,7 @@ async function _backgroundBringSync() { try { const [bringData, smartData] = await Promise.all([ - api('bring_list').catch(() => null), + api('shopping_list').catch(() => null), api('smart_shopping').catch(() => null), ]); @@ -15893,12 +15943,12 @@ async function _backgroundBringSync() { const allChanges = [...toAdd, ...toUpdate]; if (allChanges.length > 0) { - await api('bring_add', {}, 'POST', { items: allChanges, listUUID }); + await api('shopping_add', {}, 'POST', { items: allChanges, listUUID }); logOperation('bg_bring_sync', { added: toAdd.map(i=>i.name), updated: toUpdate.map(i=>i.name) }); } if (toRemove.length > 0) { - await api('bring_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID }); + await api('shopping_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID }); logOperation('bg_bring_remove', { removed: toRemove }); } diff --git a/data/backup_last_ts.json b/data/backup_last_ts.json new file mode 100644 index 0000000..fbe528e --- /dev/null +++ b/data/backup_last_ts.json @@ -0,0 +1 @@ +{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444} \ No newline at end of file diff --git a/index.html b/index.html index b9cbd9c..db97ea7 100644 --- a/index.html +++ b/index.html @@ -833,7 +833,7 @@
- + @@ -955,9 +955,36 @@
+
-

🛒 Bring! Shopping List

-

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

+

🛒 Lista della spesa

+

Configura la lista della spesa integrata o collega Bring!.

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

Assistenza AI

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

rimasto in magazzino (0 = solo quando esaurito)

+
+

💰 Stima Prezzi (AI)

diff --git a/translations/de.json b/translations/de.json index 068928f..6746df1 100644 --- a/translations/de.json +++ b/translations/de.json @@ -849,7 +849,22 @@ "currency_title": "Währung", "currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird." }, - "tab_general": "Allgemein" + "tab_general": "Allgemein", + "shopping": { + "tab": "Einkaufsliste", + "title": "Einkaufsliste", + "hint": "Konfiguriere die integrierte Einkaufsliste oder verbinde Bring!.", + "enable_label": "Einkaufsliste aktivieren", + "mode_label": "Anbieter", + "mode_internal": "Intern (ohne Bring!)", + "mode_bring": "Bring! (externe App)", + "bring_section_title": "Bring!-Konfiguration", + "ai_section_title": "KI-Unterstützung", + "smart_suggestions_label": "KI-Vorschläge", + "forecast_label": "Prognose für bald leere Produkte", + "auto_add_label": "Automatisch hinzufügen wenn", + "auto_add_suffix": "im Lager verbleibend (0 = nur wenn leer)" + } }, "expiry": { "today": "HEUTE", diff --git a/translations/en.json b/translations/en.json index 61d7268..92826c4 100644 --- a/translations/en.json +++ b/translations/en.json @@ -849,7 +849,22 @@ "currency_title": "Currency", "currency_hint": "The currency used for all costs and prices in the app." }, - "tab_general": "General" + "tab_general": "General", + "shopping": { + "tab": "Shopping list", + "title": "Shopping list", + "hint": "Configure the built-in shopping list or connect Bring!.", + "enable_label": "Enable shopping list", + "mode_label": "Provider", + "mode_internal": "Built-in (no Bring!)", + "mode_bring": "Bring! (external app)", + "bring_section_title": "Bring! configuration", + "ai_section_title": "AI assistance", + "smart_suggestions_label": "AI suggestions", + "forecast_label": "Forecast low-stock products", + "auto_add_label": "Auto-add to list when", + "auto_add_suffix": "remaining in stock (0 = only when empty)" + } }, "expiry": { "today": "TODAY", diff --git a/translations/es.json b/translations/es.json index 5b126d5..7d0bf68 100644 --- a/translations/es.json +++ b/translations/es.json @@ -806,6 +806,21 @@ "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": "
  • Ve a console.cloud.google.com y selecciona tu proyecto
  • Habilita la API de Google Drive: API y servicios → Habilitar API → Google Drive API
  • Ve a API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth
  • Tipo de aplicación: Aplicación web; agrega la URL mostrada abajo como URI de redirección autorizado
  • Copia el Client ID y el Client Secret en los campos de arriba y guarda
  • Haz clic en Autorizar con Google: inicia sesión en tu cuenta de Google y concede acceso
  • La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas
  • " + }, + "shopping": { + "tab": "Lista de la compra", + "title": "Lista de la compra", + "hint": "Configura la lista de la compra integrada o conecta Bring!.", + "enable_label": "Activar lista de la compra", + "mode_label": "Proveedor", + "mode_internal": "Integrado (sin Bring!)", + "mode_bring": "Bring! (app externa)", + "bring_section_title": "Configuración de Bring!", + "ai_section_title": "Asistencia IA", + "smart_suggestions_label": "Sugerencias IA", + "forecast_label": "Previsión de productos por agotar", + "auto_add_label": "Añadir automáticamente cuando", + "auto_add_suffix": "restante en stock (0 = solo cuando se agota)" } }, "expiry": { diff --git a/translations/fr.json b/translations/fr.json index c0047be..a7f549d 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -806,6 +806,21 @@ "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": "
  • Allez sur console.cloud.google.com et sélectionnez votre projet
  • Activez l’API Google Drive : API et services → Activer les API → Google Drive API
  • Allez dans API et services → Identifiants → Créer des identifiants → ID client OAuth
  • Type d’application : Application Web ; ajoutez l’URL affichée ci-dessous comme URI de redirection autorisé
  • Copiez le Client ID et le Client Secret dans les champs ci-dessus et enregistrez
  • Cliquez sur Autoriser avec Google : connectez-vous et accordez l’accès
  • La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes
  • " + }, + "shopping": { + "tab": "Liste de courses", + "title": "Liste de courses", + "hint": "Configurez la liste de courses intégrée ou connectez Bring!.", + "enable_label": "Activer la liste de courses", + "mode_label": "Fournisseur", + "mode_internal": "Intégré (sans Bring!)", + "mode_bring": "Bring! (application externe)", + "bring_section_title": "Configuration Bring!", + "ai_section_title": "Assistance IA", + "smart_suggestions_label": "Suggestions IA", + "forecast_label": "Prévision des produits bientôt épuisés", + "auto_add_label": "Ajouter automatiquement quand", + "auto_add_suffix": "restant en stock (0 = seulement quand épuisé)" } }, "expiry": { diff --git a/translations/it.json b/translations/it.json index 0c364d2..fb9a996 100644 --- a/translations/it.json +++ b/translations/it.json @@ -849,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",