> /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); } // ── Home Assistant: expiry alerts ───────────────────────────────────────────── // Fire one HA webhook per expiring item (once per day guard via a simple flag file). if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') { try { $haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json'; if (!file_exists($haFlagFile)) { $expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3')); $expiringItems = $db->query( "SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days') ORDER BY i.expiry_date ASC LIMIT 20" )->fetchAll(PDO::FETCH_ASSOC); $expiredItems = $db->query( "SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL AND i.expiry_date < date('now') ORDER BY i.expiry_date ASC LIMIT 10" )->fetchAll(PDO::FETCH_ASSOC); if (!empty($expiringItems)) { $names = implode(', ', array_column($expiringItems, 'name')); _fireHaWebhook('expiry_alert', [ 'count' => count($expiringItems), 'items' => $expiringItems, 'type' => 'expiring_soon', 'days' => $expiryDays, 'summary' => $names, ]); // Also send HA notification if service configured if (env('HA_NOTIFY_SERVICE', '') !== '') { $msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names; _sendHaNotify($msg, ['expiring_items' => $expiringItems]); } echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n"; } if (!empty($expiredItems)) { $expNames = implode(', ', array_column($expiredItems, 'name')); _fireHaWebhook('expiry_alert', [ 'count' => count($expiredItems), 'items' => $expiredItems, 'type' => 'expired', 'summary' => $expNames, ]); echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n"; } // Mark as done for today file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])])); // Clean up old flag files (keep last 7 days) foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) { $flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag); if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag); } } } catch (Throwable $haE) { echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n"; } } // ── Avahi/mDNS discovery registration ───────────────────────────────────────── // If avahi-daemon is running on this host, register the _evershelf._tcp service // so that Home Assistant can auto-discover this instance via Zeroconf. if (function_exists('shell_exec')) { try { $avahiService = '/etc/avahi/services/evershelf.xml'; // Only create/update if avahi-daemon is installed and the file doesn't exist yet if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) { $template = __DIR__ . '/../docker/avahi-evershelf.xml'; if (file_exists($template)) { $xml = file_get_contents($template); @file_put_contents($avahiService, $xml); echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n"; } } } catch (Throwable $avahiE) { // Non-fatal: avahi not available } }