chore: auto-merge develop → main

Triggered by: fa0442e feat: native shopping list — decouple from Bring! (#105)
This commit is contained in:
github-actions[bot]
2026-05-19 16:07:33 +00:00
12 changed files with 454 additions and 58 deletions
+35
View File
@@ -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.
+35 -1
View File
@@ -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";
+23
View File
@@ -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))");
}
}
/**
+148 -28
View File
@@ -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 {
+73 -23
View File
@@ -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 });
}
+1
View File
@@ -0,0 +1 @@
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
+61 -3
View File
@@ -833,7 +833,7 @@
<div class="settings-tabs">
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
@@ -955,9 +955,36 @@
</div>
<!-- Bring! Tab -->
<div class="settings-panel" id="tab-bring">
<!-- Shopping enable + provider -->
<div class="settings-card">
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" id="shopping-mode-group">
<label data-i18n="settings.shopping.mode_label">Provider</label>
<div class="radio-group" style="margin-top:6px">
<label class="radio-option">
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
</label>
<label class="radio-option" style="margin-left:16px">
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
</label>
</div>
</div>
</div>
<!-- Bring! sub-section (shown only when mode = bring) -->
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
<div class="form-group">
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
@@ -968,6 +995,37 @@
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
</div>
<!-- Smart suggestions + forecast -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-smart-suggestions">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-forecast">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" style="margin-top:8px">
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
<div class="qty-control" style="margin-top:6px">
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)"></button>
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
</div>
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
</div>
</div>
<!-- Price Estimation Settings -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
+16 -1
View File
@@ -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",
+16 -1
View File
@@ -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",
+15
View File
@@ -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": "<li>Ve a <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> y selecciona tu proyecto</li><li>Habilita la <strong>API de Google Drive</strong>: <em>API y servicios → Habilitar API → Google Drive API</em></li><li>Ve a <em>API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth</em></li><li>Tipo de aplicación: <strong>Aplicación web</strong>; agrega la URL mostrada abajo como <em>URI de redirección autorizado</em></li><li>Copia el <strong>Client ID</strong> y el <strong>Client Secret</strong> en los campos de arriba y guarda</li><li>Haz clic en <strong>Autorizar con Google</strong>: inicia sesión en tu cuenta de Google y concede acceso</li><li>La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas</li>"
},
"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": {
+15
View File
@@ -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": "<li>Allez sur <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> et sélectionnez votre projet</li><li>Activez l<strong>API Google Drive</strong> : <em>API et services → Activer les API → Google Drive API</em></li><li>Allez dans <em>API et services → Identifiants → Créer des identifiants → ID client OAuth</em></li><li>Type dapplication : <strong>Application Web</strong> ; ajoutez lURL affichée ci-dessous comme <em>URI de redirection autorisé</em></li><li>Copiez le <strong>Client ID</strong> et le <strong>Client Secret</strong> dans les champs ci-dessus et enregistrez</li><li>Cliquez sur <strong>Autoriser avec Google</strong> : connectez-vous et accordez laccès</li><li>La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes</li>"
},
"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": {
+16 -1
View File
@@ -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",