Compare commits
6 Commits
v1.7.39
...
kiosk-1.7.17
| Author | SHA1 | Date | |
|---|---|---|---|
| 338bd7ff66 | |||
| c7532f90cd | |||
| 5831e3bcea | |||
| ec1aae2a25 | |||
| 4f9f44e230 | |||
| 9be8fb5cf3 |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
@@ -52,3 +52,4 @@ data/food_facts_cache.json
|
|||||||
data/category_ai_cache.json
|
data/category_ai_cache.json
|
||||||
assets/img/logo/*_backup.*
|
assets/img/logo/*_backup.*
|
||||||
logs/*.log
|
logs/*.log
|
||||||
|
assets/vendor/transformers/Xenova/
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ RewriteEngine On
|
|||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Force HTTPS
|
# Force HTTPS (skip when terminated TLS is forwarded — Traefik, Caddy, NPM, …)
|
||||||
RewriteCond %{HTTPS} !=on
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
|
||||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
# API routing
|
# API routing
|
||||||
|
|||||||
@@ -11,6 +11,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||||
|
|
||||||
|
## [1.7.41] - 2026-06-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Docker/Traefik “Impossibile contattare il server”** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production.
|
||||||
|
- **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually.
|
||||||
|
- **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs.
|
||||||
|
|
||||||
|
## [1.7.40] - 2026-06-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”.
|
||||||
|
- **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list.
|
||||||
|
- **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen.
|
||||||
|
- **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue).
|
||||||
|
- **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token.
|
||||||
|
- **Ops CLI scripts** — `audit-finished-shopping.php`, `backfill-finished-shopping.php`, `sync-shopping-bring.php`, `install-transformers-model.sh` (offline Xenova classifier bootstrap).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention.
|
||||||
|
- **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode.
|
||||||
|
- **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron).
|
||||||
|
- **Spesa scan crash** — `currentPage` → `_currentPageId` in `_applySpesaScanUI`.
|
||||||
|
- **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes.
|
||||||
|
- **Smart shopping purchased block** — Server-side blocklist + spesa mode sync prevents cron from re-adding bought items.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops.
|
||||||
|
|
||||||
## [1.7.39] - 2026-06-06
|
## [1.7.39] - 2026-06-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-1
@@ -33,7 +33,9 @@ RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.
|
|||||||
RUN echo '<Directory /var/www/html>\n\
|
RUN echo '<Directory /var/www/html>\n\
|
||||||
AllowOverride All\n\
|
AllowOverride All\n\
|
||||||
Require all granted\n\
|
Require all granted\n\
|
||||||
</Directory>' > /etc/apache2/conf-available/evershelf.conf \
|
</Directory>\n\
|
||||||
|
# Traefik / reverse-proxy: treat forwarded HTTPS as on so .htaccess does not redirect-loop\n\
|
||||||
|
SetEnvIf X-Forwarded-Proto "https" HTTPS=on' > /etc/apache2/conf-available/evershelf.conf \
|
||||||
&& a2enconf evershelf
|
&& a2enconf evershelf
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
[](https://www.sqlite.org/)
|
[](https://www.sqlite.org/)
|
||||||
[](Dockerfile)
|
[](Dockerfile)
|
||||||
[](translations/)
|
[](translations/)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
/**
|
/**
|
||||||
* EverShelf API bootstrap — shared by HTTP router and cron.
|
* EverShelf API bootstrap — shared by HTTP router and cron.
|
||||||
*/
|
*/
|
||||||
|
// Never emit HTML notices before JSON API responses (breaks fetch().json() in the PWA).
|
||||||
|
if (!defined('CRON_MODE') && (getenv('DISPLAY_ERRORS') ?: '') !== '1') {
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('html_errors', '0');
|
||||||
|
}
|
||||||
require_once __DIR__ . '/lib/env.php';
|
require_once __DIR__ . '/lib/env.php';
|
||||||
require_once __DIR__ . '/lib/constants.php';
|
require_once __DIR__ . '/lib/constants.php';
|
||||||
require_once __DIR__ . '/lib/github.php';
|
require_once __DIR__ . '/lib/github.php';
|
||||||
|
|||||||
+40
-1
@@ -38,8 +38,24 @@ function _ensureDataDir(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensure the SQLite DB and WAL sidecar files are writable (Docker volume first-boot). */
|
||||||
|
function _ensureDbWritable(): void {
|
||||||
|
if (!file_exists(DB_PATH)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!is_writable(DB_PATH)) {
|
||||||
|
@chmod(DB_PATH, 0664);
|
||||||
|
}
|
||||||
|
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $sidecar) {
|
||||||
|
if (file_exists($sidecar) && !is_writable($sidecar)) {
|
||||||
|
@chmod($sidecar, 0664);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDB(): PDO {
|
function getDB(): PDO {
|
||||||
_ensureDataDir();
|
_ensureDataDir();
|
||||||
|
_ensureDbWritable();
|
||||||
// logger.php is required by index.php before getDB() is called.
|
// logger.php is required by index.php before getDB() is called.
|
||||||
// In cron context it may not be loaded yet — guard with class_exists.
|
// In cron context it may not be loaded yet — guard with class_exists.
|
||||||
$useLogging = class_exists('LoggingPDO', false);
|
$useLogging = class_exists('LoggingPDO', false);
|
||||||
@@ -53,7 +69,7 @@ function getDB(): PDO {
|
|||||||
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
$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.
|
// For SQLite, we use PRAGMA busy_timeout.
|
||||||
$db->exec('PRAGMA journal_mode = WAL;');
|
$db->exec('PRAGMA journal_mode = WAL;');
|
||||||
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
|
$db->exec('PRAGMA busy_timeout = 10000;'); // 10 s — cron + PWA writes can contend under WAL
|
||||||
|
|
||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec("PRAGMA journal_mode=WAL");
|
$db->exec("PRAGMA journal_mode=WAL");
|
||||||
@@ -72,6 +88,29 @@ function getDB(): PDO {
|
|||||||
return $db;
|
return $db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a DB write when SQLite returns "database is locked" (concurrent cron + API).
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param callable(): T $fn
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
function dbWithRetry(callable $fn, int $maxAttempts = 4): mixed {
|
||||||
|
$attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return $fn();
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
$attempt++;
|
||||||
|
$locked = str_contains($e->getMessage(), 'database is locked');
|
||||||
|
if (!$locked || $attempt >= $maxAttempts) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
usleep(150000 * $attempt); // 150 ms, 300 ms, 450 ms …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initializeDB(PDO $db): void {
|
function initializeDB(PDO $db): void {
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
|||||||
+882
-281
File diff suppressed because it is too large
Load Diff
@@ -335,6 +335,7 @@ class LoggingPDOStatement {
|
|||||||
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
class LoggingPDO extends \PDO {
|
class LoggingPDO extends \PDO {
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
||||||
$stmt = parent::prepare($query, $options);
|
$stmt = parent::prepare($query, $options);
|
||||||
if ($stmt === false) {
|
if ($stmt === false) {
|
||||||
|
|||||||
@@ -1847,6 +1847,41 @@ body.server-offline .bottom-nav {
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qty-control-with-unit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-control-with-unit .qty-control {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-unit-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-unit-badge.qty-unit-muted {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--primary);
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== USE OPTIONS ===== */
|
/* ===== USE OPTIONS ===== */
|
||||||
.use-options {
|
.use-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+341
-85
@@ -497,7 +497,8 @@ function _scaleUpdateLiveBox(msg) {
|
|||||||
}
|
}
|
||||||
if (valEl) valEl.textContent = displayVal + stIcon;
|
if (valEl) valEl.textContent = displayVal + stIcon;
|
||||||
if (lblEl) {
|
if (lblEl) {
|
||||||
lblEl.textContent = '';
|
const targetLbl = getUnitDisplayLabel(getActiveUseUnitLabel());
|
||||||
|
lblEl.textContent = targetLbl ? ((t('qty.enter_in') || 'Inserimento in') + ' ' + targetLbl) : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -985,6 +986,7 @@ function _scaleShowReadingModal(targetInputId, unit) {
|
|||||||
<div style="padding:16px;text-align:center">
|
<div style="padding:16px;text-align:center">
|
||||||
<p style="margin-bottom:16px">${t('scale.place_on_scale')}</p>
|
<p style="margin-bottom:16px">${t('scale.place_on_scale')}</p>
|
||||||
<div id="scale-reading-live" class="scale-reading-live">— — —</div>
|
<div id="scale-reading-live" class="scale-reading-live">— — —</div>
|
||||||
|
<p style="margin-top:8px;font-weight:700;color:var(--primary)">${escapeHtml((t('qty.enter_in') || 'Inserimento in') + ' ' + getUnitDisplayLabel(unit))}</p>
|
||||||
<p class="settings-hint" style="margin-top:12px">${t('scale.waiting_stable')}</p>
|
<p class="settings-hint" style="margin-top:12px">${t('scale.waiting_stable')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -6357,6 +6359,76 @@ function formatQuantity(qty, unit, defaultQty, packageUnit) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable unit label for quantity inputs (pz, g, ml, conf…). */
|
||||||
|
function getUnitDisplayLabel(unit) {
|
||||||
|
const u = (unit || 'pz').toLowerCase();
|
||||||
|
const map = {
|
||||||
|
pz: t('units.pz') || 'pz',
|
||||||
|
g: 'g',
|
||||||
|
ml: 'ml',
|
||||||
|
conf: t('units.conf') || 'conf',
|
||||||
|
kg: 'kg',
|
||||||
|
l: 'l',
|
||||||
|
};
|
||||||
|
return map[u] || u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap qty input with a visible unit badge if missing. */
|
||||||
|
function ensureQtyUnitBadge(inputId) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!input) return null;
|
||||||
|
let badge = document.getElementById(inputId + '-unit');
|
||||||
|
if (badge) return badge;
|
||||||
|
const control = input.closest('.qty-control');
|
||||||
|
if (!control) return null;
|
||||||
|
let wrap = control.closest('.qty-control-with-unit');
|
||||||
|
if (!wrap) {
|
||||||
|
wrap = document.createElement('div');
|
||||||
|
wrap.className = 'qty-control-with-unit';
|
||||||
|
control.parentNode.insertBefore(wrap, control);
|
||||||
|
wrap.appendChild(control);
|
||||||
|
}
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.className = 'qty-unit-badge';
|
||||||
|
badge.id = inputId + '-unit';
|
||||||
|
badge.setAttribute('aria-live', 'polite');
|
||||||
|
badge.textContent = '—';
|
||||||
|
wrap.appendChild(badge);
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQtyInputUnitLabel(inputId, unit, muted = false) {
|
||||||
|
const badge = ensureQtyUnitBadge(inputId) || document.getElementById(inputId + '-unit');
|
||||||
|
if (!badge) return;
|
||||||
|
badge.textContent = getUnitDisplayLabel(unit);
|
||||||
|
badge.classList.toggle('qty-unit-muted', !!muted);
|
||||||
|
badge.title = (t('qty.unit_for_input') || 'Unità di misura') + ': ' + badge.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveUseUnitLabel() {
|
||||||
|
if (_useConfMode) {
|
||||||
|
if (_useConfMode._activeUnit === 'conf') return 'conf';
|
||||||
|
return _useConfMode.subLabel || _useConfMode.packageUnit || 'g';
|
||||||
|
}
|
||||||
|
return _useNormalUnit || 'pz';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRecipeUseUnitLabel() {
|
||||||
|
if (_recipeUseConfMode) {
|
||||||
|
if (_recipeUseConfMode._activeUnit === 'conf') return 'conf';
|
||||||
|
return _recipeUseConfMode.subLabel || _recipeUseConfMode.packageUnit || 'g';
|
||||||
|
}
|
||||||
|
return _recipeUseNormalUnit || 'pz';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUseQtyUnitBadge() {
|
||||||
|
setQtyInputUnitLabel('use-quantity', getActiveUseUnitLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRecipeUseQtyUnitBadge() {
|
||||||
|
setQtyInputUnitLabel('ruse-quantity', getActiveRecipeUseUnitLabel());
|
||||||
|
}
|
||||||
|
|
||||||
// Structured quantity display for inventory cards.
|
// Structured quantity display for inventory cards.
|
||||||
// Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' }
|
// Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' }
|
||||||
function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
||||||
@@ -6387,7 +6459,8 @@ function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
|||||||
|
|
||||||
let packageDetail = '';
|
let packageDetail = '';
|
||||||
let fraction = '';
|
let fraction = '';
|
||||||
if (unit !== 'conf' && defaultQty && defaultQty > 1) {
|
// pz = piece count only; default_quantity may hold legacy avg weight — ignore for display
|
||||||
|
if (unit !== 'conf' && unit !== 'pz' && defaultQty && defaultQty > 1) {
|
||||||
const d = parseFloat(defaultQty);
|
const d = parseFloat(defaultQty);
|
||||||
const ratio = n / d;
|
const ratio = n / d;
|
||||||
const remainder = ratio - Math.floor(ratio);
|
const remainder = ratio - Math.floor(ratio);
|
||||||
@@ -6894,10 +6967,13 @@ function editInventoryItem(id) {
|
|||||||
<form class="form" onsubmit="submitEditInventory(event, ${id}, ${item.product_id})">
|
<form class="form" onsubmit="submitEditInventory(event, ${id}, ${item.product_id})">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>📦 ${t('inventory.label_quantity').replace('📦 ', '')}</label>
|
<label>📦 ${t('inventory.label_quantity').replace('📦 ', '')}</label>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)">−</button>
|
||||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
|
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="edit-qty-unit" aria-live="polite">${escapeHtml(getUnitDisplayLabel(item.unit || 'pz'))}</span>
|
||||||
</div>
|
</div>
|
||||||
${scaleEditReady ? `
|
${scaleEditReady ? `
|
||||||
<div id="edit-scale-section" style="display:none;text-align:center;padding:10px;background:linear-gradient(135deg,#f3e8ff,#ede9fe);border-radius:10px;margin-top:8px">
|
<div id="edit-scale-section" style="display:none;text-align:center;padding:10px;background:linear-gradient(135deg,#f3e8ff,#ede9fe);border-radius:10px;margin-top:8px">
|
||||||
@@ -6951,10 +7027,12 @@ function editInventoryItem(id) {
|
|||||||
`;
|
`;
|
||||||
document.getElementById('modal-overlay').style.display = 'flex';
|
document.getElementById('modal-overlay').style.display = 'flex';
|
||||||
_initExpiryManualTracking('edit-expiry', item);
|
_initExpiryManualTracking('edit-expiry', item);
|
||||||
|
setQtyInputUnitLabel('edit-qty', item.unit || 'pz');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditUnitChange() {
|
function onEditUnitChange() {
|
||||||
const unit = document.getElementById('edit-unit').value;
|
const unit = document.getElementById('edit-unit').value;
|
||||||
|
setQtyInputUnitLabel('edit-qty', unit);
|
||||||
const confGroup = document.getElementById('edit-conf-size-group');
|
const confGroup = document.getElementById('edit-conf-size-group');
|
||||||
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
|
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
|
||||||
if (unit === 'conf') {
|
if (unit === 'conf') {
|
||||||
@@ -8847,10 +8925,13 @@ function editActionInventoryItem(inventoryId) {
|
|||||||
<form class="form" onsubmit="submitActionEditInventory(event, ${inventoryId}, ${item.product_id})">
|
<form class="form" onsubmit="submitActionEditInventory(event, ${inventoryId}, ${item.product_id})">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>${t('add.quantity_label')}</label>
|
<label>${t('add.quantity_label')}</label>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', -1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="action-edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', -1)">−</button>
|
||||||
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', 1)">+</button>
|
<input type="number" id="action-edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', 1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="action-edit-qty-unit" aria-live="polite">${escapeHtml(getUnitDisplayLabel(item.unit || 'pz'))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -8899,10 +8980,12 @@ function editActionInventoryItem(inventoryId) {
|
|||||||
`;
|
`;
|
||||||
document.getElementById('modal-overlay').style.display = 'flex';
|
document.getElementById('modal-overlay').style.display = 'flex';
|
||||||
_initExpiryManualTracking('action-edit-expiry', item);
|
_initExpiryManualTracking('action-edit-expiry', item);
|
||||||
|
setQtyInputUnitLabel('action-edit-qty', item.unit || 'pz');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onActionEditUnitChange() {
|
function onActionEditUnitChange() {
|
||||||
const unit = document.getElementById('action-edit-unit').value;
|
const unit = document.getElementById('action-edit-unit').value;
|
||||||
|
setQtyInputUnitLabel('action-edit-qty', unit);
|
||||||
const confGroup = document.getElementById('action-edit-conf-group');
|
const confGroup = document.getElementById('action-edit-conf-group');
|
||||||
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
|
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
@@ -8997,10 +9080,13 @@ function showThrowForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>${t('use.throw_qty_label')}</label>
|
<label>${t('use.throw_qty_label')}</label>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', -1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="throw-quantity" value="1" min="0.1" step="any" class="qty-input">
|
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', -1)">−</button>
|
||||||
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', 1)">+</button>
|
<input type="number" id="throw-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', 1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="throw-quantity-unit" aria-live="polite">${escapeHtml(getUnitDisplayLabel(items[0]?.unit || 'pz'))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-large btn-warning full-width" onclick="throwPartial()">
|
<button class="btn btn-large btn-warning full-width" onclick="throwPartial()">
|
||||||
@@ -9541,6 +9627,7 @@ function onAddUnitChange() {
|
|||||||
|
|
||||||
// Show/hide scale read button based on new unit
|
// Show/hide scale read button based on new unit
|
||||||
updateScaleReadButtons();
|
updateScaleReadButtons();
|
||||||
|
setQtyInputUnitLabel('add-quantity', unit, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAddQtyStep() {
|
function updateAddQtyStep() {
|
||||||
@@ -9552,6 +9639,7 @@ function updateAddQtyStep() {
|
|||||||
} else {
|
} else {
|
||||||
qtyInput.min = '1';
|
qtyInput.min = '1';
|
||||||
}
|
}
|
||||||
|
setQtyInputUnitLabel('add-quantity', unit, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAddQtyManuallySet() {
|
function markAddQtyManuallySet() {
|
||||||
@@ -9838,26 +9926,28 @@ async function submitAdd(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
showToast(t('add.product_added').replace('{name}', currentProduct.name).replace('{qty}', qtyInfo), 'success');
|
showToast(t('add.product_added').replace('{name}', currentProduct.name).replace('{qty}', qtyInfo), 'success');
|
||||||
if (result.removed_from_bring) {
|
if (!(await spesaModeAfterAdd(result))) {
|
||||||
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
if (result.removed_from_bring) {
|
||||||
} else if (shoppingItems.length > 0 && shoppingListUUID) {
|
_applyShoppingListRemovals(result.removed_names || []);
|
||||||
// PHP matching may have missed the item (custom name / no catalog match) —
|
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||||
// try a client-side fuzzy remove using the already-loaded shoppingItems
|
} else if (shoppingListUUID) {
|
||||||
const match = _findSimilarItem(currentProduct.name, shoppingItems);
|
const generic = currentProduct.shopping_name || currentProduct.name;
|
||||||
if (match) {
|
const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(currentProduct.name, shoppingItems);
|
||||||
api('shopping_remove', {}, 'POST', {
|
api('shopping_remove', {}, 'POST', {
|
||||||
name: match.name,
|
name: match?.name || generic,
|
||||||
rawName: match.rawName || '',
|
rawName: match?.rawName || '',
|
||||||
listUUID: shoppingListUUID
|
listUUID: shoppingListUUID,
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
if (r && r.success) {
|
if (r?.success) {
|
||||||
shoppingItems = shoppingItems.filter(i => i !== match);
|
_applyShoppingListRemovals([match?.name || generic, match?.rawName].filter(Boolean));
|
||||||
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
showPage('dashboard');
|
||||||
|
} else if (result.removed_from_bring) {
|
||||||
|
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||||
}
|
}
|
||||||
if (!(await spesaModeAfterAdd())) showPage('dashboard');
|
|
||||||
|
|
||||||
// Submit extra batches (different expiry dates) in the background, silently
|
// Submit extra batches (different expiry dates) in the background, silently
|
||||||
if ((window._addExtraBatches || []).length > 0) {
|
if ((window._addExtraBatches || []).length > 0) {
|
||||||
@@ -9911,6 +10001,7 @@ function showUseForm() {
|
|||||||
loadUseInventoryInfo();
|
loadUseInventoryInfo();
|
||||||
showPage('use');
|
showPage('use');
|
||||||
updateScaleReadButtons();
|
updateScaleReadButtons();
|
||||||
|
syncUseQtyUnitBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsePreview() {
|
function renderUsePreview() {
|
||||||
@@ -10179,6 +10270,7 @@ async function loadUseInventoryInfo() {
|
|||||||
|
|
||||||
// Trigger a live-box refresh with the latest reading if on scale
|
// Trigger a live-box refresh with the latest reading if on scale
|
||||||
if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight);
|
if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight);
|
||||||
|
syncUseQtyUnitBadge();
|
||||||
} else {
|
} else {
|
||||||
// --- NORMAL MODE ---
|
// --- NORMAL MODE ---
|
||||||
_useConfMode = null;
|
_useConfMode = null;
|
||||||
@@ -10216,6 +10308,7 @@ async function loadUseInventoryInfo() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
document.querySelector('#page-use .use-partial').appendChild(fracDiv);
|
document.querySelector('#page-use .use-partial').appendChild(fracDiv);
|
||||||
}
|
}
|
||||||
|
syncUseQtyUnitBadge();
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -10254,6 +10347,7 @@ function switchUseUnit(mode) {
|
|||||||
hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) });
|
hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) });
|
||||||
if (confFracBtns) confFracBtns.style.display = '';
|
if (confFracBtns) confFracBtns.style.display = '';
|
||||||
}
|
}
|
||||||
|
syncUseQtyUnitBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConfFraction(f) {
|
function setConfFraction(f) {
|
||||||
@@ -11857,6 +11951,15 @@ function _isBringPurchased(name, urgency) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop smart-shopping rows the user already bought (blocklist from spesa). */
|
||||||
|
function _filterPurchasedSmartItems(items) {
|
||||||
|
if (!Array.isArray(items) || !items.length) return items || [];
|
||||||
|
return items.filter(item => {
|
||||||
|
const names = [item.shopping_name, item.name].filter(Boolean);
|
||||||
|
return !names.some(n => _isBringPurchased(n, item.urgency));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function autoAddCriticalItems() {
|
async function autoAddCriticalItems() {
|
||||||
// Time-based guard: run at most once every 5 minutes
|
// Time-based guard: run at most once every 5 minutes
|
||||||
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
|
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
|
||||||
@@ -12605,7 +12708,7 @@ async function loadSmartShopping() {
|
|||||||
const prevCriticalNames = new Set(
|
const prevCriticalNames = new Set(
|
||||||
smartShoppingItems.filter(i => i.urgency === 'critical').map(i => i.name)
|
smartShoppingItems.filter(i => i.urgency === 'critical').map(i => i.name)
|
||||||
);
|
);
|
||||||
smartShoppingItems = data.items;
|
smartShoppingItems = _filterPurchasedSmartItems(data.items);
|
||||||
_smartShoppingLastFetch = Date.now();
|
_smartShoppingLastFetch = Date.now();
|
||||||
// NOTE: do NOT clear _cachedPrices here — qty validation (_qty/_unit metadata)
|
// NOTE: do NOT clear _cachedPrices here — qty validation (_qty/_unit metadata)
|
||||||
// handles stale entries automatically item by item.
|
// handles stale entries automatically item by item.
|
||||||
@@ -13197,6 +13300,7 @@ async function renderShoppingItems() {
|
|||||||
return { item, idx, smartData, urgency, sec };
|
return { item, idx, smartData, urgency, sec };
|
||||||
});
|
});
|
||||||
const enriched = _dedupeShoppingByGeneric(enrichedRaw);
|
const enriched = _dedupeShoppingByGeneric(enrichedRaw);
|
||||||
|
const pantryRows = enriched;
|
||||||
|
|
||||||
countEl.textContent = enriched.length;
|
countEl.textContent = enriched.length;
|
||||||
const tabCount = document.getElementById('tab-count-acquisto');
|
const tabCount = document.getElementById('tab-count-acquisto');
|
||||||
@@ -13313,7 +13417,7 @@ async function renderShoppingItems() {
|
|||||||
// ── PANTRY HINTS: show "already at home: X" for each shopping item ──────
|
// ── PANTRY HINTS: show "already at home: X" for each shopping item ──────
|
||||||
// Load inventory once, then decorate all items asynchronously.
|
// Load inventory once, then decorate all items asynchronously.
|
||||||
_getShoppingInventoryCache().then(invItems => {
|
_getShoppingInventoryCache().then(invItems => {
|
||||||
for (const { item, idx, smartData, urgency } of enriched) {
|
for (const { item, idx, smartData, urgency } of pantryRows) {
|
||||||
const matches = _shoppingFamilyInventoryRows(item, smartData, invItems);
|
const matches = _shoppingFamilyInventoryRows(item, smartData, invItems);
|
||||||
if (matches.length === 0) continue;
|
if (matches.length === 0) continue;
|
||||||
// Don't show "already at home" when the item is flagged urgent — stock is clearly insufficient.
|
// Don't show "already at home" when the item is flagged urgent — stock is clearly insufficient.
|
||||||
@@ -14320,6 +14424,7 @@ function viewArchivedRecipe(idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _cachedRecipe = null;
|
let _cachedRecipe = null;
|
||||||
|
let _recipeShoppingSuggestions = [];
|
||||||
let _generatedTodayTitles = []; // client-side list, robust vs race conditions
|
let _generatedTodayTitles = []; // client-side list, robust vs race conditions
|
||||||
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
|
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
|
||||||
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
|
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
|
||||||
@@ -14419,6 +14524,14 @@ function _normalizeRecipeIngQtyNumber(ing) {
|
|||||||
const pkgUnit = (ing.package_unit || '').toLowerCase();
|
const pkgUnit = (ing.package_unit || '').toLowerCase();
|
||||||
const isConfSub = unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml');
|
const isConfSub = unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml');
|
||||||
let useQty = parseFloat(ing.qty_number) || 0;
|
let useQty = parseFloat(ing.qty_number) || 0;
|
||||||
|
const stockPieces = parseFloat(ing.inventory_qty_total ?? ing.inventory_qty) || 0;
|
||||||
|
|
||||||
|
if (unit === 'pz') {
|
||||||
|
useQty = _recipeResolvePieceQty(useQty, recipeVal, recipeUnit, stockPieces);
|
||||||
|
ing.qty_number = Math.round(useQty * 1000) / 1000;
|
||||||
|
ing.qty = _recipeFormatPieceQtyLabel(useQty);
|
||||||
|
return useQty;
|
||||||
|
}
|
||||||
|
|
||||||
if (isConfSub && recipeVal > 0 && recipeUnit === pkgUnit) {
|
if (isConfSub && recipeVal > 0 && recipeUnit === pkgUnit) {
|
||||||
useQty = recipeVal;
|
useQty = recipeVal;
|
||||||
@@ -14435,17 +14548,84 @@ function _normalizeRecipeIngQtyNumber(ing) {
|
|||||||
return useQty;
|
return useQty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _recipeRoundPieceQty(n) {
|
||||||
|
return Math.max(0.25, Math.round(n * 4) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _recipeFormatPieceQtyLabel(n) {
|
||||||
|
const whole = Math.floor(n);
|
||||||
|
const frac = Math.round((n - whole) * 4) / 4;
|
||||||
|
const fracMap = { 0.25: '¼', 0.5: '½', 0.75: '¾' };
|
||||||
|
const fracStr = fracMap[frac] || '';
|
||||||
|
if (whole === 0) return (fracStr || '0') + ' pz';
|
||||||
|
return whole + fracStr + ' pz';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Piece inventory only — never derive count from default_quantity / grams. */
|
||||||
|
function _recipeResolvePieceQty(rawQty, recipeVal, recipeUnit, stockPieces) {
|
||||||
|
stockPieces = Math.max(0, stockPieces);
|
||||||
|
if (recipeUnit === 'pz' && recipeVal > 0) {
|
||||||
|
return _recipeRoundPieceQty(Math.min(recipeVal, stockPieces > 0 ? stockPieces : recipeVal));
|
||||||
|
}
|
||||||
|
if (rawQty >= 0.25 && rawQty <= Math.min(stockPieces > 0 ? stockPieces : 50, 50)) {
|
||||||
|
return _recipeRoundPieceQty(rawQty);
|
||||||
|
}
|
||||||
|
if (rawQty >= 20 && (stockPieces <= 0 || rawQty > stockPieces)) {
|
||||||
|
return _recipeRoundPieceQty(Math.min(1, stockPieces > 0 ? stockPieces : 1));
|
||||||
|
}
|
||||||
|
if (recipeVal >= 0.25 && recipeVal <= 50 && !['g', 'ml', 'kg', 'l'].includes(recipeUnit)) {
|
||||||
|
return _recipeRoundPieceQty(Math.min(recipeVal, stockPieces > 0 ? stockPieces : recipeVal));
|
||||||
|
}
|
||||||
|
return _recipeRoundPieceQty(Math.min(1, stockPieces > 0 ? stockPieces : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _recipeGetServingCapForIngredient(name, unit, persons) {
|
||||||
|
if (!persons || persons <= 0) return null;
|
||||||
|
const n = (name || '').toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
if (unit === 'pz') {
|
||||||
|
if (/\b(cipoll\w*|porr\w*|scalog\w*)\b/.test(n)) return persons;
|
||||||
|
if (/\b(peperon\w*|melanzan\w*|zucchin\w*|finocchi\w*|melone)\b/.test(n)) return persons;
|
||||||
|
if (/\b(limon\w*|aranc\w*|limett\w*)\b/.test(n)) return Math.max(1, Math.ceil(0.5 * persons));
|
||||||
|
if (/\b(dado|brodo)\b/.test(n)) return Math.min(persons, 1);
|
||||||
|
if (/\b(baulett\w*|panin\w*|toast|piadin\w*|grissin\w*)\b/.test(n)) return Math.min(2, persons);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (unit === 'g' || unit === 'ml') {
|
||||||
|
if (/\b(spinac\w*|bietol\w*|rucol\w*|lattug\w*|valerian\w*|songin\w*|misticanz\w*|indivi\w*|radicchi\w*|cicori\w*)\b/.test(n)) return 150 * persons;
|
||||||
|
if (/\b(minestr\w*|verdure)\b/.test(n)) return 200 * persons;
|
||||||
|
if (/\b(pane\s*gratt|grattugi\w*|pangratt)\b/.test(n)) return 30 * persons;
|
||||||
|
if (/\b(zucchin\w*|melanzan\w*|peperon\w*|carot\w*|sedan\w*|finocchi\w*|cavolf\w*|broccol\w*|zucc\w*|pomodor\w*|verdur\w*)\b/.test(n)) return 150 * persons;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _recipeClampQtyForServings(ing, persons) {
|
||||||
|
if (!persons || persons <= 0) return;
|
||||||
|
const unit = ing.inventory_unit || 'pz';
|
||||||
|
let qty = parseFloat(ing.qty_number) || 0;
|
||||||
|
if (qty <= 0) return;
|
||||||
|
const cap = _recipeGetServingCapForIngredient(ing.name, unit, persons);
|
||||||
|
if (cap === null || qty <= cap) return;
|
||||||
|
ing.qty_number = Math.round(cap * 100) / 100;
|
||||||
|
if (unit === 'pz') ing.qty = _recipeFormatPieceQtyLabel(cap);
|
||||||
|
else if (unit === 'g' || unit === 'ml') ing.qty = Math.round(cap) + ' ' + unit;
|
||||||
|
delete ing.use_all_suggested;
|
||||||
|
if (ing.stock_have != null) ing.stock_remain = Math.max(0, Math.round((ing.stock_have - cap) * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
function _recipeGetClosedProductBaseQty(ing) {
|
function _recipeGetClosedProductBaseQty(ing) {
|
||||||
const unit = ing.inventory_unit || 'pz';
|
const unit = ing.inventory_unit || 'pz';
|
||||||
const pkgSize = parseFloat(ing.default_quantity) || 0;
|
const pkgSize = parseFloat(ing.default_quantity) || 0;
|
||||||
const pkgUnit = (ing.package_unit || '').toLowerCase();
|
const pkgUnit = (ing.package_unit || '').toLowerCase();
|
||||||
|
// Countable items: one piece = one unit — ignore default_quantity weight in grams
|
||||||
|
if (unit === 'pz') return 1;
|
||||||
if (unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml')) {
|
if (unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml')) {
|
||||||
return pkgSize;
|
return pkgSize;
|
||||||
}
|
}
|
||||||
if (unit === 'conf' && pkgSize > 0) {
|
if (unit === 'conf' && pkgSize > 0) {
|
||||||
return pkgSize;
|
return pkgSize;
|
||||||
}
|
}
|
||||||
if (pkgSize > 0 && (unit === 'g' || unit === 'ml' || unit === 'pz')) {
|
if (pkgSize > 0 && (unit === 'g' || unit === 'ml')) {
|
||||||
return pkgSize;
|
return pkgSize;
|
||||||
}
|
}
|
||||||
if (unit === 'conf') {
|
if (unit === 'conf') {
|
||||||
@@ -14495,7 +14675,9 @@ function _computeRecipeIngStockHint(ing, totalStockQty) {
|
|||||||
ing.qty = Math.round(useDisp) + ' ' + pkgUnit;
|
ing.qty = Math.round(useDisp) + ' ' + pkgUnit;
|
||||||
} else {
|
} else {
|
||||||
ing.qty_number = Math.round(totalStockQty * 1000) / 1000;
|
ing.qty_number = Math.round(totalStockQty * 1000) / 1000;
|
||||||
ing.qty = (unit === 'pz' ? (Math.round(totalStockQty * 100) / 100) : Math.round(totalStockQty)) + ' ' + (unit === 'pz' ? t('units.pz') : unit);
|
ing.qty = unit === 'pz'
|
||||||
|
? _recipeFormatPieceQtyLabel(totalStockQty)
|
||||||
|
: Math.round(totalStockQty) + ' ' + unit;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
delete ing.use_all_suggested;
|
delete ing.use_all_suggested;
|
||||||
@@ -14551,6 +14733,7 @@ async function enrichRecipeIngredientsStock(recipe) {
|
|||||||
ing.package_unit = pick.package_unit;
|
ing.package_unit = pick.package_unit;
|
||||||
}
|
}
|
||||||
_computeRecipeIngStockHint(ing, totalStock);
|
_computeRecipeIngStockHint(ing, totalStock);
|
||||||
|
_recipeClampQtyForServings(ing, Math.max(1, parseInt(recipe.persons, 10) || 1));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('enrichRecipeIngredientsStock:', e);
|
console.warn('enrichRecipeIngredientsStock:', e);
|
||||||
@@ -14659,11 +14842,14 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
|||||||
<button type="button" class="use-unit-btn" id="ruse-unit-conf" onclick="switchRecipeUseUnit('conf')">${t('recipes.packs_label')}</button>
|
<button type="button" class="use-unit-btn" id="ruse-unit-conf" onclick="switchRecipeUseUnit('conf')">${t('recipes.packs_label')}</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="ruse-hint" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.quantity_in_total').replace('{unit}', subLabel).replace('{total}', Math.round(totalSub) + subLabel)}</p>
|
<p id="ruse-hint" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.quantity_in_total').replace('{unit}', subLabel).replace('{total}', Math.round(totalSub) + subLabel)}</p>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input"
|
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
||||||
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input"
|
||||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="ruse-quantity-unit" aria-live="polite">${escapeHtml(subLabel)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
_recipeUseNormalUnit = unit;
|
_recipeUseNormalUnit = unit;
|
||||||
@@ -14671,12 +14857,15 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
|||||||
const unitLabel = unitLabels[unit] || unit;
|
const unitLabel = unitLabels[unit] || unit;
|
||||||
const inputMin = '0.1';
|
const inputMin = '0.1';
|
||||||
qtySection = `
|
qtySection = `
|
||||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.amount_label')} (${unitLabel}):</p>
|
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.amount_label')}:</p>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input"
|
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
||||||
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input"
|
||||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="ruse-quantity-unit" aria-live="polite">${escapeHtml(unitLabel)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14733,6 +14922,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('modal-overlay').style.display = 'flex';
|
document.getElementById('modal-overlay').style.display = 'flex';
|
||||||
|
syncRecipeUseQtyUnitBadge();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('useRecipeIngredient error:', err);
|
console.error('useRecipeIngredient error:', err);
|
||||||
@@ -14771,6 +14961,7 @@ function switchRecipeUseUnit(mode) {
|
|||||||
qtyInput.min = 0.5;
|
qtyInput.min = 0.5;
|
||||||
hint.textContent = t('recipes.packs_of_have').replace('{size}', `${_recipeUseConfMode.packageSize}${_recipeUseConfMode.subLabel}`).replace('{count}', _recipeUseConfMode.totalConf.toFixed(1));
|
hint.textContent = t('recipes.packs_of_have').replace('{size}', `${_recipeUseConfMode.packageSize}${_recipeUseConfMode.subLabel}`).replace('{count}', _recipeUseConfMode.totalConf.toFixed(1));
|
||||||
}
|
}
|
||||||
|
syncRecipeUseQtyUnitBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustRecipeUseQty(direction) {
|
function adjustRecipeUseQty(direction) {
|
||||||
@@ -15047,6 +15238,30 @@ function scaleRecipePersons(delta) {
|
|||||||
_updateRecipeStockHintsAfterScale(ratio);
|
_updateRecipeStockHintsAfterScale(ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addRecipeShoppingSuggestions() {
|
||||||
|
const items = (_recipeShoppingSuggestions || []).filter(s => s && s.name);
|
||||||
|
if (!items.length) return;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
items: items.map(s => ({
|
||||||
|
name: s.name,
|
||||||
|
specification: s.qty ? `Da ricetta · ${s.qty}` : 'Da ricetta',
|
||||||
|
})),
|
||||||
|
listUUID: typeof shoppingListUUID !== 'undefined' ? shoppingListUUID : undefined,
|
||||||
|
};
|
||||||
|
const data = await api('shopping_add', {}, 'POST', payload);
|
||||||
|
if (data.success) {
|
||||||
|
showToast(t('recipes.shopping_suggestions_added'), 'success');
|
||||||
|
if (typeof loadShoppingCount === 'function') loadShoppingCount();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || t('error.bring_add'), 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('addRecipeShoppingSuggestions:', e);
|
||||||
|
showToast(t('error.bring_add'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderRecipe(r) {
|
async function renderRecipe(r) {
|
||||||
await enrichRecipeIngredientsStock(r);
|
await enrichRecipeIngredientsStock(r);
|
||||||
// Reset regen choice panel (hide choice, show button)
|
// Reset regen choice panel (hide choice, show button)
|
||||||
@@ -15093,6 +15308,20 @@ async function renderRecipe(r) {
|
|||||||
html += `<div class="recipe-tools-banner">🔧 <strong>${escapeHtml(t('recipes.tools_title'))}:</strong> ${tools.map(tool => `<span class="recipe-tool-chip">${escapeHtml(tool)}</span>`).join('')}</div>`;
|
html += `<div class="recipe-tools-banner">🔧 <strong>${escapeHtml(t('recipes.tools_title'))}:</strong> ${tools.map(tool => `<span class="recipe-tool-chip">${escapeHtml(tool)}</span>`).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional shopping suggestions (ingredients removed because not in pantry)
|
||||||
|
const shopSug = r.shopping_suggestions || [];
|
||||||
|
if (shopSug.length > 0) {
|
||||||
|
const items = shopSug.map(s => `<li><strong>${escapeHtml(s.name)}</strong>${s.qty ? ': ' + escapeHtml(s.qty) : ''}</li>`).join('');
|
||||||
|
html += `<div class="recipe-shopping-suggestions" id="recipe-shopping-suggestions">
|
||||||
|
<p>🛒 ${escapeHtml(t('recipes.shopping_suggestions_intro'))}</p>
|
||||||
|
<ul>${items}</ul>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" onclick="addRecipeShoppingSuggestions()">${escapeHtml(t('recipes.shopping_suggestions_add'))}</button>
|
||||||
|
</div>`;
|
||||||
|
_recipeShoppingSuggestions = shopSug;
|
||||||
|
} else {
|
||||||
|
_recipeShoppingSuggestions = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Ingredients
|
// Ingredients
|
||||||
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
||||||
(r.ingredients || []).forEach((ing, idx) => {
|
(r.ingredients || []).forEach((ing, idx) => {
|
||||||
@@ -15106,6 +15335,9 @@ async function renderRecipe(r) {
|
|||||||
let details = [];
|
let details = [];
|
||||||
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
||||||
details.push(ingredientLocLabels[ing.location] || ('📍 ' + ing.location));
|
details.push(ingredientLocLabels[ing.location] || ('📍 ' + ing.location));
|
||||||
|
if (ing.location === 'freezer') {
|
||||||
|
details.push(t('recipes.frozen_badge') || '❄️ Surgelato');
|
||||||
|
}
|
||||||
if (ing.expiry_date) {
|
if (ing.expiry_date) {
|
||||||
const exp = new Date(ing.expiry_date);
|
const exp = new Date(ing.expiry_date);
|
||||||
const now = new Date(); now.setHours(0,0,0,0);
|
const now = new Date(); now.setHours(0,0,0,0);
|
||||||
@@ -15125,10 +15357,8 @@ async function renderRecipe(r) {
|
|||||||
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, "'")}')" title="${t('cooking.ingredient_deduct_title')}">${t('cooking.ingredient_use_btn')}</button>`;
|
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, "'")}')" title="${t('cooking.ingredient_deduct_title')}">${t('cooking.ingredient_use_btn')}</button>`;
|
||||||
}
|
}
|
||||||
html += `</li>`;
|
html += `</li>`;
|
||||||
} else {
|
|
||||||
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
|
|
||||||
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}"><span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${pantryIcon}</span></li>`;
|
|
||||||
}
|
}
|
||||||
|
// Non-pantry ingredients are stripped server-side; nothing to render here.
|
||||||
});
|
});
|
||||||
html += '</ul>';
|
html += '</ul>';
|
||||||
|
|
||||||
@@ -18300,8 +18530,28 @@ function _applySpesaScanUI() {
|
|||||||
_updateScanAiButton();
|
_updateScanAiButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop purchased items from in-memory Bring list (by Italian or catalog name). */
|
||||||
|
function _applyShoppingListRemovals(removedNames) {
|
||||||
|
if (!removedNames?.length) return;
|
||||||
|
const keys = new Set();
|
||||||
|
for (const n of removedNames) {
|
||||||
|
const low = String(n || '').trim().toLowerCase();
|
||||||
|
if (low) keys.add(low);
|
||||||
|
const tok = _nameTokens(low)[0];
|
||||||
|
if (tok) keys.add(tok);
|
||||||
|
}
|
||||||
|
if (!keys.size) return;
|
||||||
|
shoppingItems = (shoppingItems || []).filter(item => {
|
||||||
|
const nameLow = (item.name || '').toLowerCase();
|
||||||
|
const rawLow = (item.rawName || '').toLowerCase();
|
||||||
|
const first = _nameTokens(nameLow)[0] || '';
|
||||||
|
return !keys.has(nameLow) && !keys.has(rawLow) && !(first && keys.has(first));
|
||||||
|
});
|
||||||
|
loadShoppingCount();
|
||||||
|
}
|
||||||
|
|
||||||
// Called after successful add — returns true if spesa mode handled navigation
|
// Called after successful add — returns true if spesa mode handled navigation
|
||||||
async function spesaModeAfterAdd() {
|
async function spesaModeAfterAdd(addResult) {
|
||||||
if (!_spesaMode) return false;
|
if (!_spesaMode) return false;
|
||||||
if (currentProduct) {
|
if (currentProduct) {
|
||||||
_spesaSession.push({
|
_spesaSession.push({
|
||||||
@@ -18311,7 +18561,7 @@ async function spesaModeAfterAdd() {
|
|||||||
});
|
});
|
||||||
updateSpesaBanner();
|
updateSpesaBanner();
|
||||||
_shoppingInventoryCache = null;
|
_shoppingInventoryCache = null;
|
||||||
await _spesaRemovePurchasedFromList(currentProduct);
|
await _spesaRemovePurchasedFromList(currentProduct, addResult);
|
||||||
const addLoc = document.getElementById('add-location')?.value || 'dispensa';
|
const addLoc = document.getElementById('add-location')?.value || 'dispensa';
|
||||||
_showFamilySiblingSuggest(currentProduct.id, addLoc);
|
_showFamilySiblingSuggest(currentProduct.id, addLoc);
|
||||||
}
|
}
|
||||||
@@ -18320,27 +18570,34 @@ async function spesaModeAfterAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Remove matching shopping-list / Bring entry after a spesa-mode purchase. */
|
/** Remove matching shopping-list / Bring entry after a spesa-mode purchase. */
|
||||||
async function _spesaRemovePurchasedFromList(product) {
|
async function _spesaRemovePurchasedFromList(product, addResult) {
|
||||||
const namesToMark = [product.name];
|
const namesToMark = [product.name];
|
||||||
if (product.shopping_name) namesToMark.push(product.shopping_name);
|
if (product.shopping_name) namesToMark.push(product.shopping_name);
|
||||||
if (shoppingListUUID && shoppingItems.length > 0) {
|
|
||||||
const generic = product.shopping_name || product.name;
|
if (addResult?.removed_names?.length) {
|
||||||
const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(product.name, shoppingItems);
|
_applyShoppingListRemovals(addResult.removed_names);
|
||||||
if (match) {
|
namesToMark.push(...addResult.removed_names);
|
||||||
try {
|
|
||||||
const r = await api('shopping_remove', {}, 'POST', {
|
|
||||||
name: match.name,
|
|
||||||
rawName: match.rawName || '',
|
|
||||||
listUUID: shoppingListUUID,
|
|
||||||
});
|
|
||||||
if (r?.success) {
|
|
||||||
shoppingItems = shoppingItems.filter(i => i !== match);
|
|
||||||
namesToMark.push(match.name);
|
|
||||||
}
|
|
||||||
} catch (_) { /* best effort */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generic = product.shopping_name || product.name;
|
||||||
|
if (!addResult?.removed_from_bring) {
|
||||||
|
try {
|
||||||
|
const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(product.name, shoppingItems);
|
||||||
|
const r = await api('shopping_remove', {}, 'POST', {
|
||||||
|
name: match?.name || generic,
|
||||||
|
rawName: match?.rawName || '',
|
||||||
|
listUUID: shoppingListUUID || undefined,
|
||||||
|
});
|
||||||
|
if (r?.success) {
|
||||||
|
_applyShoppingListRemovals([match?.name || generic, match?.rawName].filter(Boolean));
|
||||||
|
if (match?.name) namesToMark.push(match.name);
|
||||||
|
}
|
||||||
|
} catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
_markBringPurchased(namesToMark);
|
_markBringPurchased(namesToMark);
|
||||||
|
loadShoppingList._bgCall = true;
|
||||||
|
loadShoppingList();
|
||||||
}
|
}
|
||||||
|
|
||||||
const _FAMILY_SIBLING_CONFIRM_TTL = 24 * 60 * 60 * 1000;
|
const _FAMILY_SIBLING_CONFIRM_TTL = 24 * 60 * 60 * 1000;
|
||||||
@@ -18936,27 +19193,6 @@ function _heartbeatRetry() {
|
|||||||
* Returns true if the app can proceed, false if a critical check failed.
|
* Returns true if the app can proceed, false if a critical check failed.
|
||||||
*/
|
*/
|
||||||
async function _runStartupCheck() {
|
async function _runStartupCheck() {
|
||||||
const spinnerEl = document.getElementById('preloader-spinner');
|
|
||||||
const wrapEl = document.getElementById('preloader-progress-wrap');
|
|
||||||
const barEl = document.getElementById('preloader-bar');
|
|
||||||
const labelEl = document.getElementById('preloader-check-label');
|
|
||||||
const warningsEl = document.getElementById('preloader-warnings');
|
|
||||||
const errorEl = document.getElementById('preloader-error-msg');
|
|
||||||
const retryBtn = document.getElementById('preloader-retry-btn');
|
|
||||||
|
|
||||||
if (!wrapEl) return true; // preloader already removed
|
|
||||||
|
|
||||||
const tl = (key, fallback) => {
|
|
||||||
const full = 'startup.' + key;
|
|
||||||
const v = typeof t === 'function' ? t(full) : full;
|
|
||||||
return (v === full) ? fallback : v;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Switch from spinner to progress bar
|
|
||||||
if (spinnerEl) spinnerEl.style.display = 'none';
|
|
||||||
wrapEl.style.display = '';
|
|
||||||
|
|
||||||
// Helper: set progress bar + crossfade status text (function decl avoids TDZ if called early)
|
|
||||||
let _curPct = 0;
|
let _curPct = 0;
|
||||||
function setProgress(pct, label, state) {
|
function setProgress(pct, label, state) {
|
||||||
_curPct = pct;
|
_curPct = pct;
|
||||||
@@ -18981,6 +19217,26 @@ async function _runStartupCheck() {
|
|||||||
el.textContent = cleanLabel;
|
el.textContent = cleanLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spinnerEl = document.getElementById('preloader-spinner');
|
||||||
|
const wrapEl = document.getElementById('preloader-progress-wrap');
|
||||||
|
const barEl = document.getElementById('preloader-bar');
|
||||||
|
const labelEl = document.getElementById('preloader-check-label');
|
||||||
|
const warningsEl = document.getElementById('preloader-warnings');
|
||||||
|
const errorEl = document.getElementById('preloader-error-msg');
|
||||||
|
const retryBtn = document.getElementById('preloader-retry-btn');
|
||||||
|
|
||||||
|
if (!wrapEl) return true; // preloader already removed
|
||||||
|
|
||||||
|
const tl = (key, fallback) => {
|
||||||
|
const full = 'startup.' + key;
|
||||||
|
const v = typeof t === 'function' ? t(full) : full;
|
||||||
|
return (v === full) ? fallback : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch from spinner to progress bar
|
||||||
|
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||||
|
wrapEl.style.display = '';
|
||||||
|
|
||||||
// Auto-provision API token for same-origin browser sessions
|
// Auto-provision API token for same-origin browser sessions
|
||||||
if (typeof ensureApiToken === 'function') {
|
if (typeof ensureApiToken === 'function') {
|
||||||
setProgress(5, tl('token_autoconfig', 'Configurazione accesso...'), 'ok');
|
setProgress(5, tl('token_autoconfig', 'Configurazione accesso...'), 'ok');
|
||||||
|
|||||||
+107
File diff suppressed because one or more lines are too long
@@ -1101,9 +1101,9 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||||
}
|
}
|
||||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
|
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"")
|
||||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
|
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"")
|
||||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
|
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"")
|
||||||
append("}")
|
append("}")
|
||||||
}
|
}
|
||||||
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<string name="install_error_download">Download fehlgeschlagen</string>
|
<string name="install_error_download">Download fehlgeschlagen</string>
|
||||||
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
|
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
|
||||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||||
<string name="install_perm_detail">Aktiviere 'Unbekannte Apps installieren' in den Einstellungen, dann komm zurück.</string>
|
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||||
<string name="btn_back">Zurück</string>
|
<string name="btn_back">Zurück</string>
|
||||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||||
@@ -72,15 +72,11 @@
|
|||||||
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
||||||
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
||||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
|
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.\n\nZum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||||
|
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → \"API-Schlüssel erhalten\"</string>
|
||||||
Zum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
|
||||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → "API-Schlüssel erhalten"</string>
|
|
||||||
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
||||||
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
||||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.
|
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||||
|
|
||||||
Bring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
|
||||||
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
||||||
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
||||||
<string name="setup_done_title">Alles bereit!</string>
|
<string name="setup_done_title">Alles bereit!</string>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<string name="install_error_download">Descarga fallida</string>
|
<string name="install_error_download">Descarga fallida</string>
|
||||||
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
||||||
<string name="install_error_install">Instalación fallida</string>
|
<string name="install_error_install">Instalación fallida</string>
|
||||||
<string name="install_perm_detail">Habilita 'Instalar apps desconocidas' en los ajustes y vuelve aquí.</string>
|
<string name="install_perm_detail">Habilita \'Instalar apps desconocidas\' en los ajustes y vuelve aquí.</string>
|
||||||
<string name="install_btn_retry">↩ Reintentar</string>
|
<string name="install_btn_retry">↩ Reintentar</string>
|
||||||
<string name="btn_back">Atrás</string>
|
<string name="btn_back">Atrás</string>
|
||||||
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
||||||
@@ -72,15 +72,11 @@
|
|||||||
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
||||||
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
||||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
|
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.\n\nPara activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||||
|
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → \"Obtener clave API\"</string>
|
||||||
Para activarla, introduce tu clave API de Gemini gratuita.</string>
|
|
||||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → "Obtener clave API"</string>
|
|
||||||
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
||||||
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
||||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.
|
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.\n\nIntroduce tus credenciales de Bring! para activar la integración.</string>
|
||||||
|
|
||||||
Introduce tus credenciales de Bring! para activar la integración.</string>
|
|
||||||
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
||||||
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
||||||
<string name="setup_done_title">¡Todo listo!</string>
|
<string name="setup_done_title">¡Todo listo!</string>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">EverShelf Kiosk</string>
|
<string name="app_name">EverShelf Kiosk</string>
|
||||||
<string name="setup_enter_url">Veuillez d'abord saisir une URL</string>
|
<string name="setup_enter_url">Veuillez d\'abord saisir une URL</string>
|
||||||
<string name="setup_testing">Test de connexion…</string>
|
<string name="setup_testing">Test de connexion…</string>
|
||||||
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
||||||
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
||||||
<string name="setup_unreachable">Impossible d'atteindre le serveur</string>
|
<string name="setup_unreachable">Impossible d\'atteindre le serveur</string>
|
||||||
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
||||||
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
||||||
<string name="setup_discovering">Analyse en cours…</string>
|
<string name="setup_discovering">Analyse en cours…</string>
|
||||||
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
||||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l'URL manuellement.</string>
|
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l\'URL manuellement.</string>
|
||||||
<string name="setup_exit_title">Quitter la configuration ?</string>
|
<string name="setup_exit_title">Quitter la configuration ?</string>
|
||||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l'app.</string>
|
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l\'app.</string>
|
||||||
<string name="setup_exit_confirm">Quitter</string>
|
<string name="setup_exit_confirm">Quitter</string>
|
||||||
<string name="setup_exit_cancel">Continuer</string>
|
<string name="setup_exit_cancel">Continuer</string>
|
||||||
<string name="setup_step_back">← Retour</string>
|
<string name="setup_step_back">← Retour</string>
|
||||||
@@ -22,20 +22,20 @@
|
|||||||
<string name="wizard_step3_title">Balance intelligente</string>
|
<string name="wizard_step3_title">Balance intelligente</string>
|
||||||
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
||||||
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
||||||
<string name="wizard_step3_yes">✅ Oui, j'ai une balance</string>
|
<string name="wizard_step3_yes">✅ Oui, j\'ai une balance</string>
|
||||||
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
||||||
<string name="ble_scanning">🔍 Scan en cours…</string>
|
<string name="ble_scanning">🔍 Scan en cours…</string>
|
||||||
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
||||||
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
||||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu'elle est allumée et à proximité, puis réessayez.</string>
|
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu\'elle est allumée et à proximité, puis réessayez.</string>
|
||||||
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
||||||
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
||||||
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
||||||
<string name="ble_weight_received">Poids reçu — correspond-il à l'affichage de la balance ?</string>
|
<string name="ble_weight_received">Poids reçu — correspond-il à l\'affichage de la balance ?</string>
|
||||||
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
||||||
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
||||||
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
||||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l'une d'elles pour la sélectionner.</string>
|
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l\'une d\'elles pour la sélectionner.</string>
|
||||||
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
||||||
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
||||||
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
||||||
@@ -43,13 +43,13 @@
|
|||||||
<string name="install_downloading">Téléchargement en cours…</string>
|
<string name="install_downloading">Téléchargement en cours…</string>
|
||||||
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
||||||
<string name="install_installing">Installation en cours…</string>
|
<string name="install_installing">Installation en cours…</string>
|
||||||
<string name="install_confirm_detail">Confirmez l'installation dans la boîte de dialogue ouverte.</string>
|
<string name="install_confirm_detail">Confirmez l\'installation dans la boîte de dialogue ouverte.</string>
|
||||||
<string name="install_success">Installé avec succès !</string>
|
<string name="install_success">Installé avec succès !</string>
|
||||||
<string name="install_success_detail">L'app a été mise à jour.</string>
|
<string name="install_success_detail">L\'app a été mise à jour.</string>
|
||||||
<string name="install_error_download">Téléchargement échoué</string>
|
<string name="install_error_download">Téléchargement échoué</string>
|
||||||
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
||||||
<string name="install_error_install">Installation échouée</string>
|
<string name="install_error_install">Installation échouée</string>
|
||||||
<string name="install_perm_detail">Activez 'Installer des apps inconnues' dans les paramètres, puis revenez ici.</string>
|
<string name="install_perm_detail">Activez \'Installer des apps inconnues\' dans les paramètres, puis revenez ici.</string>
|
||||||
<string name="install_btn_retry">↩ Réessayer</string>
|
<string name="install_btn_retry">↩ Réessayer</string>
|
||||||
<string name="btn_back">Retour</string>
|
<string name="btn_back">Retour</string>
|
||||||
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
||||||
@@ -58,13 +58,13 @@
|
|||||||
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
||||||
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
||||||
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
||||||
<string name="wizard_server_ok_detail">Rapport d'erreurs actif — les échecs d'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
<string name="wizard_server_ok_detail">Rapport d\'erreurs actif — les échecs d\'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||||
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
||||||
<string name="wizard_server_error_detail">Les erreurs n'atteindront pas GitHub Issues. Vérifiez l'URL saisie à l'étape 2.</string>
|
<string name="wizard_server_error_detail">Les erreurs n\'atteindront pas GitHub Issues. Vérifiez l\'URL saisie à l\'étape 2.</string>
|
||||||
<string name="setup_features_title">Fonctionnalités</string>
|
<string name="setup_features_title">Fonctionnalités</string>
|
||||||
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
||||||
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
||||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d'inactivité.</string>
|
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d\'inactivité.</string>
|
||||||
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
||||||
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
||||||
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
||||||
@@ -72,15 +72,11 @@
|
|||||||
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
||||||
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
||||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
|
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.\n\nPour l\'activer, entrez votre clé API Gemini gratuite.</string>
|
||||||
|
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → \"Obtenir une clé API\"</string>
|
||||||
Pour l'activer, entrez votre clé API Gemini gratuite.</string>
|
|
||||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → "Obtenir une clé API"</string>
|
|
||||||
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
||||||
<string name="setup_bring_title">Bring! Liste de courses</string>
|
<string name="setup_bring_title">Bring! Liste de courses</string>
|
||||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l'app Bring!.
|
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l\'app Bring!.\n\nEntrez vos identifiants Bring! pour activer l\'intégration.</string>
|
||||||
|
|
||||||
Entrez vos identifiants Bring! pour activer l'intégration.</string>
|
|
||||||
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
||||||
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
||||||
<string name="setup_done_title">Tout est prêt !</string>
|
<string name="setup_done_title">Tout est prêt !</string>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
||||||
<string name="setup_discovering">Scansione in corso…</string>
|
<string name="setup_discovering">Scansione in corso…</string>
|
||||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l'URL manualmente.</string>
|
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l'app.</string>
|
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||||
<string name="setup_exit_confirm">Esci</string>
|
<string name="setup_exit_confirm">Esci</string>
|
||||||
<string name="setup_exit_cancel">Continua</string>
|
<string name="setup_exit_cancel">Continua</string>
|
||||||
<string name="setup_step_back">← Indietro</string>
|
<string name="setup_step_back">← Indietro</string>
|
||||||
@@ -28,28 +28,28 @@
|
|||||||
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
||||||
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
||||||
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
||||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall'elenco.</string>
|
<string name="ble_select_from_list">Seleziona la tua bilancia dall\'elenco.</string>
|
||||||
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
||||||
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
||||||
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
||||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all'avvio.</string>
|
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell'elenco per connettersi.</string>
|
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||||
<string name="install_downloading">Scaricamento in corso…</string>
|
<string name="install_downloading">Scaricamento in corso…</string>
|
||||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||||
<string name="install_installing">Installazione in corso…</string>
|
<string name="install_installing">Installazione in corso…</string>
|
||||||
<string name="install_confirm_detail">Conferma l'installazione nel dialog che si è aperto.</string>
|
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||||
<string name="install_success">Installato con successo!</string>
|
<string name="install_success">Installato con successo!</string>
|
||||||
<string name="install_success_detail">L'app è stata aggiornata.</string>
|
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||||
<string name="install_error_download">Download fallito</string>
|
<string name="install_error_download">Download fallito</string>
|
||||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||||
<string name="install_error_install">Installazione fallita</string>
|
<string name="install_error_install">Installazione fallita</string>
|
||||||
<string name="install_perm_detail">Abilita 'Installa app sconosciute' nelle impostazioni, poi torna qui.</string>
|
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||||
<string name="install_btn_retry">↩ Riprova</string>
|
<string name="install_btn_retry">↩ Riprova</string>
|
||||||
<string name="btn_back">Indietro</string>
|
<string name="btn_back">Indietro</string>
|
||||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||||
@@ -60,11 +60,11 @@
|
|||||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l'URL inserito al passaggio 2.</string>
|
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||||
<string name="setup_features_title">Funzionalità</string>
|
<string name="setup_features_title">Funzionalità</string>
|
||||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||||
<string name="setup_screensaver_toggle_hint">Mostra l'overlay orologio dopo 5 min di inattività.</string>
|
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||||
@@ -72,15 +72,11 @@
|
|||||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
|
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.\n\nPer abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||||
|
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → \"Ottieni chiave API\"</string>
|
||||||
Per abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
|
||||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → "Ottieni chiave API"</string>
|
|
||||||
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
||||||
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
||||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l'app Bring!.
|
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l\'app Bring!.\n\nInserisci le credenziali del tuo account Bring! per abilitare l\'integrazione.</string>
|
||||||
|
|
||||||
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
|
|
||||||
<string name="setup_bring_email_hint">Email Bring!</string>
|
<string name="setup_bring_email_hint">Email Bring!</string>
|
||||||
<string name="setup_bring_pass_hint">Password Bring!</string>
|
<string name="setup_bring_pass_hint">Password Bring!</string>
|
||||||
<string name="setup_done_title">Tutto pronto!</string>
|
<string name="setup_done_title">Tutto pronto!</string>
|
||||||
|
|||||||
+12
-8
@@ -94,7 +94,7 @@
|
|||||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||||
<span class="app-preloader-version" id="preloader-version">v1.7.39</span>
|
<span class="app-preloader-version" id="preloader-version">v1.7.41</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.39</span>
|
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.41</span>
|
||||||
</h1>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -420,6 +420,7 @@
|
|||||||
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
|
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||||
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="qty-unit-badge qty-unit-muted" id="add-quantity-unit" aria-live="polite">pz</span>
|
||||||
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
||||||
<option value="pz">pz</option>
|
<option value="pz">pz</option>
|
||||||
<option value="conf">conf</option>
|
<option value="conf">conf</option>
|
||||||
@@ -499,11 +500,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="use-partial">
|
<div class="use-partial">
|
||||||
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||||
|
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="use-quantity-unit" aria-live="polite">—</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1985,6 +1989,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/app.js?v=20260606n"></script>
|
<script src="assets/js/app.js?v=20260606z"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "EverShelf",
|
"name": "EverShelf",
|
||||||
"short_name": "EverShelf",
|
"short_name": "EverShelf",
|
||||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
"version": "1.7.39",
|
"version": "1.7.41",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Audit: products depleted in last N days vs shopping list / Bring / smart shopping.
|
||||||
|
* Usage: php scripts/audit-finished-shopping.php [days]
|
||||||
|
*/
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$days = max(1, (int)($argv[1] ?? 30));
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
// Recompute smart shopping fresh
|
||||||
|
ob_start();
|
||||||
|
smartShopping($db);
|
||||||
|
$smartJson = ob_get_clean();
|
||||||
|
$smartData = json_decode($smartJson, true);
|
||||||
|
$smartItems = $smartData['items'] ?? [];
|
||||||
|
$smartByPid = [];
|
||||||
|
$smartByName = [];
|
||||||
|
foreach ($smartItems as $si) {
|
||||||
|
foreach ($si['variants'] ?? [] as $v) {
|
||||||
|
$smartByPid[(int)$v['product_id']] = $si;
|
||||||
|
}
|
||||||
|
$smartByPid[(int)$si['product_id']] = $si;
|
||||||
|
$sn = strtolower(trim($si['shopping_name'] ?? $si['name'] ?? ''));
|
||||||
|
if ($sn !== '') $smartByName[$sn] = $si;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bring list
|
||||||
|
$bringNames = [];
|
||||||
|
$bringSpecs = [];
|
||||||
|
$auth = bringAuth();
|
||||||
|
if ($auth && !empty($auth['bringListUUID'])) {
|
||||||
|
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
|
||||||
|
if ($listData && isset($listData['purchase'])) {
|
||||||
|
foreach ($listData['purchase'] as $bi) {
|
||||||
|
$k = mb_strtolower($bi['name'] ?? '');
|
||||||
|
$bringNames[$k] = $bi['name'] ?? '';
|
||||||
|
$bringSpecs[$k] = $bi['specification'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal shopping list
|
||||||
|
$shopNames = [];
|
||||||
|
$shopRows = $db->query("SELECT name, specification FROM shopping_list")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($shopRows as $r) {
|
||||||
|
$shopNames[mb_strtolower($r['name'])] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products with zero stock, last activity in window
|
||||||
|
$rows = $db->query("
|
||||||
|
SELECT p.id, p.name, p.brand, p.shopping_name, p.unit,
|
||||||
|
COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) AS stock_qty,
|
||||||
|
(SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0
|
||||||
|
AND t.type IN ('out','waste','in')
|
||||||
|
AND t.created_at >= datetime('now', '-{$days} days')) AS last_activity,
|
||||||
|
(SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0
|
||||||
|
AND t.type IN ('out','waste')
|
||||||
|
AND t.created_at >= datetime('now', '-{$days} days')) AS last_out,
|
||||||
|
(SELECT COUNT(*) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')) AS use_count,
|
||||||
|
(SELECT COUNT(*) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type = 'in') AS buy_count
|
||||||
|
FROM products p
|
||||||
|
WHERE COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001
|
||||||
|
AND (SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0
|
||||||
|
AND t.type IN ('out','waste','in')
|
||||||
|
AND t.created_at >= datetime('now', '-{$days} days')) IS NOT NULL
|
||||||
|
ORDER BY last_activity DESC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
$onList = [];
|
||||||
|
$suppressed = [];
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$pid = (int)$r['id'];
|
||||||
|
$generic = trim($r['shopping_name'] ?? '') ?: computeShoppingName($r['name'], '', $r['brand'] ?? '');
|
||||||
|
$bringKey = mb_strtolower(italianToBring($generic));
|
||||||
|
$shopKey = mb_strtolower($generic);
|
||||||
|
|
||||||
|
$smart = $smartByPid[$pid] ?? $smartByName[mb_strtolower($generic)] ?? null;
|
||||||
|
$onBring = isset($bringNames[$bringKey]);
|
||||||
|
$onShop = isset($shopNames[$shopKey]);
|
||||||
|
$inSmart = $smart !== null && ($smart['urgency'] ?? 'none') !== 'none';
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'id' => $pid,
|
||||||
|
'name' => $r['name'],
|
||||||
|
'brand' => $r['brand'],
|
||||||
|
'generic' => $generic,
|
||||||
|
'last_activity' => $r['last_activity'],
|
||||||
|
'last_out' => $r['last_out'],
|
||||||
|
'use_count' => (int)$r['use_count'],
|
||||||
|
'buy_count' => (int)$r['buy_count'],
|
||||||
|
'on_bring' => $onBring,
|
||||||
|
'on_shop' => $onShop,
|
||||||
|
'in_smart' => $inSmart,
|
||||||
|
'smart_urgency' => $smart['urgency'] ?? null,
|
||||||
|
'smart_reasons' => $smart['reasons'] ?? [],
|
||||||
|
'bring_spec' => $bringSpecs[$bringKey] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$onBring && !$onShop && !$inSmart) {
|
||||||
|
$missing[] = $entry;
|
||||||
|
} elseif ($onBring || $onShop) {
|
||||||
|
$onList[] = $entry;
|
||||||
|
} elseif ($inSmart) {
|
||||||
|
$suppressed[] = $entry; // in smart but not synced yet
|
||||||
|
} else {
|
||||||
|
$missing[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Audit prodotti esauriti (ultimi {$days} giorni) ===\n";
|
||||||
|
echo 'Totale esauriti con attività recente: ' . count($rows) . "\n";
|
||||||
|
echo 'Già in lista/Bring: ' . count($onList) . "\n";
|
||||||
|
echo 'In smart shopping ma non in lista: ' . count($suppressed) . "\n";
|
||||||
|
echo 'MANCANTI (né lista né Bring né smart): ' . count($missing) . "\n\n";
|
||||||
|
|
||||||
|
if ($missing) {
|
||||||
|
echo "--- MANCANTI ---\n";
|
||||||
|
foreach ($missing as $m) {
|
||||||
|
echo sprintf(
|
||||||
|
"- [%d] %s%s → generico: %s | usi:%d acquisti:%d | ultimo:%s\n",
|
||||||
|
$m['id'],
|
||||||
|
$m['name'],
|
||||||
|
$m['brand'] ? " ({$m['brand']})" : '',
|
||||||
|
$m['generic'],
|
||||||
|
$m['use_count'],
|
||||||
|
$m['buy_count'],
|
||||||
|
$m['last_activity']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($suppressed) {
|
||||||
|
echo "--- IN SMART MA NON IN LISTA/BRING ---\n";
|
||||||
|
foreach ($suppressed as $m) {
|
||||||
|
echo sprintf(
|
||||||
|
"- [%d] %s → %s | urgenza:%s | %s\n",
|
||||||
|
$m['id'],
|
||||||
|
$m['name'],
|
||||||
|
$m['generic'],
|
||||||
|
$m['smart_urgency'] ?? '?',
|
||||||
|
implode(', ', $m['smart_reasons'] ?? [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export JSON for fix script
|
||||||
|
file_put_contents(
|
||||||
|
__DIR__ . '/../data/audit_finished_missing.json',
|
||||||
|
json_encode(['days' => $days, 'missing' => $missing, 'suppressed' => $suppressed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||||
|
);
|
||||||
|
echo "\nReport salvato in data/audit_finished_missing.json\n";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Backfill Bring!/shopping list for products depleted in the last N days.
|
||||||
|
* Usage: php scripts/backfill-finished-shopping.php [days]
|
||||||
|
*/
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$days = max(1, (int)($argv[1] ?? RECENTLY_EXHAUSTED_DAYS));
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$rows = $db->query("
|
||||||
|
SELECT p.id, p.name, p.shopping_name
|
||||||
|
FROM products p
|
||||||
|
WHERE COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001
|
||||||
|
AND (
|
||||||
|
SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')
|
||||||
|
) >= datetime('now', '-{$days} days')
|
||||||
|
ORDER BY (
|
||||||
|
SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')
|
||||||
|
) DESC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] Backfill {$days}d — " . count($rows) . " prodotti esauriti\n";
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$res = bringAddDepletedProduct($db, (int)$r['id']);
|
||||||
|
if (!empty($res['added'])) {
|
||||||
|
$added++;
|
||||||
|
echo " + {$r['name']} → {$res['generic_name']}\n";
|
||||||
|
} elseif (!empty($res['updated'])) {
|
||||||
|
$updated++;
|
||||||
|
echo " ~ {$r['name']} → {$res['generic_name']}\n";
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
smartShopping($db);
|
||||||
|
$json = ob_get_clean();
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
if ($decoded && !empty($decoded['success'])) {
|
||||||
|
$decoded['cached_at'] = date('c');
|
||||||
|
$decoded['cached_ts'] = time();
|
||||||
|
file_put_contents(
|
||||||
|
__DIR__ . '/../data/smart_shopping_cache.json',
|
||||||
|
json_encode($decoded, JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
bringSyncFull($db, false);
|
||||||
|
$sync = json_decode(ob_get_clean(), true);
|
||||||
|
$auto = $sync['auto_add'] ?? [];
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] bringAddDepleted: added={$added} updated={$updated} skipped={$skipped}\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] bringSync auto_add: ' . json_encode($auto, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Download @xenova/transformers runtime + all-MiniLM-L6-v2 for offline category classification.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
VENDOR="$ROOT/assets/vendor/transformers"
|
||||||
|
MODEL="$VENDOR/Xenova/all-MiniLM-L6-v2"
|
||||||
|
ONNX="$MODEL/onnx"
|
||||||
|
BASE="https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main"
|
||||||
|
|
||||||
|
mkdir -p "$ONNX"
|
||||||
|
|
||||||
|
echo "→ transformers.min.js"
|
||||||
|
curl -fsSL "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js" \
|
||||||
|
-o "$VENDOR/transformers.min.js"
|
||||||
|
|
||||||
|
for f in config.json tokenizer.json tokenizer_config.json; do
|
||||||
|
echo "→ $f"
|
||||||
|
curl -fsSL "$BASE/$f" -o "$MODEL/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "→ onnx/model_quantized.onnx (~22 MB)"
|
||||||
|
curl -fsSL "$BASE/onnx/model_quantized.onnx" -o "$ONNX/model_quantized.onnx"
|
||||||
|
|
||||||
|
chown -R www-data:www-data "$VENDOR" 2>/dev/null || true
|
||||||
|
echo "Done. Model installed under assets/vendor/transformers/"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Full Bring! sync: recompute smart shopping, migrate names, dedupe generics,
|
||||||
|
* fix specs, remove obsolete items, add missing critical/high.
|
||||||
|
*
|
||||||
|
* Usage: php scripts/sync-shopping-bring.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] Starting full Bring! sync…\n";
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
bringSyncFull($db, true);
|
||||||
|
$json = ob_get_clean();
|
||||||
|
$result = json_decode($json, true);
|
||||||
|
|
||||||
|
if (!$result || empty($result['success'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . ($result['error'] ?? $json) . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Smart items: ' . ($result['smart_items'] ?? '?') . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Migrate: ' . json_encode($result['migrate'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Dedupe: ' . json_encode($result['dedupe'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Specs: ' . json_encode($result['specs'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Cleanup: ' . json_encode($result['cleanup'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Auto-add: ' . json_encode($result['auto_add'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
if (!empty($result['dedupe_final'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Dedupe (final): ' . json_encode($result['dedupe_final'], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
}
|
||||||
|
if (!empty($result['cache_restored'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Cache restored: ' . $result['cache_restored'] . " items\n";
|
||||||
|
}
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] Done.\n";
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* One-shot triage: comment + close resolved auto-report bugs; reply on #200 (keep open).
|
||||||
|
* Usage: php scripts/triage-open-issues.php [--dry-run]
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/github.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/constants.php';
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv ?? [], true);
|
||||||
|
$repo = GH_REPO;
|
||||||
|
$token = _ghToken();
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghApi(string $token, string $method, string $url, array $payload = []): array {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = [
|
||||||
|
'Authorization: token ' . $token,
|
||||||
|
'Accept: application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version: 2022-11-28',
|
||||||
|
'User-Agent: EverShelf-Triage/1.0',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
];
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
]);
|
||||||
|
if ($method === 'PATCH') {
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
} elseif ($method === 'POST') {
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
}
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentIssue(string $token, string $repo, int $num, string $body, bool $dryRun): bool {
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "[dry-run] comment #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$r = ghApi($token, 'POST', "https://api.github.com/repos/$repo/issues/$num/comments", ['body' => $body]);
|
||||||
|
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||||
|
echo "OK comment #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fwrite(STDERR, "FAIL comment #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeIssue(string $token, string $repo, int $num, bool $dryRun): bool {
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "[dry-run] close #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$r = ghApi($token, 'PATCH', "https://api.github.com/repos/$repo/issues/$num", ['state' => 'closed']);
|
||||||
|
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||||
|
echo "OK close #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fwrite(STDERR, "FAIL close #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #200: reply only, keep OPEN ─────────────────────────────────────────────
|
||||||
|
$body200 = <<<'MD'
|
||||||
|
Ciao Marco, grazie per la segnalazione dettagliata.
|
||||||
|
|
||||||
|
Il messaggio **«Impossibile contattare il server»** compare quando il browser **non riesce a completare** la richiesta a `api/index.php?action=health_check`. Quindi phpinfo funziona, ma **l'endpoint API no** (404, redirect, TLS, path sbagliato, ecc.).
|
||||||
|
|
||||||
|
### Check rapidi (dalla macchina dove apri il browser)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sv "https://TUO-DOMINIO/api/index.php?action=ping"
|
||||||
|
curl -sv "https://TUO-DOMINIO/api/index.php?action=health_check"
|
||||||
|
```
|
||||||
|
|
||||||
|
Se uno dei due fallisce: DevTools → **Network** → URL esatto e **status code** della richiesta `health_check`.
|
||||||
|
|
||||||
|
### Cause frequenti con Traefik + Docker Swarm
|
||||||
|
|
||||||
|
1. **Routing incompleto** — Traefik deve inoltrare `/` **e** `/api/*`, non solo la homepage.
|
||||||
|
2. **Redirect HTTPS** — dietro Traefik serve `X-Forwarded-Proto: https`, oppure disabilitare il redirect in `.htaccess`. Nelle immagini recenti il Dockerfile imposta `SetEnvIf X-Forwarded-Proto "https" HTTPS=on`.
|
||||||
|
3. **Sottopath** — EverShelf usa URL relativi (`api/index.php`); se l'app è su `/sottocartella/`, l'URL pubblico deve essere coerente.
|
||||||
|
4. **Volume `data/`** — al primo avvio può essere quasi vuoto; assicurati permessi scrivibili:
|
||||||
|
```bash
|
||||||
|
docker exec -it CONTAINER chown -R www-data:www-data /var/www/html/data
|
||||||
|
docker exec -it CONTAINER chmod -R 775 /var/www/html/data
|
||||||
|
```
|
||||||
|
5. **`API_TOKEN` in `.env`** — se impostato, compare un prompt token (non «server non raggiungibile»).
|
||||||
|
|
||||||
|
### Per il passo successivo
|
||||||
|
|
||||||
|
Puoi condividere:
|
||||||
|
- URL pubblico esatto (con path)
|
||||||
|
- Output dei due `curl` sopra
|
||||||
|
- Screenshot Network tab su `health_check`
|
||||||
|
- Labels Traefik del servizio (router + middlewares)
|
||||||
|
|
||||||
|
Resta aperta finché non confermi che `ping`/`health_check` rispondono — poi chiudiamo insieme.
|
||||||
|
MD;
|
||||||
|
|
||||||
|
commentIssue($token, $repo, 200, $body200, $dryRun);
|
||||||
|
|
||||||
|
// ── Resolved auto-report bugs ───────────────────────────────────────────────
|
||||||
|
$bugs = [
|
||||||
|
198 => "Risolto in develop: `PRAGMA busy_timeout` portato a 10s e `dbWithRetry()` su `updateInventory` per ritentare su SQLITE_BUSY quando cron smart-shopping e PWA scrivono in parallelo.",
|
||||||
|
199 => "Duplicato di #198 — stesso evento (`inventory_update` → database locked). Fix: retry + busy_timeout aumentato.",
|
||||||
|
196 => "Risolto in v1.7.38+: `saveProduct` intercetta `UNIQUE constraint failed: products.barcode`, fa merge sul prodotto esistente o risponde 409 JSON (`barcode_already_used`) invece di HTTP 500.",
|
||||||
|
197 => "Conseguenza lato PWA del crash PHP #196 — risolto con gestione barcode duplicato in `saveProduct`.",
|
||||||
|
195 => "Risolto: `EverLog::request()` ora riceve sempre stringhe — `\$method = (string)(\$_SERVER['REQUEST_METHOD'] ?? 'GET')` (fix CLI/cron che passavano null).",
|
||||||
|
193 => "Stesso root cause di #195 (fatal TypeError su `EverLog::request` con method null da CLI). Fix già in develop.",
|
||||||
|
194 => "Risolto: `_applySpesaScanUI` usava `currentPage` (inesistente) → corretto in `_currentPageId`.",
|
||||||
|
192 => "Risolto: in `renderShoppingItems` la variabile `enriched` veniva referenziata prima della dichiarazione (TDZ). Ora `enrichedRaw` → `_dedupeShoppingByGeneric` → `enriched`.",
|
||||||
|
191 => "Risolto: in `_runStartupCheck` `setProgress` è dichiarata prima delle chiamate e `barEl` inizializzato prima dell'uso (niente più TDZ).",
|
||||||
|
134 => "Segnalazione auto-report su volume Docker non scrivibile. Mitigazioni: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile `chown www-data`. Su Swarm: `chown -R www-data:www-data data` al primo boot.",
|
||||||
|
184 => "Correlato a #134: SQLite readonly quando `data/` o `evershelf.db` non sono scrivibili. Fix operativo + chmod WAL/SHM sidecar in `_ensureDbWritable()`.",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($bugs as $num => $msg) {
|
||||||
|
commentIssue($token, $repo, $num, $msg . "\n\n_Chiuso dopo triage — fix in develop._", $dryRun);
|
||||||
|
closeIssue($token, $repo, $num, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature / enhancement backlog (close with acknowledgment) ───────────────
|
||||||
|
$features = [122, 121, 120, 119, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
|
||||||
|
$featMsg = <<<'MD'
|
||||||
|
Grazie per la proposta — è nel **backlog** del progetto.
|
||||||
|
|
||||||
|
Chiudiamo questa issue per tenere il tracker focalizzato sui bug attivi; la funzionalità resta nel radar per release future. **Riapri pure** quando vuoi lavorarci o seguirne lo sviluppo.
|
||||||
|
MD;
|
||||||
|
|
||||||
|
foreach ($features as $num) {
|
||||||
|
commentIssue($token, $repo, $num, $featMsg, $dryRun);
|
||||||
|
closeIssue($token, $repo, $num, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done.\n";
|
||||||
@@ -427,6 +427,11 @@
|
|||||||
"regen_save_new": "💾 Save to archive & generate a new one",
|
"regen_save_new": "💾 Save to archive & generate a new one",
|
||||||
"close_btn": "✅ Close",
|
"close_btn": "✅ Close",
|
||||||
"ingredients_title": "🧾 Ingredients",
|
"ingredients_title": "🧾 Ingredients",
|
||||||
|
"shopping_suggestions_intro": "For an alternative version you'd need (not in pantry — optional):",
|
||||||
|
"shopping_suggestions_add": "Add to shopping list",
|
||||||
|
"shopping_suggestions_added": "Added to shopping list",
|
||||||
|
"unit_for_input": "Unit of measure",
|
||||||
|
"enter_in": "Enter value in",
|
||||||
"tools_title": "Equipment needed",
|
"tools_title": "Equipment needed",
|
||||||
"steps_title": "👨🍳 Steps",
|
"steps_title": "👨🍳 Steps",
|
||||||
"no_steps": "No steps available",
|
"no_steps": "No steps available",
|
||||||
|
|||||||
@@ -427,6 +427,12 @@
|
|||||||
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
|
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
|
||||||
"close_btn": "✅ Chiudi",
|
"close_btn": "✅ Chiudi",
|
||||||
"ingredients_title": "🧾 Ingredienti",
|
"ingredients_title": "🧾 Ingredienti",
|
||||||
|
"shopping_suggestions_intro": "Per una variante servirebbe (non in dispensa — opzionale):",
|
||||||
|
"shopping_suggestions_add": "Aggiungi alla lista spesa",
|
||||||
|
"shopping_suggestions_added": "Aggiunto alla lista spesa",
|
||||||
|
"frozen_badge": "surgelato — dal freezer",
|
||||||
|
"unit_for_input": "Unità di misura",
|
||||||
|
"enter_in": "Inserimento in",
|
||||||
"tools_title": "Strumenti necessari",
|
"tools_title": "Strumenti necessari",
|
||||||
"steps_title": "👨🍳 Procedimento",
|
"steps_title": "👨🍳 Procedimento",
|
||||||
"no_steps": "Nessun procedimento disponibile",
|
"no_steps": "Nessun procedimento disponibile",
|
||||||
|
|||||||
Reference in New Issue
Block a user