feat: native shopping list — decouple from Bring! (#105)
- 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)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user