Compare commits

...

21 Commits

Author SHA1 Message Date
dadaloop82 85ccdaa6f6 Release v1.7.42: shopping totals, waste learning, stability fixes.
Document waste reason picker, stable price estimates, DB retry, and kiosk CI fixes in CHANGELOG and README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 12:50:13 +00:00
dadaloop82 16993135b9 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-06-14 12:43:44 +00:00
dadaloop82 d1716fa6ff Fix shopping estimates, waste reasons, and recurring DB/timeouts.
Price each list line as one retail purchase; learn from discard reasons to cap restock suggestions. Retry inventory_use/shopping_add on SQLITE_BUSY; extend smart_shopping time limit. Reopen feature issues #98/#125; close auto-report bugs #201–#204.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 12:43:03 +00:00
github-actions[bot] 3ac42f7767 chore(kiosk): publish APK v1.7.19 for LAN OTA 2026-06-11 05:48:36 +00:00
dadaloop82 eb19265586 Kiosk: auto-discover on setup, LAN OTA, English-only GitHub triage.
Auto-run LAN discovery on server step; serve kiosk updates from releases/ via kiosk_update API; check LAN before GitHub for OTA in-place upgrades. Docker CI retries hub timeouts. Remove non-English feature issue comments; triage script English-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 05:46:12 +00:00
dadaloop82 8a69e6d941 Fix kiosk LAN discovery and improve OTA update detection.
Discovery no longer aborts after 3s idle, probes priority hosts (.128, gateway) first, accepts ping API and normalizes HTTPS URLs. OTA compares versionCode from release notes; bump kiosk to 1.7.18.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 05:40:41 +00:00
dadaloop82 c5b0dbcf42 Fix inflated shopping price estimates and restore feature issues workflow.
Price each list line as one retail purchase instead of 14-day restock qty; convert €/kg AI prices to estimated piece weight; cap smart-shopping suggested conf/pz counts. Stop triage script from bulk-closing feature backlog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 05:34:56 +00:00
dadaloop82 338bd7ff66 Fix Kotlin string escaping in SetupActivity finishSetup JSON.
Correct broken replace() literals that prevented compileDebugKotlin from building.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:01:43 +00:00
dadaloop82 c7532f90cd Fix kiosk locale strings for Android resource compiler.
Escape apostrophes and normalize multiline strings in de/es/fr/it so assembleDebug no longer fails with invalid unicode escape sequences.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:54:20 +00:00
dadaloop82 5831e3bcea Release v1.7.41: fix Traefik startup and clean JSON API responses.
PHP 8.2 deprecations no longer corrupt health_check JSON; .htaccess
respects X-Forwarded-Proto behind reverse proxies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:32:10 +00:00
dadaloop82 ec1aae2a25 docs: note ops scripts in CHANGELOG 1.7.40
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 06:04:09 +00:00
dadaloop82 4f9f44e230 Add ops scripts and offline transformers bootstrap.
Maintenance CLI for finished-product/shopping audit, Bring sync, and
install-transformers-model.sh to fetch the Xenova classifier (model gitignored).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 06:03:24 +00:00
dadaloop82 9be8fb5cf3 Release v1.7.40: recipe fixes, DB lock retry, and Docker Traefik support.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 06:00:54 +00:00
github-actions[bot] 00b1c35665 chore: auto-merge develop → main
Triggered by: 5dd3bae Release v1.7.39: faster barcode lookup, spesa UX, and expiry control.
2026-06-06 10:21:21 +00:00
dadaloop82 5dd3baea5d Release v1.7.39: faster barcode lookup, spesa UX, and expiry control.
Parallel resolve_barcode with SQLite cache speeds bulk shopping scans; spesa mode skips to add form. Manual expiry dates persist across location moves; family sibling checks dedupe for 24h. Fixes kiosk crashes, empty barcode UNIQUE errors, and spesa ghost products.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 10:19:39 +00:00
github-actions[bot] 9d3cf05496 chore: auto-merge develop → main
Triggered by: 34dcb05 Improve scan flow, AI UX, expiry history, and shopping sync.
2026-06-06 09:39:52 +00:00
dadaloop82 34dcb05c05 Improve scan flow, AI UX, expiry history, and shopping sync.
Manual AI identification replaces auto-fallback; add duplicate-add guard,
AI product match UI, ZBar/Tesseract offline scanning, expiry averages from
last 3 insertions, family sibling hints, and missing i18n keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 09:38:05 +00:00
github-actions[bot] dea1223faf chore: auto-merge develop → main
Triggered by: 7eda4a5 Release v1.7.38: stable shopping total and finished-product Bring sync.
2026-06-04 18:12:09 +00:00
dadaloop82 7eda4a5eb9 Release v1.7.38: stable shopping total and finished-product Bring sync.
Add depleted products under generic shopping names, unify weekly canonical price total across all surfaces, and fix screensaver amount mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 18:10:24 +00:00
github-actions[bot] e72e57edf6 chore: auto-merge develop → main
Triggered by: b63deca Release v1.7.37: strict recipe pantry matching and renderRecipe fix.
2026-06-04 17:39:57 +00:00
dadaloop82 b63deca795 Release v1.7.37: strict recipe pantry matching and renderRecipe fix.
Prevent false  pantry links via strict name matching and full inventory prompts; fix qtyNum crash when reopening archived recipes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:38:12 +00:00
49 changed files with 7448 additions and 2274 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.git/
dist/
build/
*.log
+18 -2
View File
@@ -37,8 +37,10 @@ jobs:
id: version
run: |
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
VCODE=$(grep 'versionCode' evershelf-kiosk/app/build.gradle.kts | grep -oP '\d+')
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
echo "Kiosk version: $VERSION"
echo "code=$VCODE" >> "$GITHUB_OUTPUT"
echo "Kiosk version: $VERSION (versionCode $VCODE)"
- name: Build debug APK
run: gradle assembleDebug --no-daemon
@@ -75,7 +77,21 @@ jobs:
sleep 3
gh release create kiosk-latest \
--title "EverShelf Kiosk Latest" \
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
--prerelease \
artifacts/evershelf-kiosk.apk
- name: Publish APK to releases/ for LAN OTA
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
run: |
cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk
printf '{"version":"%s","version_code":%s}\n' \
"${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \
> releases/kiosk-version.json
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add releases/evershelf-kiosk.apk releases/kiosk-version.json
git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA"
git push origin HEAD:${{ github.ref_name }}
+12 -1
View File
@@ -43,7 +43,18 @@ jobs:
- uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t evershelf-test .
run: |
set -e
for attempt in 1 2 3; do
echo "Docker build attempt $attempt/3..."
if docker build -t evershelf-test .; then
exit 0
fi
echo "Attempt $attempt failed — retrying in 20s..."
sleep 20
done
echo "Docker build failed after 3 attempts"
exit 1
- name: Test container starts
run: |
+1
View File
@@ -52,3 +52,4 @@ data/food_facts_cache.json
data/category_ai_cache.json
assets/img/logo/*_backup.*
logs/*.log
assets/vendor/transformers/Xenova/
+2 -1
View File
@@ -14,8 +14,9 @@ RewriteEngine On
Require all denied
</FilesMatch>
# Force HTTPS
# Force HTTPS (skip when terminated TLS is forwarded — Traefik, Caddy, NPM, …)
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# API routing
+84
View File
@@ -11,6 +11,90 @@ 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.
## [1.7.42] - 2026-06-11
### Added
- **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES.
- **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage.
- **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments.
### Fixed
- **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context.
- **SQLite database locked (#201#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`.
- **Smart shopping timeout (#203#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories.
- **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`.
- **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented.
## [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
### Added
- **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans.
- **Spesa barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page).
- **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip.
- **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated.
- **Family sibling 24h dedup** — After confirming “Sì, tutto ok” on a similar in-stock product, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings).
- **Family sibling stock line** — Spesa prompt shows readable stock (e.g. `4 conf (da 20g)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES.
- **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page.
### Fixed
- **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched``enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes.
- **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included).
- **Spesa ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity.
- **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned.
- **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay.
- **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after spesa add.
### Changed
- **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~12s first hit); 30-minute negative cache for unknown codes.
- **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes.
## [1.7.38] - 2026-06-04
### Fixed
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
### Changed
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
## [1.7.37] - 2026-06-04
### Fixed
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
### Changed
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
## [1.7.36] - 2026-06-04
### Added
+3 -1
View File
@@ -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\
AllowOverride All\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
# Expose port 80
+12 -1
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.36-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.42-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
@@ -40,6 +40,17 @@
---
### 🆕 Release 1.7.42 (2026-06-11)
- **Stable shopping total** — Each list item is priced as one typical purchase (no more inflated totals from 14-day restock quantities or €/kg × piece count).
- **Waste reason picker** — When discarding food, choose why (expired, wrong storage, bought too much, …); EverShelf learns and adjusts restock suggestions and storage hints.
- **Fewer SQLite lock errors** — `inventory_use` and `shopping_add` retry on `SQLITE_BUSY`; smart shopping gets a longer PHP time limit on large pantries.
- **Android kiosk** — Locale string escaping fix; setup wizard JSON save fix (CI build).
See [CHANGELOG.md](CHANGELOG.md) for full details.
---
## ✨ Features
### 🏠 NEW — Home Assistant Integration
+5
View File
@@ -2,6 +2,11 @@
/**
* 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/constants.php';
require_once __DIR__ . '/lib/github.php';
+24 -3
View File
@@ -44,9 +44,10 @@ try {
$itemCount = count($decoded['items'] ?? []);
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
// ── Bring! server-side cleanup ────────────────────────────────────────
// After computing smart shopping, automatically remove stale Bring! items
// and add/update critical ones. This runs fully server-side every cron cycle.
// ── Bring! server-side sync ───────────────────────────────────────────
// After computing smart shopping, remove stale Bring! items and push every
// product that needs restocking (esauriti, quasi finiti, previsione).
// Runs fully server-side every cron cycle (~5 min).
try {
$cleanupResult = bringCleanupObsolete($db);
if (isset($cleanupResult['skipped'])) {
@@ -57,6 +58,21 @@ try {
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
}
$dedupeResult = bringDedupeGenerics($db);
if (isset($dedupeResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe skipped: ' . $dedupeResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe — removed: ' . ($dedupeResult['removed'] ?? 0)
. ', merged specs: ' . ($dedupeResult['merged'] ?? 0) . "\n";
}
$specsResult = bringSyncSpecs($db);
if (isset($specsResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs skipped: ' . $specsResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs — updated: ' . ($specsResult['updated'] ?? 0) . "\n";
}
$addResult = bringAutoAddCritical($db);
if (isset($addResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
@@ -64,6 +80,11 @@ try {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
}
$dedupeFinal = bringDedupeGenerics($db);
if (!isset($dedupeFinal['skipped']) && (($dedupeFinal['removed'] ?? 0) > 0)) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe (final) — removed: ' . ($dedupeFinal['removed'] ?? 0) . "\n";
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
}
+60 -1
View File
@@ -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 {
_ensureDataDir();
_ensureDbWritable();
// logger.php is required by index.php before getDB() is called.
// In cron context it may not be loaded yet — guard with class_exists.
$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.
// For SQLite, we use PRAGMA busy_timeout.
$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->exec("PRAGMA journal_mode=WAL");
@@ -72,6 +88,29 @@ function getDB(): PDO {
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 {
$db->exec("
CREATE TABLE IF NOT EXISTS products (
@@ -148,6 +187,24 @@ function migrateDB(PDO $db): void {
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
// Empty barcode strings break UNIQUE (only one '' allowed); normalize to NULL.
$db->exec("UPDATE products SET barcode = NULL WHERE barcode IS NOT NULL AND TRIM(barcode) = ''");
$invCols = $db->query("PRAGMA table_info(inventory)")->fetchAll();
$invColNames = array_column($invCols, 'name');
if (!in_array('expiry_user_set', $invColNames)) {
try { $db->exec("ALTER TABLE inventory ADD COLUMN expiry_user_set INTEGER DEFAULT 0"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
$db->exec("CREATE TABLE IF NOT EXISTS barcode_cache (
barcode TEXT PRIMARY KEY,
found INTEGER NOT NULL DEFAULT 0,
source TEXT,
payload TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)");
// Migrate transactions CHECK constraint to allow 'waste' type
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
if ($sql && strpos($sql, "'waste'") === false) {
@@ -443,6 +500,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) return 7;
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
@@ -520,6 +578,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
elseif (preg_match('/uova/', $n)) $days = 28;
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
elseif (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) $days = 7;
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
+3218 -1422
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -155,8 +155,8 @@ function evershelfSendCorsHeaders(): void {
function evershelfDemoReadOnlyActions(): array {
return [
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
'search_barcode', 'lookup_barcode', 'stock_for_name',
'product_get', 'products_list', 'products_search', 'inventory_search',
'search_barcode', 'lookup_barcode', 'resolve_barcode', 'stock_for_name',
'product_get', 'products_list', 'products_search', 'inventory_search', 'ai_product_suggest',
'inventory_list', 'inventory_summary', 'inventory_finished_items',
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
+1
View File
@@ -335,6 +335,7 @@ class LoggingPDOStatement {
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
// ═══════════════════════════════════════════════════════════════════════════
class LoggingPDO extends \PDO {
#[\ReturnTypeWillChange]
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
$stmt = parent::prepare($query, $options);
if ($stmt === false) {
+266 -21
View File
@@ -666,6 +666,126 @@ body.server-offline .bottom-nav {
flex-shrink: 0;
}
/* Family sibling hint (spesa mode) — compact card with thumbnail */
.family-sibling-prompt {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 9998;
background: linear-gradient(145deg, #1e3a5f 0%, #0f2744 100%);
color: #fff;
border-radius: 14px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
max-width: min(440px, calc(100vw - 20px));
width: calc(100% - 20px);
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.12);
animation: family-sibling-in 0.22s ease-out;
}
@keyframes family-sibling-in {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.family-sibling-prompt-body {
display: flex;
gap: 10px;
align-items: flex-start;
}
.family-sibling-prompt-thumb {
flex-shrink: 0;
width: 52px;
height: 52px;
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.family-sibling-prompt-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.family-sibling-prompt-icon {
font-size: 1.6rem;
line-height: 1;
}
.family-sibling-prompt-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.family-sibling-prompt-title {
font-size: 0.72rem;
font-weight: 700;
line-height: 1.2;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.85);
}
.family-sibling-prompt-name {
font-size: 0.95rem;
font-weight: 700;
line-height: 1.25;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.family-sibling-prompt-stock {
font-size: 1.05rem;
font-weight: 800;
line-height: 1.3;
margin: 4px 0 0;
color: #bbf7d0;
}
.family-sibling-prompt-meta {
font-size: 0.8rem;
line-height: 1.3;
margin: 0;
color: rgba(255, 255, 255, 0.78);
}
.family-sibling-prompt-question {
font-size: 0.82rem;
line-height: 1.25;
margin: 2px 0 0;
color: #f8fafc;
font-weight: 600;
}
.family-sibling-prompt-actions {
display: flex;
gap: 8px;
}
.family-sibling-prompt-actions button {
flex: 1;
border: none;
border-radius: 10px;
padding: 9px 10px;
font-size: 0.88rem;
font-weight: 700;
cursor: pointer;
min-height: 38px;
}
.family-sibling-prompt-yes {
background: #22c55e;
color: #fff;
}
.family-sibling-prompt-no {
background: #475569;
color: #fff;
}
@keyframes pulse-scan {
0%, 100% { box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
50% { box-shadow: 0 2px 16px rgba(255,255,255,0.4); }
@@ -1727,6 +1847,41 @@ body.server-offline .bottom-nav {
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 {
display: flex;
@@ -1845,6 +2000,19 @@ body.server-offline .bottom-nav {
overflow: hidden;
}
/* Spesa mode: hide manual barcode field only — tabs and normal viewport stay */
#page-scan.spesa-scan-layout .barcode-input-row {
display: none !important;
}
.spesa-scan-barcode-hint {
margin: 0;
padding: 6px 4px 2px;
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
line-height: 1.35;
}
.scanner-viewport video {
width: 100%;
height: 100%;
@@ -2046,21 +2214,25 @@ body.server-offline .bottom-nav {
max-width: 220px;
}
/* — AI retry button (shown below scanner after visual ID fails) — */
.scan-ai-retry-btn {
/* — Manual AI button (user-triggered only) — */
.scan-ai-manual-btn {
width: 100%;
margin-top: 10px;
font-size: 0.95rem;
padding: 12px;
border-radius: var(--radius);
font-size: 1.05rem;
padding: 14px 16px;
border-radius: 14px;
border: 2px solid var(--accent);
background: rgba(124,58,237,0.1);
background: rgba(124,58,237,0.12);
color: var(--accent);
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, opacity 0.15s;
}
.scan-ai-retry-btn:active { background: rgba(124,58,237,0.22); }
.scan-ai-manual-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.scan-ai-manual-btn:active:not(:disabled) { background: rgba(124,58,237,0.22); }
/* — Viewport overlay controls (torch / zoom / flip) — */
.scan-viewport-controls {
@@ -2115,17 +2287,59 @@ body.server-offline .bottom-nav {
.scan-ai-match-box {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
}
.scan-ai-match-head {
.scan-ai-hero {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
gap: 14px;
padding: 14px;
background: linear-gradient(135deg, rgba(124,58,237,0.12), rgba(34,197,94,0.1));
border-radius: 14px;
border: 1px solid var(--border);
}
.scan-ai-hero-icon {
font-size: 2.6rem;
line-height: 1;
flex-shrink: 0;
}
.scan-ai-hero-text {
min-width: 0;
flex: 1;
}
.scan-ai-match-title {
font-size: 1rem;
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 4px;
}
.scan-ai-hero-name {
font-size: 1.35rem;
font-weight: 800;
line-height: 1.2;
color: var(--text);
word-break: break-word;
}
.scan-ai-hero-brand {
font-size: 1rem;
color: var(--text-muted);
margin-top: 2px;
}
.scan-ai-action-hint {
margin: -6px 0 0;
font-size: 0.92rem;
line-height: 1.4;
color: var(--text-muted);
text-align: center;
}
.scan-ai-or-divider {
margin: 0;
font-size: 0.88rem;
font-weight: 700;
color: var(--text-muted);
text-align: center;
}
.scan-ai-match-subtitle {
font-size: 0.82rem;
@@ -2137,9 +2351,9 @@ body.server-offline .bottom-nav {
gap: 8px;
}
.scan-ai-match-list-title {
font-size: 0.78rem;
font-size: 0.88rem;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.04em;
color: var(--text-muted);
font-weight: 700;
}
@@ -2160,6 +2374,22 @@ body.server-offline .bottom-nav {
text-align: left;
}
.scan-ai-candidate-item:active { transform: scale(0.99); }
.scan-ai-candidate-thumb {
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
background: var(--bg-card);
display: flex;
align-items: center;
justify-content: center;
}
.scan-ai-candidate-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.scan-ai-candidate-icon {
font-size: 1.3rem;
flex-shrink: 0;
@@ -2172,26 +2402,27 @@ body.server-offline .bottom-nav {
flex: 1;
}
.scan-ai-candidate-name {
font-size: 0.9rem;
font-weight: 600;
font-size: 1rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-meta {
font-size: 0.76rem;
font-size: 0.84rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-cta {
font-size: 0.74rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 3px 8px;
padding: 6px 10px;
flex-shrink: 0;
}
.scan-ai-match-empty {
@@ -2204,6 +2435,12 @@ body.server-offline .bottom-nav {
}
.scan-ai-add-btn {
width: 100%;
font-size: 1.15rem !important;
font-weight: 800 !important;
padding: 16px 20px !important;
min-height: 54px;
border-radius: 14px !important;
box-shadow: 0 4px 14px rgba(34, 197, 94, 0.35);
}
.scan-ai-detected-label {
font-size: 0.72rem;
@@ -3098,6 +3335,14 @@ body.server-offline .bottom-nav {
font-style: italic;
}
.shopping-item-specific {
font-size: 0.73rem;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.3;
font-style: italic;
}
.smart-brand {
font-weight: 400;
color: var(--text-muted);
+1896 -630
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+26
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 35
versionCode = 18
versionName = "1.7.17"
versionCode = 20
versionName = "1.7.19"
}
signingConfigs {
@@ -643,6 +643,79 @@ class KioskActivity : AppCompatActivity() {
webView.evaluateJavascript("$jsCallback($escaped)", null)
}
}
val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
val installedVc: Long = try {
val pi = packageManager.getPackageInfo(packageName, 0)
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
else @Suppress("DEPRECATION") pi.versionCode.toLong()
} catch (_: Exception) { -1L }
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
for (i in 0 until maxOf(r.size, l.size)) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when {
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) ->
semverNewer(remoteVersion, currentKiosk)
else -> false
}
fun applyUpdate(remoteVersion: String, apkUrl: String) {
val result = JSONObject()
.put("has_update", true)
.put("current", currentKiosk)
.put("latest", remoteVersion)
.put("apk_url", apkUrl)
notifyJs(result)
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, remoteVersion)
.putString(KEY_PENDING_UPDATE_URL, apkUrl)
.apply()
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk$remoteVersion", apkUrl) }
}
// 1) Prefer LAN/self-hosted update (no GitHub required)
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/')
if (baseUrl.isNotEmpty()) {
try {
val localApi = "$baseUrl/api/index.php?action=kiosk_update"
val conn = openTrustedConnection(localApi)
conn.connectTimeout = 5000
conn.readTimeout = 5000
if (conn.responseCode == 200) {
val localJson = JSONObject(conn.inputStream.bufferedReader().readText())
conn.disconnect()
if (localJson.optBoolean("success")) {
val remoteVersion = localJson.optString("version", "")
val remoteVc = localJson.optLong("version_code", -1L)
val apkUrl = localJson.optString("apk_url", "")
if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) {
applyUpdate(remoteVersion, apkUrl)
return@Thread
}
if (!needsUpdate(remoteVersion, remoteVc)) {
notifyJs(JSONObject().put("has_update", false).put("source", "local"))
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread
}
}
} else conn.disconnect()
} catch (_: Exception) { /* fall through to GitHub */ }
}
// 2) GitHub release fallback (requires internet)
try {
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
@@ -657,43 +730,16 @@ class KioskActivity : AppCompatActivity() {
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "")
if (latestTag.isEmpty()) {
notifyJs(JSONObject().put("has_update", false).put("error", "no tag"))
return@Thread
}
val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
// Extract the actual kiosk version from the release body text.
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
// Fall back to stripping the tag prefix if body parsing fails.
val bodyText = json.optString("body", "")
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
.find(bodyText)?.groupValues?.get(1)
?.takeIf { it.isNotEmpty() }
?: norm(latestTag)
?: norm(json.optString("tag_name", ""))
// Compare semver: returns true if `remote` is strictly greater than `local`
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val len = maxOf(r.size, l.size)
for (i in 0 until len) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
val assets = json.optJSONArray("assets")
var kioskApkUrl = ""
if (assets != null) {
@@ -707,38 +753,35 @@ class KioskActivity : AppCompatActivity() {
}
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
// Only flag an update when the remote version is parseable as semver AND
// strictly greater than the installed version.
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
semverNewer(remoteKioskVersion, currentKiosk)
val result = JSONObject()
.put("has_update", kioskNeedsUpdate)
.put("current", currentKiosk)
.put("latest", remoteKioskVersion)
.put("apk_url", kioskApkUrl)
notifyJs(result)
if (!kioskNeedsUpdate) {
// Clear any stale pending update if the current version is now up to date
if (!needsUpdate(remoteKioskVersion, remoteVc)) {
notifyJs(JSONObject().put("has_update", false))
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread
}
// Persist the pending update so the banner reappears after a crash/restart
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
.apply()
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk$remoteKioskVersion", kioskApkUrl) }
applyUpdate(remoteKioskVersion, kioskApkUrl)
} catch (e: Exception) {
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
}
}.start()
}
/** HTTPS with self-signed cert support (LAN servers). */
private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection {
val conn = URL(urlStr).openConnection()
if (conn is javax.net.ssl.HttpsURLConnection) {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : javax.net.ssl.X509TrustManager {
override fun checkClientTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
override fun checkServerTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
conn.sslSocketFactory = sc.socketFactory
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
return conn as java.net.HttpURLConnection
}
/**
* On resume: if a previous session detected an available update and saved it to prefs,
* restore the update banner immediately without a network round-trip.
@@ -540,6 +540,11 @@ class SetupActivity : AppCompatActivity() {
// Cancel auto-discover when leaving server step
if (step != 3) discoverCancelled.set(true)
// Auto-discover when entering server step (empty URL only)
if (step == 3 && urlEdit.text.toString().trim().isEmpty()) {
autoDiscover()
}
// Scroll to top
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
}
@@ -697,6 +702,58 @@ class SetupActivity : AppCompatActivity() {
})
}
private fun normalizeDiscoveredBase(urlStr: String): String {
var base = urlStr.substringBefore("/api/")
if (base.endsWith(":443")) base = base.removeSuffix(":443")
if (base.endsWith(":80")) base = base.removeSuffix(":80")
return if (base.endsWith("/")) base else "$base/"
}
private fun probeEverShelfEndpoint(urlStr: String): String? {
return try {
val conn = openConn(urlStr) ?: return null
val code = conn.responseCode
if (code !in 200..399) {
conn.disconnect()
return null
}
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
if (body.contains("gemini_key_set") || body.contains("\"success\"") || body.contains("\"ok\"")) {
normalizeDiscoveredBase(urlStr)
} else null
} catch (_: Exception) {
null
}
}
private fun probeEverShelfHost(ip: String, port: Int): String? {
val reachable = try {
Socket().use { s -> s.connect(InetSocketAddress(ip, port), 800); true }
} catch (_: Exception) {
false
}
if (!reachable) return null
val scheme = if (port == 443 || port == 8443) "https" else "http"
val portInUrl = when {
scheme == "https" && port == 443 -> ""
scheme == "http" && port == 80 -> ""
else -> ":$port"
}
val paths = listOf(
"/dispensa/api/index.php?action=ping",
"/api/index.php?action=ping",
"/dispensa/api/index.php?action=get_settings",
"/api/index.php?action=get_settings",
"/evershelf/api/index.php?action=get_settings",
)
for (path in paths) {
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return it }
}
return null
}
private fun openConn(urlStr: String): HttpURLConnection? {
return try {
val conn = URL(urlStr).openConnection()
@@ -772,9 +829,52 @@ class SetupActivity : AppCompatActivity() {
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
val ports = listOf(443, 80, 8080, 8443)
// ── 1b. Fast path: likely hosts on Wi-Fi subnet (incl. .128) before full sweep ─
val priorityIps = linkedSetOf<String>()
try {
val ifaces = NetworkInterface.getNetworkInterfaces()
while (ifaces != null && ifaces.hasMoreElements()) {
val intf = ifaces.nextElement()
if (!intf.isUp || intf.isLoopback) continue
for (addr in intf.interfaceAddresses) {
val ip = addr.address
if (ip is java.net.Inet4Address && !ip.isLoopbackAddress) {
priorityIps.add(ip.hostAddress ?: continue)
}
}
}
} catch (_: Exception) {}
for (subnet in wifiSubnets.ifEmpty { subnets.take(1) }) {
for (last in listOf(1, 128, 100, 10, 50, 254)) {
priorityIps.add("$subnet.$last")
}
}
runOnUiThread { discoverStatus.text = "🔍 ${getString(R.string.setup_discovering_detail)}" }
for (ip in priorityIps) {
if (discoverCancelled.get()) break
for (port in ports) {
val hit = probeEverShelfHost(ip, port)
if (hit != null) {
runOnUiThread {
urlEdit.setText(hit)
discoverStatus.text = "${getString(R.string.setup_server_found)}: $hit"
discoverStatus.setTextColor(0xFF34d399.toInt())
showUrlStatus("${getString(R.string.setup_server_found)}", true)
btnDiscover.isEnabled = true
btnDiscover.text = getString(R.string.setup_discover_btn)
}
return@Thread
}
}
}
val paths = listOf(
"/api/index.php?action=get_settings",
"/dispensa/api/index.php?action=ping",
"/api/index.php?action=ping",
"/dispensa/api/index.php?action=get_settings",
"/api/index.php?action=get_settings",
"/evershelf/api/index.php?action=get_settings",
)
@@ -819,30 +919,24 @@ class SetupActivity : AppCompatActivity() {
// Full HTTP probe on reachable host
val scheme = if (port == 443 || port == 8443) "https" else "http"
val portInUrl = when {
scheme == "https" && port == 443 -> ""
scheme == "http" && port == 80 -> ""
else -> ":$port"
}
for (path in paths) {
if (discoverCancelled.get() || found.get()) break
val urlStr = "$scheme://$ip:$port$path"
try {
val conn = openConn(urlStr) ?: continue
val code = conn.responseCode
if (code in 200..399) {
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
return@submit urlStr.substringBefore("/api/") + "/"
}
} else conn.disconnect()
} catch (_: Exception) {}
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it }
}
null
}
}
// ── 3. Collect results as they complete (not in submission order) ────
// ── 3. Collect results until all tasks finish or a server is found ────
var result: String? = null
var collected = 0
while (collected < total && !discoverCancelled.get()) {
val future = cs.poll(3, TimeUnit.SECONDS) ?: break
while (collected < total && !discoverCancelled.get() && result == null) {
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue
collected++
val r = try { future.get() } catch (_: Exception) { null }
if (r != null && found.compareAndSet(false, true)) {
@@ -1101,9 +1195,9 @@ class SetupActivity : AppCompatActivity() {
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
}
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"")
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"")
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"")
append("}")
}
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_detail">Verbindung prüfen und erneut versuchen.</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="btn_back">Zurück</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_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_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
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_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>
<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_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.
Bring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
<string name="setup_bring_pass_hint">Bring!-Passwort</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_detail">Comprueba la conexión e inténtalo de nuevo.</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="btn_back">Atrás</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_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_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
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_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>
<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_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.
Introduce tus credenciales de Bring! para activar la integración.</string>
<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>
<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_done_title">¡Todo listo!</string>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_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_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_perms_granted_next">✅ Permissions accordées — Continuer →</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_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_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_cancel">Continuer</string>
<string name="setup_step_back">← Retour</string>
@@ -22,20 +22,20 @@
<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_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="ble_scanning">🔍 Scan en cours…</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_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_not_confirmed">Balance non confirmée. Relancer le scan.</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_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_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_up_to_date">Service BLE de la balance prêt.</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_detail">Veuillez patienter, le fichier est en cours de téléchargement.</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_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_detail">Vérifiez la connexion et réessayez.</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="btn_back">Retour</string>
<string name="btn_launch">🚀 Lancer EverShelf</string>
@@ -58,13 +58,13 @@
<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_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_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_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_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_hint">Estimation automatique du coût de chaque article via IA.</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_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_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
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_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>
<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_desc">EverShelf peut synchroniser votre liste de courses avec l'app Bring!.
Entrez vos identifiants Bring! pour activer l'intégration.</string>
<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>
<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_done_title">Tout est prêt !</string>
@@ -10,9 +10,9 @@
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
<string name="setup_discovering">Scansione in corso…</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_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_cancel">Continua</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_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_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_scan_again">🔄 Scansiona di nuovo</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_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_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_up_to_date">Servizio BLE bilancia pronto.</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_detail">Attendi, il file viene scaricato.</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_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_detail">Controlla la connessione e riprova.</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="btn_back">Indietro</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_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_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_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_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_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</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_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_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
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_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>
<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_desc">EverShelf può sincronizzare la lista della spesa con l'app Bring!.
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
<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>
<string name="setup_bring_email_hint">Email Bring!</string>
<string name="setup_bring_pass_hint">Password Bring!</string>
<string name="setup_done_title">Tutto pronto!</string>
+42 -23
View File
@@ -11,11 +11,21 @@
<title>EverShelf</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260603a">
<link rel="stylesheet" href="assets/css/style.css?v=20260606m">
<!-- Core modules (auth, DOM helpers) -->
<script src="assets/js/core/dom.js?v=20260603a"></script>
<script src="assets/js/core/auth.js?v=20260603b"></script>
<!-- QuaggaJS — local vendor with CDN fallback -->
<!-- ZBar WASM — lazy on kiosk WebView (OOM); eager elsewhere -->
<script>
(function () {
var kioskWv = /; wv\)/.test(navigator.userAgent);
if (kioskWv) return;
document.write('<script src="assets/vendor/zbar/index.js?v=20260606a"><\/script>');
document.write('<script>if(window.zbarWasm&&zbarWasm.setModuleArgs){zbarWasm.setModuleArgs({locateFile:function(f){return"assets/vendor/zbar/"+f}});}<\/script>');
document.write('<script src="assets/vendor/zbar/polyfill.js?v=20260606a"><\/script>');
})();
</script>
<!-- QuaggaJS — legacy last-resort only -->
<script src="assets/vendor/quagga/quagga.min.js?v=20260603a"></script>
<script>if(typeof Quagga==='undefined'){document.write('<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"><\\/script>');}</script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
@@ -31,13 +41,25 @@
try {
const localBase = 'assets/vendor/transformers/';
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/';
const modelProbe = localBase + 'Xenova/all-MiniLM-L6-v2/tokenizer.json';
let pipeline, env;
try {
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
} catch (_) {
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
}
env.localModelPath = localBase;
// Use bundled model files when present; otherwise HuggingFace CDN (no 404 spam)
let localModels = false;
try {
const r = await fetch(modelProbe, { method: 'HEAD' });
localModels = r.ok;
} catch (_) {}
if (localModels) {
env.localModelPath = localBase;
env.allowLocalModels = true;
} else {
env.allowLocalModels = false;
}
env.allowRemoteModels = true;
env.useBrowserCache = true;
const pipe = await pipeline(
@@ -72,7 +94,7 @@
<div id="preloader-warnings" class="preloader-warnings" 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>
<span class="app-preloader-version" id="preloader-version">v1.7.36</span>
<span class="app-preloader-version" id="preloader-version">v1.7.42</span>
</div>
</div>
@@ -85,7 +107,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<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.36</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.42</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -268,7 +290,7 @@
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
<div class="scan-ai-overlay-inner">
<div class="loading-spinner"></div>
<span class="scan-ai-overlay-label">Gemini Vision</span>
<span class="scan-ai-overlay-label" data-i18n="scan.ai_overlay_label">Gemini Vision</span>
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
</div>
</div>
@@ -290,8 +312,8 @@
<!-- Scan errors -->
<div class="scan-result" id="scan-result" style="display:none"></div>
<!-- AI retry button (shown after visual identification fails) -->
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
<!-- Manual AI identification (only when user taps — never automatic) -->
<button class="btn btn-accent scan-ai-manual-btn" id="scan-ai-manual-btn" type="button" style="display:none" onclick="_triggerManualAiScan()" data-i18n="scan.ai_manual_btn">🤖 Identifica con AI</button>
<!-- Recent scans -->
<div class="scan-recents" id="scan-recents" style="display:none">
@@ -398,6 +420,7 @@
<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>
</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()">
<option value="pz">pz</option>
<option value="conf">conf</option>
@@ -477,11 +500,14 @@
</button>
<div class="use-partial">
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
<div class="qty-control">
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)"></button>
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
<div class="qty-control-with-unit">
<div class="qty-control">
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)"></button>
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
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>
<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>
@@ -1203,14 +1229,7 @@
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
</div>
<div class="form-group" style="margin-top:14px">
<label class="toggle-row">
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-barcode-ai-fallback">
<span class="toggle-slider"></span>
</span>
</label>
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
<p class="settings-hint" data-i18n="settings.camera.ai_manual_hint">Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato.</p>
</div>
</div>
</div>
@@ -1860,7 +1879,7 @@
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p>
<button class="btn btn-large btn-warning full-width" onclick="doRegenerateReplace()" data-i18n="recipes.regen_replace">🔄 Genera un'altra (scarta questa)</button>
<button class="btn btn-large btn-success full-width mt-2" onclick="doRegenerateSave()" data-i18n="recipes.regen_save_new">💾 Salva nell'archivio e genera nuova</button>
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="action.cancel">Annulla</button>
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="confirm.cancel">Annulla</button>
</div>
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
✅ Chiudi
@@ -1970,6 +1989,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260604c"></script>
<script src="assets/js/app.js?v=20260606z"></script>
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.36",
"version": "1.7.42",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version":"1.7.19","version_code":20}
+163
View File
@@ -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";
+65
View File
@@ -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";
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env php
<?php
/** Delete all comments on open feature/enhancement backlog issues (English-only tracker policy). */
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';
$token = _ghToken();
if ($token === '') {
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
exit(1);
}
function ghRequest(string $token, string $method, string $url, ?array $body = null): 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',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
} elseif ($method === 'GET') {
// default
}
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $code, 'body' => $raw];
}
$issues = [122, 121, 120, 119, 118, 117, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
$deleted = 0;
foreach ($issues as $num) {
$page = 1;
while (true) {
$url = 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments?per_page=100&page=$page";
$r = ghRequest($token, 'GET', $url);
if ($r['code'] !== 200) {
fwrite(STDERR, "#$num list comments HTTP {$r['code']}\n");
break;
}
$comments = json_decode($r['body'], true);
if (!is_array($comments) || empty($comments)) {
break;
}
foreach ($comments as $c) {
$id = (int)($c['id'] ?? 0);
if ($id <= 0) continue;
$dr = ghRequest($token, 'DELETE', 'https://api.github.com/repos/' . GH_REPO . "/issues/comments/$id");
if ($dr['code'] === 204) {
$deleted++;
echo "deleted comment $id on #$num\n";
} else {
fwrite(STDERR, "FAIL delete comment $id on #$num HTTP {$dr['code']}\n");
}
usleep(200000);
}
if (count($comments) < 100) break;
$page++;
}
}
echo "Done. Deleted $deleted comments.\n";
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env php
<?php
/** Reopen wrongly closed feature issues; close resolved auto-report bugs (English). */
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';
$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 comment(string $token, int $num, string $body): void {
$r = ghApi($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments", ['body' => $body]);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK comment #$num\n" : "FAIL comment #$num\n";
}
function closeIssue(string $token, int $num): void {
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'closed']);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK close #$num\n" : "FAIL close #$num\n";
}
function reopenIssue(string $token, int $num): void {
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'open']);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK reopen #$num\n" : "FAIL reopen #$num\n";
}
$reopen = [
125 => "Reopened: **voice commands in cooking mode** are not implemented yet (only TTS readout exists). This was closed by mistake during bulk triage — the feature backlog should stay open until hands-free step navigation ships.",
98 => "Reopened: **pin favourite products to the top of inventory** is not implemented yet (recipe favourites #124 are done, but product pinning is a separate request). Closed by mistake — keeping on the backlog.",
];
foreach ($reopen as $num => $msg) {
comment($token, $num, $msg);
reopenIssue($token, $num);
}
$bugs = [
201 => 'Fixed in latest develop: `inventory_use` and `shopping_add` now retry on `SQLITE_BUSY` via `dbWithRetry()` (same pattern as #198).',
202 => 'Fixed: Bring/internal `shopping_add` wrapped in `dbWithRetry()` to survive cron + PWA concurrent writes.',
203 => 'Fixed: `smartShopping()` / `smartShoppingCached()` now call `set_time_limit(120)` so large pantries no longer hit the 30s PHP fatal.',
204 => 'Fixed: same as #203 — smart shopping timeout caused HTTP 500; extended execution limit resolves the crash.',
];
foreach ($bugs as $num => $msg) {
comment($token, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._");
closeIssue($token, $num);
}
echo "Done.\n";
+26
View File
@@ -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/"
+15 -1
View File
@@ -28,6 +28,17 @@ if (!is_array($recipe)) {
exit(1);
}
$stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC, p.name ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
recipeApplyStockHintsToRecipe($db, $recipe);
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
@@ -35,7 +46,10 @@ $upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
foreach ($recipe['ingredients'] ?? [] as $ing) {
if (empty($ing['from_pantry'])) continue;
if (empty($ing['from_pantry'])) {
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
continue;
}
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
echo sprintf(
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
+44
View File
@@ -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";
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env php
<?php
/**
* Triage resolved auto-report bugs only (English comments).
* Feature/enhancement backlog issues are never bulk-closed here.
* 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;
}
$bugs = [
198 => 'Fixed in develop: `PRAGMA busy_timeout` raised to 10s and `dbWithRetry()` on `updateInventory` retries SQLITE_BUSY when cron and PWA write in parallel.',
199 => 'Duplicate of #198 — same event (`inventory_update` → database locked). Fix: retry + longer busy_timeout.',
196 => 'Fixed in v1.7.38+: `saveProduct` handles duplicate barcodes (merge or 409 JSON) instead of HTTP 500.',
197 => 'PWA side-effect of PHP crash #196 — fixed with duplicate barcode handling in `saveProduct`.',
195 => 'Fixed: `EverLog::request()` always receives strings — `(string)($_SERVER[\'REQUEST_METHOD\'] ?? \'GET\')`.',
193 => 'Same root cause as #195 (TypeError when method was null from CLI).',
194 => 'Fixed: `_applySpesaScanUI` referenced `currentPage` → corrected to `_currentPageId`.',
192 => 'Fixed: TDZ on `enriched` in `renderShoppingItems`.',
191 => 'Fixed: TDZ on `setProgress` / `barEl` in `_runStartupCheck`.',
134 => 'Auto-report for non-writable Docker volume. Mitigations: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile chown.',
184 => 'Related to #134: SQLite readonly when `data/` is not writable.',
];
foreach ($bugs as $num => $msg) {
commentIssue($token, $repo, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._", $dryRun);
closeIssue($token, $repo, $num, $dryRun);
}
echo "Done.\n";
+52 -7
View File
@@ -32,6 +32,7 @@
"reset_default": "↺ Standard wiederherstellen",
"save_info": "💾 Info speichern",
"retry": "🔄 Erneut versuchen",
"next": "Weiter →",
"yes_short": "Ja",
"no_short": "Nein"
},
@@ -199,6 +200,7 @@
"mode_shopping": "🛒 Einkaufsmodus",
"mode_shopping_end": "✅ Einkauf beenden",
"spesa_btn": "🛒 Einkauf",
"spesa_camera_hint": "Barcode mit der Kamera erfassen. Kein Barcode? Tippe unten auf «Mit KI erkennen».",
"zoom": "Zoom",
"tab_barcode": "Barcode",
"tab_name": "Name",
@@ -234,21 +236,36 @@
"status_confirmed": "Bestätigt!",
"status_parallel": "Kombinierter Scan aktiv...",
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
"status_digit_ocr": "Lese Ziffern unter dem Barcode...",
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"method_local_ocr": "Ziffern-OCR",
"method_zbar": "ZBar",
"local_ocr_found": "Code aus Ziffern: {code}",
"ai_fallback_searching": "KI identifiziert Produkt...",
"ai_fallback_found": "Produkt von KI erkannt",
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
"ai_retry_btn": "Mit KI erneut versuchen",
"ai_manual_btn": "🤖 Mit KI erkennen",
"ai_not_recognized": "KI: Produkt nicht erkannt. Erneut versuchen oder manuell eingeben.",
"ai_match_title": "Produkt von KI erkannt",
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
"ai_match_existing": "Mogliche Treffer in der Vorratskammer",
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
"ai_match_use_btn": "Dieses nutzen",
"ai_match_add_btn": "\"{name}\" hinzufugen",
"ai_match_existing": "Aktuell in der Vorratskammer",
"ai_match_finished": "Aufgebraucht / leer",
"ai_match_catalog": "Im Katalog (ohne Bestand)",
"ai_match_finished_badge": "aufgebraucht",
"ai_match_finished_hint": "Produkt aufgebraucht — Menge auffüllen",
"ai_match_merged_existing": "Mit vorhandenem Katalogprodukt verknüpft",
"ai_match_none": "Keine ähnlichen Produkte — du kannst ein neues anlegen.",
"ai_match_use_btn": "Nutzen",
"ai_match_create_btn": " Neu anlegen: {name}",
"ai_match_add_btn": "{name} hinzufugen",
"ai_match_action_hint": "Tippe auf den gruenen Button, um dieses Produkt hinzuzufuegen",
"ai_match_or_similar": "Oder waehle ein aehnliches Produkt:",
"ai_detected_label": "KI erkannt",
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
},
@@ -289,8 +306,9 @@
"hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
"scan_expiry_title": "📷 Ablaufdatum scannen",
"product_added": "✅ {name} hinzugefügt!{qty}",
"duplicate_recent_confirm": "Du hast «{name}» gerade hinzugefuegt ({when}).\n\nDie Menge ist bereits {total}.\n\nUm {qty} erhoehen?",
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
"history_badge_tip": "Durchschnitt der letzten {n} Einträge — wird bei jedem Kauf aktualisiert",
"vacuum_question": "Vakuumiert?",
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
},
@@ -335,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Ja, entsorgen",
"locations_short": "Orte"
},
"waste": {
"reason_title": "Warum wirfst du es weg?",
"reason_subtitle": "Das hilft uns, ähnliche Verschwendung zu vermeiden.",
"reason_expired": "⏰ Abgelaufen",
"reason_spoiled": "🦠 Verdorben",
"reason_wrong_location": "📍 Falscher Lagerort",
"reason_kept_too_long": "⏳ Zu lange aufbewahrt",
"reason_bought_too_much": "🛒 Zu viel gekauft",
"reason_forgotten": "😴 Vergessen / nicht rechtzeitig genutzt",
"reason_bad_quality": "👎 Schlechte Qualität beim Kauf",
"reason_other": "❓ Sonstiges"
},
"product": {
"title_new": "Neues Produkt",
"title_edit": "Produkt bearbeiten",
@@ -343,6 +373,8 @@
"name_label": "🏷️ Produktname *",
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
"brand_label": "🏢 Marke",
"allergens_label": "Allergene:",
"ingredients_summary": "📋 Zutaten",
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
"category_label": "📂 Kategorie",
"unit_label": "📏 Maßeinheit",
@@ -375,7 +407,7 @@
"labels_label": "Etiketten",
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:",
"history_badge": "📊 Verlauf",
"from_history": " (aus Verlauf)"
"from_history": " (Ø letzte 3)"
},
"products": {
"title": "📦 Alle Produkte",
@@ -499,6 +531,18 @@
"item_removed": "✅ {name} von der Liste entfernt!",
"urgency_spec_critical": "⚡ Dringend",
"urgency_spec_high": "🟠 Bald",
"urgency_spec_medium": "🟡 Bald",
"urgency_spec_low": "🔵 Prognose",
"family_sibling_title": "Ähnlich in {location}",
"family_sibling_check": "Prüfen: {name}",
"family_sibling_stock": "Du solltest haben: {qty}",
"family_sibling_location": "Standort: {location}",
"family_sibling_qty": "Menge: {qty}",
"family_sibling_purchased": "Gekauft am {date}",
"family_sibling_question": "Ist die Menge noch korrekt?",
"family_sibling_prompt": "Du hast auch {name}: {qty} auf Lager. Menge bestätigen?",
"family_sibling_yes": "Ja, passt",
"family_sibling_no": "Nein, aktualisieren",
"bring_add_n": "{n} zu Bring! hinzufügen",
"bring_add_selected": "Ausgewählte zu Bring! hinzufügen",
"bring_adding": "Wird hinzugefügt...",
@@ -715,7 +759,8 @@
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
"detect_btn": "🔄 Kameras erkennen",
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini."
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini.",
"ai_manual_hint": "Wenn der Barcode nicht lesbar ist, nutze den Button «Mit KI erkennen» unter der Kamera. Erfordert konfiguriertes Gemini."
},
"security": {
"title": "🔒 HTTPS-Zertifikat",
+57 -7
View File
@@ -32,6 +32,7 @@
"reset_default": "↺ Reset to default",
"save_info": "💾 Save information",
"retry": "🔄 Retry",
"next": "Next →",
"yes_short": "Yes",
"no_short": "No"
},
@@ -199,6 +200,7 @@
"mode_shopping": "🛒 Shopping Mode",
"mode_shopping_end": "✅ End shopping",
"spesa_btn": "🛒 Shopping",
"spesa_camera_hint": "Point the camera at the barcode. No barcode? Tap «Identify with AI» below.",
"zoom": "Zoom",
"tab_barcode": "Barcode",
"tab_name": "Name",
@@ -234,21 +236,36 @@
"status_confirmed": "Confirmed!",
"status_parallel": "Using combined scan methods...",
"status_ocr_searching": "Reading the barcode digits...",
"status_digit_ocr": "Reading numbers below the barcode...",
"status_ai_visual_searching": "Now trying to recognize the product...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"method_local_ocr": "Digit OCR",
"method_zbar": "ZBar",
"local_ocr_found": "Code from digits: {code}",
"ai_fallback_searching": "AI identifying product...",
"ai_fallback_found": "Product identified by AI",
"ai_fallback_not_found": "AI: product not recognized",
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision is analyzing the product...",
"ai_retry_btn": "Retry with AI",
"ai_manual_btn": "🤖 Identify with AI",
"ai_not_recognized": "AI could not recognize the product. Try again or add manually.",
"ai_match_title": "Product recognized by AI",
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
"ai_match_existing": "Possible pantry matches",
"ai_match_none": "No similar pantry products found.",
"ai_match_use_btn": "Use this",
"ai_match_add_btn": "Add \"{name}\"",
"ai_match_existing": "Currently in pantry",
"ai_match_finished": "Finished / depleted",
"ai_match_catalog": "In catalog (no stock)",
"ai_match_finished_badge": "depleted",
"ai_match_finished_hint": "Finished product — restock the quantity",
"ai_match_merged_existing": "Linked to an existing catalog product",
"ai_match_none": "No similar products found — you can create a new one.",
"ai_match_use_btn": "Use",
"ai_match_create_btn": " Create new: {name}",
"ai_match_add_btn": "Add {name}",
"ai_match_action_hint": "Tap the green button to add this product",
"ai_match_or_similar": "Or pick a similar product:",
"ai_detected_label": "AI detected",
"mode_shopping_activated": "🛒 Shopping mode activated!"
},
@@ -289,8 +306,9 @@
"hint_modify": "📝 You can change the date or scan it with the camera",
"scan_expiry_title": "📷 Scan Expiry Date",
"product_added": "✅ {name} added!{qty}",
"duplicate_recent_confirm": "You just added \"{name}\" ({when}).\n\nQuantity is already {total}.\n\nIncrease it by {qty}?",
"suffix_freezer_vacuum": "(freezer + vacuum sealed)",
"history_badge_tip": "Average from {n} previous entries",
"history_badge_tip": "Average of the last {n} entries — updates with each new purchase",
"vacuum_question": "Vacuum sealed?",
"vacuum_saved": "🔒 Vacuum sealed!"
},
@@ -335,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Yes, discard",
"locations_short": "places"
},
"waste": {
"reason_title": "Why are you discarding it?",
"reason_subtitle": "This helps us prevent similar waste next time.",
"reason_expired": "⏰ Expired",
"reason_spoiled": "🦠 Spoiled / gone bad",
"reason_wrong_location": "📍 Wrong storage (fridge/freezer/pantry)",
"reason_kept_too_long": "⏳ Kept too long",
"reason_bought_too_much": "🛒 Bought too much",
"reason_forgotten": "😴 Forgotten / not used in time",
"reason_bad_quality": "👎 Poor quality when bought",
"reason_other": "❓ Other"
},
"product": {
"title_new": "New Product",
"title_edit": "Edit Product",
@@ -343,6 +373,8 @@
"name_label": "🏷️ Product Name *",
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
"brand_label": "🏢 Brand",
"allergens_label": "Allergens:",
"ingredients_summary": "📋 Ingredients",
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
"category_label": "📂 Category",
"unit_label": "📏 Unit of measure",
@@ -375,7 +407,7 @@
"labels_label": "Labels",
"select_variant": "Select the exact variant or use AI data:",
"history_badge": "📊 history",
"from_history": " (from history)"
"from_history": " (last 3 avg)"
},
"products": {
"title": "📦 All Products",
@@ -407,6 +439,11 @@
"regen_save_new": "💾 Save to archive & generate a new one",
"close_btn": "✅ Close",
"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",
"steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available",
@@ -499,6 +536,18 @@
"item_removed": "✅ {name} removed from list!",
"urgency_spec_critical": "⚡ Urgent",
"urgency_spec_high": "🟠 Soon",
"urgency_spec_medium": "🟡 Soon",
"urgency_spec_low": "🔵 Forecast",
"family_sibling_title": "Similar in {location}",
"family_sibling_check": "Check: {name}",
"family_sibling_stock": "You should have: {qty}",
"family_sibling_location": "Location: {location}",
"family_sibling_qty": "Quantity: {qty}",
"family_sibling_purchased": "Purchased on {date}",
"family_sibling_question": "Is the quantity still correct?",
"family_sibling_prompt": "You also have {name}: {qty} in stock. Confirm the quantity?",
"family_sibling_yes": "Yes, all good",
"family_sibling_no": "No, update",
"bring_add_n": "Add {n} to Bring!",
"bring_add_selected": "Add selected to Bring!",
"bring_adding": "Adding...",
@@ -715,7 +764,8 @@
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
"detect_btn": "🔄 Detect cameras",
"ai_fallback_label": "AI visual identification (5s fallback)",
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured."
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured.",
"ai_manual_hint": "If the barcode cannot be read, use the «Identify with AI» button below the camera. Requires Gemini configured."
},
"security": {
"title": "🔒 HTTPS Certificate",
+52 -7
View File
@@ -32,6 +32,7 @@
"reset_default": "↺ Restablecer valores por defecto",
"save_info": "💾 Guardar información",
"retry": "🔄 Reintentar",
"next": "Siguiente →",
"yes_short": "Sí",
"no_short": "No"
},
@@ -199,6 +200,7 @@
"mode_shopping": "🛒 Modo compras",
"mode_shopping_end": "✅ Finalizar compras",
"spesa_btn": "🛒 Compras",
"spesa_camera_hint": "Enfoca el código con la cámara. ¿Sin código? Pulsa «Identificar con IA» abajo.",
"zoom": "Zoom",
"tab_barcode": "Código de barras",
"tab_name": "Nombre",
@@ -233,21 +235,36 @@
"status_confirmed": "Confirmado!",
"status_parallel": "Escaneo combinado activo...",
"status_ocr_searching": "Estoy leyendo los números del código de barras...",
"status_digit_ocr": "Leyendo los números bajo el código...",
"status_ai_visual_searching": "Ahora intento reconocer el producto...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"method_local_ocr": "OCR números",
"method_zbar": "ZBar",
"local_ocr_found": "Código desde los números: {code}",
"ai_fallback_searching": "Identificación de IA en curso...",
"ai_fallback_found": "Producto identificado por IA",
"ai_fallback_not_found": "IA: producto no reconocido",
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision está analizando el producto...",
"ai_retry_btn": "Reintentar con IA",
"ai_manual_btn": "🤖 Identificar con IA",
"ai_not_recognized": "IA: producto no reconocido. Reintenta o añádelo manualmente.",
"ai_match_title": "Producto reconocido por IA",
"ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.",
"ai_match_existing": "Posibles coincidencias en despensa",
"ai_match_none": "No se encontraron productos similares en despensa.",
"ai_match_use_btn": "Usar este",
"ai_match_add_btn": "Agregar \"{name}\"",
"ai_match_existing": "Actualmente en despensa",
"ai_match_finished": "Agotados / terminados",
"ai_match_catalog": "En catálogo (sin stock)",
"ai_match_finished_badge": "agotado",
"ai_match_finished_hint": "Producto agotado — repón la cantidad",
"ai_match_merged_existing": "Vinculado a un producto existente del catálogo",
"ai_match_none": "Sin productos similares — puedes crear uno nuevo.",
"ai_match_use_btn": "Usar",
"ai_match_create_btn": " Crear nuevo: {name}",
"ai_match_add_btn": "Agregar {name}",
"ai_match_action_hint": "Toca el boton verde para agregar este producto",
"ai_match_or_similar": "O elige un producto similar:",
"ai_detected_label": "IA detecto",
"stock_in_pantry": "Ya en despensa:",
"mode_shopping_activated": "🛒 ¡Modo compras activado!"
@@ -289,8 +306,9 @@
"hint_modify": "📝 Puedes cambiar la fecha o escanearla con la cámara",
"scan_expiry_title": "📷 Escanear fecha de caducidad",
"product_added": "✅ ¡{name} añadido!{qty}",
"duplicate_recent_confirm": "Acabas de añadir «{name}» ({when}).\n\nLa cantidad ya es {total}.\n\n¿Aumentarla en {qty}?",
"suffix_freezer_vacuum": "(congelador + al vacío)",
"history_badge_tip": "Media de {n} entradas anteriores",
"history_badge_tip": "Media de los últimos {n} registros — se actualiza con cada compra",
"vacuum_question": "¿Al vacío?",
"vacuum_saved": "🔒 ¡Al vacío!"
},
@@ -335,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Sí, desechar",
"locations_short": "ubicaciones"
},
"waste": {
"reason_title": "¿Por qué lo tiras?",
"reason_subtitle": "Nos ayuda a evitar desperdicios similares.",
"reason_expired": "⏰ Caducado",
"reason_spoiled": "🦠 Estropeado",
"reason_wrong_location": "📍 Lugar de guardado incorrecto",
"reason_kept_too_long": "⏳ Guardado demasiado tiempo",
"reason_bought_too_much": "🛒 Comprado de más",
"reason_forgotten": "😴 Olvidado / no usado a tiempo",
"reason_bad_quality": "👎 Mala calidad al comprar",
"reason_other": "❓ Otro"
},
"product": {
"title_new": "Nuevo producto",
"title_edit": "Editar producto",
@@ -343,6 +373,8 @@
"name_label": "🏷️ Nombre del producto *",
"name_placeholder": "Ej.: Leche entera, Pasta penne...",
"brand_label": "🏢 Marca",
"allergens_label": "Alérgenos:",
"ingredients_summary": "📋 Ingredientes",
"brand_placeholder": "Ej.: Barilla, Danone, Heinz...",
"category_label": "📂 Categoría",
"unit_label": "📏 Unidad de medida",
@@ -375,7 +407,7 @@
"labels_label": "Etiquetas",
"select_variant": "Selecciona la variante exacta o usa los datos de IA:",
"history_badge": "📊 historial",
"from_history": " (del historial)"
"from_history": " (media últimos 3)"
},
"products": {
"title": "📦 Todos los productos",
@@ -499,6 +531,18 @@
"item_removed": "✅ ¡{name} eliminado de la lista!",
"urgency_spec_critical": "⚡ Urgente",
"urgency_spec_high": "🟠 Pronto",
"urgency_spec_medium": "🟡 Pronto",
"urgency_spec_low": "🔵 Previsión",
"family_sibling_title": "Similar en {location}",
"family_sibling_check": "Comprueba: {name}",
"family_sibling_stock": "Deberías tener: {qty}",
"family_sibling_location": "Ubicación: {location}",
"family_sibling_qty": "Cantidad: {qty}",
"family_sibling_purchased": "Comprado el {date}",
"family_sibling_question": "¿La cantidad sigue siendo correcta?",
"family_sibling_prompt": "También tienes {name}: {qty} en stock. ¿Confirmas la cantidad?",
"family_sibling_yes": "Sí, correcto",
"family_sibling_no": "No, actualizar",
"bring_add_n": "Añadir {n} a Bring!",
"bring_add_selected": "Añadir selección a Bring!",
"bring_adding": "Añadiendo...",
@@ -715,7 +759,8 @@
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
"detect_btn": "🔄 Detectar cámaras",
"ai_fallback_label": "Identificación visual IA (repuesto 5s)",
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado."
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado.",
"ai_manual_hint": "Si el código no se lee, usa el botón «Identificar con IA» debajo de la cámara. Requiere Gemini configurado."
},
"security": {
"title": "🔒 Certificado HTTPS",
+52 -7
View File
@@ -32,6 +32,7 @@
"reset_default": "↺ Rétablir les valeurs par défaut",
"save_info": "💾 Enregistrer les informations",
"retry": "🔄 Réessayer",
"next": "Suivant →",
"yes_short": "Oui",
"no_short": "Non"
},
@@ -199,6 +200,7 @@
"mode_shopping": "🛒 Mode courses",
"mode_shopping_end": "✅ Terminer les courses",
"spesa_btn": "🛒 Courses",
"spesa_camera_hint": "Visez le code-barres avec la caméra. Pas de code ? Appuyez sur « Identifier avec l'IA » ci-dessous.",
"zoom": "Zoom",
"tab_barcode": "Code-barres",
"tab_name": "Nom",
@@ -233,21 +235,36 @@
"status_confirmed": "Confirmé !",
"status_parallel": "Scan combiné actif...",
"status_ocr_searching": "Je lis les chiffres du code-barres...",
"status_digit_ocr": "Lecture des chiffres sous le code-barres...",
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"method_local_ocr": "OCR chiffres",
"method_zbar": "ZBar",
"local_ocr_found": "Code depuis les chiffres : {code}",
"ai_fallback_searching": "Identification IA en cours...",
"ai_fallback_found": "Produit identifié par l'IA",
"ai_fallback_not_found": "IA : produit non reconnu",
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision analyse le produit...",
"ai_retry_btn": "Reessayer avec l'IA",
"ai_manual_btn": "🤖 Identifier avec l'IA",
"ai_not_recognized": "IA : produit non reconnu. Réessayez ou saisissez-le manuellement.",
"ai_match_title": "Produit reconnu par l'IA",
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
"ai_match_existing": "Correspondances possibles dans le stock",
"ai_match_none": "Aucun produit similaire trouve dans le stock.",
"ai_match_use_btn": "Utiliser celui-ci",
"ai_match_add_btn": "Ajouter \"{name}\"",
"ai_match_existing": "Actuellement en stock",
"ai_match_finished": "Terminés / épuisés",
"ai_match_catalog": "Au catalogue (sans stock)",
"ai_match_finished_badge": "épuisé",
"ai_match_finished_hint": "Produit terminé — réapprovisionner la quantité",
"ai_match_merged_existing": "Lié à un produit déjà au catalogue",
"ai_match_none": "Aucun produit similaire — vous pouvez en créer un nouveau.",
"ai_match_use_btn": "Utiliser",
"ai_match_create_btn": " Créer nouveau : {name}",
"ai_match_add_btn": "Ajouter {name}",
"ai_match_action_hint": "Appuyez sur le bouton vert pour ajouter ce produit",
"ai_match_or_similar": "Ou choisissez un produit similaire :",
"ai_detected_label": "IA a detecte",
"stock_in_pantry": "Déjà à la maison :",
"mode_shopping_activated": "🛒 Mode courses activé !"
@@ -289,8 +306,9 @@
"hint_modify": "📝 Vous pouvez modifier la date ou la scanner avec la caméra",
"scan_expiry_title": "📷 Scanner la date de péremption",
"product_added": "✅ {name} ajouté !{qty}",
"duplicate_recent_confirm": "Vous venez d'ajouter « {name} » ({when}).\n\nLa quantité est déjà {total}.\n\nL'augmenter de {qty} ?",
"suffix_freezer_vacuum": "(congélateur + sous vide)",
"history_badge_tip": "Moyenne de {n} entrées précédentes",
"history_badge_tip": "Moyenne des {n} derniers ajouts — mise à jour à chaque achat",
"vacuum_question": "Sous vide ?",
"vacuum_saved": "🔒 Sous vide !"
},
@@ -335,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Oui, jeter",
"locations_short": "emplacements"
},
"waste": {
"reason_title": "Pourquoi le jetez-vous ?",
"reason_subtitle": "Cela nous aide à éviter des gaspillages similaires.",
"reason_expired": "⏰ Périmé",
"reason_spoiled": "🦠 Abîmé / gâté",
"reason_wrong_location": "📍 Mauvais emplacement",
"reason_kept_too_long": "⏳ Conservé trop longtemps",
"reason_bought_too_much": "🛒 Acheté en trop grande quantité",
"reason_forgotten": "😴 Oublié / pas utilisé à temps",
"reason_bad_quality": "👎 Mauvaise qualité à l'achat",
"reason_other": "❓ Autre"
},
"product": {
"title_new": "Nouveau produit",
"title_edit": "Modifier le produit",
@@ -343,6 +373,8 @@
"name_label": "🏷️ Nom du produit *",
"name_placeholder": "Ex. : Lait entier, Pâtes penne...",
"brand_label": "🏢 Marque",
"allergens_label": "Allergènes :",
"ingredients_summary": "📋 Ingrédients",
"brand_placeholder": "Ex. : Barilla, Danone, Heinz...",
"category_label": "📂 Catégorie",
"unit_label": "📏 Unité de mesure",
@@ -375,7 +407,7 @@
"labels_label": "Labels",
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :",
"history_badge": "📊 historique",
"from_history": " (historique)"
"from_history": " (moy. 3 derniers)"
},
"products": {
"title": "📦 Tous les produits",
@@ -499,6 +531,18 @@
"item_removed": "✅ {name} retiré de la liste !",
"urgency_spec_critical": "⚡ Urgent",
"urgency_spec_high": "🟠 Bientôt",
"urgency_spec_medium": "🟡 Bientôt",
"urgency_spec_low": "🔵 Prévision",
"family_sibling_title": "Similaire dans {location}",
"family_sibling_check": "Vérifier : {name}",
"family_sibling_stock": "Vous devriez avoir : {qty}",
"family_sibling_location": "Emplacement : {location}",
"family_sibling_qty": "Quantité : {qty}",
"family_sibling_purchased": "Acheté le {date}",
"family_sibling_question": "La quantité est-elle encore correcte ?",
"family_sibling_prompt": "Tu as aussi {name} : {qty} en stock. Confirmer la quantité ?",
"family_sibling_yes": "Oui, c'est bon",
"family_sibling_no": "Non, mettre à jour",
"bring_add_n": "Ajouter {n} à Bring !",
"bring_add_selected": "Ajouter la sélection à Bring !",
"bring_adding": "Ajout en cours...",
@@ -715,7 +759,8 @@
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
"detect_btn": "🔄 Détecter les caméras",
"ai_fallback_label": "Identification visuelle IA (repli 5s)",
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré."
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré.",
"ai_manual_hint": "Si le code-barres ne se lit pas, utilisez le bouton « Identifier avec l'IA » sous la caméra. Nécessite Gemini configuré."
},
"security": {
"title": "🔒 Certificat HTTPS",
+59 -8
View File
@@ -32,6 +32,7 @@
"reset_default": "↺ Ripristina default",
"save_info": "💾 Salva informazioni",
"retry": "🔄 Riprova",
"next": "Avanti →",
"yes_short": "Sì",
"no_short": "No"
},
@@ -199,6 +200,7 @@
"mode_shopping": "🛒 Modalità Spesa",
"mode_shopping_end": "✅ Fine spesa",
"spesa_btn": "🛒 Spesa",
"spesa_camera_hint": "Inquadra il codice con la telecamera. Senza barcode? Premi «Identifica con AI» sotto.",
"zoom": "Zoom",
"tab_barcode": "Barcode",
"tab_name": "Nome",
@@ -234,21 +236,36 @@
"status_confirmed": "Confermato!",
"status_parallel": "Doppia scansione attiva...",
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
"status_digit_ocr": "Leggo i numeri sotto il codice...",
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"method_local_ocr": "OCR numeri",
"method_zbar": "ZBar",
"local_ocr_found": "Codice dai numeri: {code}",
"ai_fallback_searching": "Identificazione AI in corso...",
"ai_fallback_found": "Prodotto identificato dall'AI",
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
"ai_retry_btn": "Riprova con AI",
"ai_manual_btn": "🤖 Identifica con AI",
"ai_not_recognized": "AI: prodotto non riconosciuto. Riprova o inserisci manualmente.",
"ai_match_title": "Prodotto riconosciuto con AI",
"ai_match_subtitle": "Scegli se usare un prodotto gia presente oppure aggiungere quello rilevato.",
"ai_match_existing": "Possibili corrispondenze in dispensa",
"ai_match_none": "Nessun prodotto simile trovato in dispensa.",
"ai_match_use_btn": "Usa questo",
"ai_match_add_btn": "Aggiungi \"{name}\"",
"ai_match_subtitle": "Scegli un prodotto esistente o creane uno nuovo con il nome rilevato.",
"ai_match_existing": "In dispensa adesso",
"ai_match_finished": "Finiti / esauriti",
"ai_match_catalog": "Nel catalogo (senza scorte)",
"ai_match_finished_badge": "esaurito",
"ai_match_finished_hint": "Prodotto finito — reintegra la quantità",
"ai_match_merged_existing": "Collegato a un prodotto già presente nel catalogo",
"ai_match_none": "Nessun prodotto simile trovato — puoi crearne uno nuovo.",
"ai_match_use_btn": "Usa",
"ai_match_create_btn": " Crea nuovo: {name}",
"ai_match_add_btn": "Aggiungi {name}",
"ai_match_action_hint": "Tocca il pulsante verde per aggiungere questo prodotto",
"ai_match_or_similar": "Oppure scegli un prodotto simile:",
"ai_detected_label": "AI ha trovato",
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
},
@@ -289,8 +306,9 @@
"hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera",
"scan_expiry_title": "📷 Scansiona Data Scadenza",
"product_added": "✅ {name} aggiunto!{qty}",
"duplicate_recent_confirm": "Hai appena aggiunto «{name}» ({when}).\n\nLa quantità è già {total}.\n\nVuoi aumentarla di {qty}?",
"suffix_freezer_vacuum": "(freezer + sotto vuoto)",
"history_badge_tip": "Media da {n} inserimenti precedenti",
"history_badge_tip": "Media degli ultimi {n} inserimenti — si aggiorna ad ogni nuovo acquisto",
"vacuum_question": "Messo sotto vuoto?",
"vacuum_saved": "🔒 Sotto vuoto registrato"
},
@@ -335,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Sì, butta",
"locations_short": "posti"
},
"waste": {
"reason_title": "Perché lo butti?",
"reason_subtitle": "Ci aiuta a evitare sprechi simili in futuro.",
"reason_expired": "⏰ Scaduto",
"reason_spoiled": "🦠 Andato a male / deperito",
"reason_wrong_location": "📍 Posto sbagliato (frigo/freezer/dispensa)",
"reason_kept_too_long": "⏳ Tenuto troppo a lungo",
"reason_bought_too_much": "🛒 Comprato troppo",
"reason_forgotten": "😴 Dimenticato / non usato in tempo",
"reason_bad_quality": "👎 Qualità scadente all'acquisto",
"reason_other": "❓ Altro"
},
"product": {
"title_new": "Nuovo Prodotto",
"title_edit": "Modifica Prodotto",
@@ -343,6 +373,8 @@
"name_label": "🏷️ Nome Prodotto *",
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
"brand_label": "🏢 Marca",
"allergens_label": "Allergeni:",
"ingredients_summary": "📋 Ingredienti",
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
"category_label": "📂 Categoria",
"unit_label": "📏 Unità di misura",
@@ -375,7 +407,7 @@
"labels_label": "Etichette",
"select_variant": "Seleziona la variante esatta o usa i dati AI:",
"history_badge": "📊 storico",
"from_history": " (da storico)"
"from_history": " (media ultimi 3)"
},
"products": {
"title": "📦 Tutti i Prodotti",
@@ -407,6 +439,12 @@
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
"close_btn": "✅ Chiudi",
"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",
"steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile",
@@ -499,6 +537,18 @@
"item_removed": "✅ {name} rimosso dalla lista!",
"urgency_spec_critical": "⚡ Urgente",
"urgency_spec_high": "🟠 Presto",
"urgency_spec_medium": "🟡 A breve",
"urgency_spec_low": "🔵 Previsione",
"family_sibling_title": "Simile in {location}",
"family_sibling_check": "Controlla: {name}",
"family_sibling_stock": "Dovresti avere: {qty}",
"family_sibling_location": "Si trova in: {location}",
"family_sibling_qty": "Quantità: {qty}",
"family_sibling_purchased": "Acquistato il {date}",
"family_sibling_question": "La quantità è ancora corretta?",
"family_sibling_prompt": "Hai anche {name}: ne hai {qty} in dispensa. Confermi la quantità?",
"family_sibling_yes": "Sì, tutto ok",
"family_sibling_no": "No, aggiorna",
"bring_add_n": "Aggiungi {n} a Bring!",
"bring_add_selected": "Aggiungi selezionati a Bring!",
"bring_adding": "Aggiunta in corso...",
@@ -715,7 +765,8 @@
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
"detect_btn": "🔄 Rileva fotocamere",
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato."
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato.",
"ai_manual_hint": "Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato."
},
"security": {
"title": "🔒 Certificato HTTPS",