Files
EverShelf/api/cron_smart_shopping.php
T
dadaloop82 fa0442e2f6 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)
2026-05-19 16:05:49 +00:00

136 lines
6.3 KiB
PHP

<?php
/**
* Cron: pre-compute smart shopping list and save to cache.
* Install with: crontab -e
* *\/5 * * * * php /var/www/html/evershelf/api/cron_smart_shopping.php >> /var/www/html/evershelf/data/cron.log 2>&1
*/
// Only allow CLI execution — block HTTP access
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Forbidden');
}
// Define CRON_MODE before loading index.php so the router is skipped
define('CRON_MODE', true);
// Load all API functions without running the HTTP router
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
try {
$db = getDB();
// Capture the JSON output of smartShopping()
ob_start();
smartShopping($db);
$json = ob_get_clean();
$decoded = json_decode($json, true);
if (!$decoded || !isset($decoded['success'])) {
throw new RuntimeException('Invalid JSON from smartShopping(): ' . substr($json, 0, 200));
}
$decoded['cached_at'] = date('c');
$decoded['cached_ts'] = time();
if (file_put_contents(CACHE_FILE, json_encode($decoded, JSON_UNESCAPED_UNICODE)) === false) {
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
}
$itemCount = count($decoded['items'] ?? []);
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
// ── Bring! server-side cleanup ────────────────────────────────────────
// After computing smart shopping, automatically remove stale Bring! items
// and add/update critical ones. This runs fully server-side every cron cycle.
try {
$cleanupResult = bringCleanupObsolete($db);
if (isset($cleanupResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup skipped: ' . $cleanupResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup — removed: ' . ($cleanupResult['removed'] ?? 0)
. '/' . ($cleanupResult['candidates'] ?? 0) . ' candidates'
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
}
$addResult = bringAutoAddCritical($db);
if (isset($addResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
}
// ── Shelf life pre-warming ────────────────────────────────────────────
// Pre-warm the opened shelf life cache for opened items not yet cached.
// Capped at 5 items per cron cycle to avoid Gemini rate limits.
try {
$prewarmResult = prewarmShelfLifeCache($db, 5);
if ($prewarmResult['warmed'] > 0) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm — warmed: ' . $prewarmResult['warmed']
. ', skipped: ' . $prewarmResult['skipped'] . "\n";
}
} catch (Throwable $pe) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
}
// ── DB cleanup (retention policy) ────────────────────────────────────
// Delete old recipes and transactions based on .env retention settings.
try {
ob_start();
dbCleanup($db);
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','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";
// Report to GitHub Issues (uses the same _phpErrorReport from index.php)
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
exit(1);
}