Compare commits

..

37 Commits

Author SHA1 Message Date
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
github-actions[bot] 217626ca2a chore: auto-merge develop → main
Triggered by: cf65e79 Release v1.7.36: recipe stock hints, ghost products, and shopping total fix.
2026-06-04 17:24:45 +00:00
dadaloop82 cf65e79010 Release v1.7.36: recipe stock hints, ghost products, and shopping total fix.
Adds pantry stock/remainder lines on recipe ingredients with zero-waste use-all on sealed package leftovers, ghost product restore in the dashboard, unified shopping totals, i18n sync, and maintenance scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:22:59 +00:00
github-actions[bot] 46bbe0f8d3 chore: auto-merge develop → main
Triggered by: a0385cf Fix unauthorized errors on recipe stream and direct fetch calls.
2026-06-04 10:34:47 +00:00
dadaloop82 a0385cfb9b Fix unauthorized errors on recipe stream and direct fetch calls.
Send API token headers on generate_recipe_stream, expiry_history, and tts_proxy after security hardening.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:33:10 +00:00
github-actions[bot] 3a938dd7fb chore: auto-merge develop → main
Triggered by: 0d00662 Fix Home Assistant integration auth compatibility.
2026-06-03 19:50:38 +00:00
dadaloop82 0d006625fd Fix Home Assistant integration auth compatibility.
Accept Authorization Bearer tokens, expose ha_info for discovery without API token, and report api_token_required in haGetInfo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 19:49:03 +00:00
github-actions[bot] d5b4a6c4da chore: auto-merge develop → main
Triggered by: d33b0ca Harden security, modularize API bootstrap, and fix scale SSE auth.
2026-06-03 18:06:05 +00:00
dadaloop82 d33b0ca2fe Harden security, modularize API bootstrap, and fix scale SSE auth.
Block web access to sensitive paths, require API_TOKEN for mutations, encrypt GitHub issue credentials in .env, auto-provision tokens for same-origin clients, and pass api_token in scale relay URLs since EventSource cannot send headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 18:04:19 +00:00
dadaloop82 3a4e843334 Merge branch 'develop' 2026-06-02 08:59:19 +00:00
dadaloop82 7104483dac fix: barcode EAN checksum validation + recipe persons dialog conflict
- Manual barcode input now blocks on invalid EAN checksum (was warning-only)
- Native BarcodeDetector now validates EAN/UPC checksum before confirming
- Renamed duplicate adjustRecipePersons (rescaler) to scaleRecipePersons
  to restore +/- buttons in the recipe generation dialog
- Added error.barcode_checksum translation key (all 5 languages)
- Bump version to v1.7.35
2026-06-02 08:58:48 +00:00
dadaloop82 94e98bc79f style: remove 'Quagga' text from scanner status bar and debug labels 2026-05-29 17:49:17 +00:00
dadaloop82 fd039d743e fix: move _aiFallbackExhausted reset out of stopScanner
stopScanner() is called internally by initScanner() on every restart,
so resetting the flag there caused the AI timer to re-arm on every
internal cycle — creating an infinite 5-second loop.

Flag now resets only in showPage('scan'), which fires exclusively when
the user opens the scanner page (fresh session). Internal stop/restart
cycles leave the flag untouched.
2026-05-29 17:46:45 +00:00
dadaloop82 b1bcf9e714 fix: AI visual barcode fallback fires only once per scanner session
If Gemini cannot identify the product visually, mark _aiFallbackExhausted=true
for the current scanner session so the 5s timer never fires again. The scanner
restarts normally (user can keep trying with the barcode reader) and a persistent
status message is shown: 'AI: product not recognized — try scanning the barcode'.
_aiFallbackExhausted resets to false in stopScanner() so the next camera session
starts fresh.
2026-05-29 17:43:55 +00:00
dadaloop82 98c38f017e feat: AI visual barcode fallback after 5s with settings toggle
When the barcode scanner cannot read a code within 5 seconds and Gemini
is available, a camera frame is automatically captured and sent to the
new gemini_barcode_visual endpoint for visual product identification.
The result pre-fills the product form identically to a barcode scan.

- PHP: new geminiBarcodeVisual() function + router case + aiActions entry
- PHP: barcode_ai_fallback setting in getServerSettings() + saveSettings() boolMap
- JS: _aiFallbackTimer (cleared on detection/stop), 5s timer in initScanner()
- JS: _tryGeminiVisualBarcode() — captures JPEG frame, calls API, saves product
- JS: barcode_ai_fallback wired into serverKeys, applyUI, collectUI, POST body
- HTML: AI fallback toggle in Settings → Camera card
- Translations: ai_fallback_* strings in scan + settings.camera (it/en/de/fr/es)

Feature is disabled by default (BARCODE_AI_FALLBACK=false).
2026-05-29 17:37:37 +00:00
dadaloop82 7947f47e6d release: v1.7.33 2026-05-29 11:06:28 +00:00
dadaloop82 758eb93e20 fix: ha_sensor shopping_total null + wrong shopping_list columns
- Extended shopping_total cache TTL from 1h to 24h
- Added inline price fallback: when cache is empty/stale, computes total
  from shopping_price_cache.json (no AI calls); joins shopping_list with
  products to get canonical shopping_name; tries both v3 and legacy v0
  key formats to maximise cache hit rate; works in both internal and
  Bring shopping modes (removed isShoppingBringMode guard — table is
  always populated by sync)
- Fixed haInventorySensor + haRefreshPrices: shopping_list has no
  quantity/unit/checked columns; changed to SELECT name with
  COALESCE(p.shopping_name, sl.name) join, defaults qty=1/unit=pz
2026-05-29 11:06:19 +00:00
dadaloop82 ff1175451a release: v1.7.32 2026-05-29 06:54:42 +00:00
dadaloop82 42630c3e3e feat: smarter expiry-to-shopping-list logic
- Extend isExpiringSoon threshold: 3d -> 7d
- Expired items: add isRegular/buyCount>=2 guard so one-off
  expired products don't appear in shopping list (expiry
  banner already covers them)
- Expiring-soon block: require isRegular for 7-day window;
  add 'willExpireBeforeUsed' check (daysLeft > daysToExpiry);
  new reason string 'Scade in Ngg — ricompra' when stock is
  adequate but won't be consumed in time
2026-05-29 06:54:40 +00:00
dadaloop82 637eaa20d6 docs: version badge 1.7.31 2026-05-29 06:48:52 +00:00
dadaloop82 5e307f79b8 docs: update version badge to v1.7.31 2026-05-29 06:48:50 +00:00
68 changed files with 18117 additions and 9596 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.git/
dist/
build/
*.log
+18 -4
View File
@@ -125,10 +125,24 @@ GDRIVE_FOLDER_ID=
GDRIVE_RETENTION_DAYS=30
# ── Security ─────────────────────────────────────────────────────────────────
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
# Leave empty to allow anyone with access to the server to change settings.
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
API_TOKEN=
SETTINGS_TOKEN=
# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard)
CORS_ORIGIN=
# GitHub automatic issue reporting (encrypted storage recommended)
# Option A — plain ( .env is gitignored ):
# GH_ISSUE_TOKEN=ghp_...
# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'):
GH_ISSUE_TOKEN=
GH_ISSUE_TOKEN_ENC=
GH_ISSUE_TOKEN_KEY=
# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens.
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
# for Zeroconf discovery label and device name in Home Assistant).
# Defaults to the server hostname if left empty.
@@ -160,5 +174,5 @@ HA_EXPIRY_DAYS=3
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
# To rotate it, update the GH_ISSUE_TOKEN constant there.
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
CRON_LOG_MAX_BYTES=524288
+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/
+16 -1
View File
@@ -1,7 +1,22 @@
RewriteEngine On
# Force HTTPS
# Block sensitive files (Apache 2.4+)
<Files ".env">
Require all denied
</Files>
<Files ".env.example">
Require all denied
</Files>
<Files "backup.sh">
Require all denied
</Files>
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# 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
+115
View File
@@ -11,6 +11,121 @@ 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.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
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
### Fixed
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
## [1.7.35] - 2026-06-02
### Fixed
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
- **Recipe persons +/ buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
## [1.7.34] - 2026-05-30
### Added
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
## [1.7.33] - 2026-05-29
### Fixed
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
## [1.7.32] - 2026-05-29
### Changed
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
## [1.7.31] - 2026-05-29
### Fixed
+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
+13 -12
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.25-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.41-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)
@@ -86,6 +86,7 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
- **Recipe stock hints** — Each pantry ingredient shows how much you have and what remains after use; when the leftover would be less than 5% of the full sealed package (10% for an already-opened partial pack), the recipe automatically uses everything on hand to avoid waste
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
@@ -236,7 +237,7 @@ TTS_ENABLED=true
# Optional: DB retention and cleanup (applied automatically each cron cycle)
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
TRANSACTION_RETENTION_DAYS=90 # delete stock transactions older than N days (min 30 enforced)
# Optional: Vacuum-sealed expiry grace period
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
@@ -247,8 +248,11 @@ GEMINI_COST_25F_OUT=0.60
GEMINI_COST_20F_IN=0.10
GEMINI_COST_20F_OUT=0.40
# Optional: Security — protect the save_settings endpoint
# Set a strong random string; the Settings UI will ask for it before saving
# Optional: Security — protect all API endpoints
# Set a strong random string; clients send it as X-API-Token header (or ?api_token= for HA)
API_TOKEN=
# Optional: Legacy alias for API_TOKEN (settings save only)
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
@@ -416,8 +420,11 @@ evershelf-kiosk/ # 📺 Android kiosk app (add-on)
- **Credentials** are stored in `.env` (server-side, never committed to Git)
- **Database** stays local — never pushed to remote repositories
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access
- **API token** — set `API_TOKEN` in `.env` to require `X-API-Token` on all API calls (Home Assistant: `?api_token=`)
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `ha_token_set`, …)
- **GitHub Issues token** — stored encrypted as `GH_ISSUE_TOKEN_ENC` + `GH_ISSUE_TOKEN_KEY` (see `scripts/encrypt-gh-token.php`)
- **Settings write protection** — `save_settings` requires the same API token when configured; validated with `hash_equals`
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
@@ -472,12 +479,6 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
---
## 🤝 Contributing
EverShelf is a community project and contributions of any size are welcome!
### Easiest way to start — translate EverShelf into your language
Translations are just JSON files. No coding, no setup — fork → edit → PR.
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* 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';
require_once __DIR__ . '/lib/security.php';
require_once __DIR__ . '/lib/cron_log.php';
require_once __DIR__ . '/logger.php';
require_once __DIR__ . '/database.php';
+28 -5
View File
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
exit('Forbidden');
}
// Define CRON_MODE before loading index.php so the router is skipped
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
define('CRON_MODE', true);
// Load all API functions without running the HTTP router
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
try {
$db = getDB();
@@ -42,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'])) {
@@ -55,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";
@@ -62,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;
+3910 -1518
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* EverShelf — shared path constants.
*/
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Rotate data/cron.log — keep last N MB / lines.
*/
require_once __DIR__ . '/constants.php';
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
$path = CRON_LOG_PATH;
if (!file_exists($path)) {
return;
}
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
$size = filesize($path);
if ($size === false || $size <= $maxBytes) {
return;
}
for ($i = $keepRotated; $i >= 1; $i--) {
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
$to = $path . '.' . $i;
if ($i === $keepRotated && file_exists($to)) {
@unlink($to);
}
if (file_exists($from)) {
@rename($from, $to);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* EverShelf — environment variable loader (.env).
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$envFile = dirname(__DIR__, 2) . '/.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$key, $val] = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
/** Push a single key into the in-memory env cache (after .env write). */
function envCacheSet(string $key, string $value): void {
loadEnv();
// Force reload on next call — callers should use loadEnv() return for batch updates
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
*
* Configure ONE of:
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
*
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
*/
require_once __DIR__ . '/env.php';
function evershelfDecryptGhToken(string $encB64, string $key): string {
$raw = base64_decode($encB64, true);
if ($raw === false || strlen($raw) < 28) {
return '';
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt(
$cipher,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return ($plain !== false) ? $plain : '';
}
function evershelfEncryptGhToken(string $plain, string $key): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(
$plain,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return base64_encode($iv . $tag . $cipher);
}
/** Decode GitHub Issues token at runtime — never stored in source code. */
function _ghToken(): string {
static $token = null;
if ($token !== null) {
return $token;
}
$plain = env('GH_ISSUE_TOKEN');
if ($plain !== '') {
$token = $plain;
return $token;
}
$enc = env('GH_ISSUE_TOKEN_ENC');
$key = env('GH_ISSUE_TOKEN_KEY');
if ($enc !== '' && $key !== '') {
$token = evershelfDecryptGhToken($enc, $key);
return $token;
}
$token = '';
return $token;
}
+293
View File
@@ -0,0 +1,293 @@
<?php
/**
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
*/
require_once __DIR__ . '/env.php';
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
function evershelfEffectiveApiToken(): string {
$api = env('API_TOKEN');
if ($api !== '') {
return $api;
}
return env('SETTINGS_TOKEN', '');
}
function evershelfApiTokenRequired(): bool {
return evershelfEffectiveApiToken() !== '';
}
function evershelfGetProvidedApiToken(): string {
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
return (string)$_SERVER['HTTP_X_API_TOKEN'];
}
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
}
if (isset($_GET['api_token'])) {
return (string)$_GET['api_token'];
}
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
return $m[1];
}
return evershelfGetProvidedApiTokenFromHeaders();
}
function evershelfApiTokenValid(): bool {
$required = evershelfEffectiveApiToken();
if ($required === '') {
return true;
}
$provided = evershelfGetProvidedApiToken();
return $provided !== '' && hash_equals($required, $provided);
}
function evershelfGetProvidedApiTokenFromHeaders(): string {
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
}
/** Actions reachable without API token (telemetry + public probes). */
function evershelfPublicActions(): array {
return [
'ping',
'app_bootstrap',
'check_update',
'report_error',
'report_bug',
'client_log',
'gdrive_oauth_callback',
];
}
/** GET actions that mutate state — require auth when token is configured. */
function evershelfMutatingGetActions(): array {
return ['db_cleanup', 'export_inventory'];
}
function evershelfDestructiveActions(): array {
return [
'save_settings', 'db_cleanup',
'backup_now', 'backup_delete', 'backup_restore',
'gdrive_push', 'gdrive_oauth_exchange',
'migrate_units',
];
}
function evershelfActionNeedsAuth(string $action, string $method): bool {
if (!evershelfApiTokenRequired()) {
return false;
}
if (in_array($action, evershelfPublicActions(), true)) {
return false;
}
if ($method === 'POST') {
return true;
}
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
return true;
}
if (in_array($action, evershelfDestructiveActions(), true)) {
return true;
}
// Protect all data reads when API token is set
return true;
}
function evershelfRequireApiAuth(string $action, string $method): void {
if (!evershelfActionNeedsAuth($action, $method)) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => 'unauthorized',
'api_token_required' => true,
]);
exit;
}
function evershelfRequireAuthForSensitive(string $action): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
function evershelfSendCorsHeaders(): void {
$configured = env('CORS_ORIGIN', '');
if ($configured === '') {
// Same-origin SPA — do not emit wildcard CORS
return;
}
if ($configured === '*') {
header('Access-Control-Allow-Origin: *');
} else {
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_filter(array_map('trim', explode(',', $configured)));
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $reqOrigin);
header('Vary: Origin');
}
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
}
/** Read-only actions allowed in DEMO_MODE. */
function evershelfDemoReadOnlyActions(): array {
return [
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
'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',
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
'recipes_list', 'chat_list', 'app_settings_get',
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
'backup_list', 'export_inventory',
];
}
function evershelfDemoBlocksAction(string $action, string $method): bool {
if (env('DEMO_MODE') !== 'true') {
return false;
}
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
return false;
}
// Block all AI generation in demo (cost + writes)
if (str_starts_with($action, 'gemini_') || in_array($action, [
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
], true)) {
return true;
}
if ($method === 'POST') {
return true;
}
if (in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
return !in_array($action, evershelfDemoReadOnlyActions(), true);
}
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
function evershelfAllowedScaleHosts(): array {
$hosts = ['127.0.0.1', 'localhost', '::1'];
$gw = env('SCALE_GATEWAY_URL', '');
if ($gw !== '') {
$p = parse_url($gw);
if (!empty($p['host'])) {
$hosts[] = strtolower($p['host']);
}
}
// Server's own LAN IP — gateway may bind here on kiosk LAN
if (function_exists('gethostname')) {
$lan = gethostbyname(gethostname());
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
$hosts[] = $lan;
}
}
return array_values(array_unique($hosts));
}
function evershelfScaleHostAllowed(string $host): bool {
$host = strtolower(trim($host));
if ($host === '') {
return false;
}
foreach (evershelfAllowedScaleHosts() as $allowed) {
if ($host === strtolower($allowed)) {
return true;
}
}
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
$serverIp = evershelfLocalLanIp();
if ($serverIp !== '') {
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
if (str_starts_with($host, $subnet . '.')) {
return true;
}
}
return false;
}
function evershelfLocalLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $ip;
}
}
return '';
}
/**
* True when the request comes from the EverShelf web UI on the same host.
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
*/
function evershelfIsSameOriginBrowser(): bool {
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if ($host === '') {
return false;
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
$oh = parse_url($origin, PHP_URL_HOST);
return $oh && strtolower($oh) === $host;
}
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if ($referer !== '') {
$rh = parse_url($referer, PHP_URL_HOST);
return $rh && strtolower($rh) === $host;
}
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
return true;
}
return false;
}
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
function evershelfRequireScaleAccess(): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
if (evershelfIsSameOriginBrowser()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
+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) {
+56 -51
View File
@@ -1,57 +1,53 @@
<?php
/**
* EverShelf Scale Gateway — Auto-discovery
*
* Scans the server's local /24 subnet for any host responding on the gateway
* port (default 8765) and confirms it with a WebSocket handshake.
*
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
evershelfSendCorsHeaders();
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) $port = 8765;
// ── Determine server LAN IP ────────────────────────────────────────────────
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
function localLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
$ifaces = @net_get_interfaces();
if ($ifaces) {
foreach ($ifaces as $name => $info) {
if ($name === 'lo') continue;
foreach ($info['unicast'] ?? [] as $u) {
$ip = $u['address'] ?? '';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
}
}
return '';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$serverIp = localLanIp();
// Simple rate limit: max 6 scans per minute per IP
$rlDir = dirname(__DIR__) . '/data/rate_limits';
if (!is_dir($rlDir)) {
@mkdir($rlDir, 0755, true);
}
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
$now = time();
$hits = [];
if (file_exists($rlFile)) {
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
}
if (count($hits) >= 6) {
http_response_code(429);
echo json_encode(['error' => 'Too many discovery scans']);
exit;
}
$hits[] = $now;
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) {
$port = 8765;
}
$serverIp = evershelfLocalLanIp();
$parts = explode('.', $serverIp);
if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
exit;
}
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
// Total scan budget: 1.5 seconds.
$candidates = [];
for ($i = 1; $i <= 254; $i++) {
$ip = $subnet . $i;
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
$read = null;
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
$n = @stream_select($read, $write, $except, 0, $usec);
if ($n === false || $n === 0) break;
if ($n === false || $n === 0) {
break;
}
// Sockets in $except = connection refused/error
$failed = [];
foreach ($except as $s) {
$ip = array_search($s, $candidates, true);
if ($ip !== false) $failed[$ip] = true;
if ($ip !== false) {
$failed[$ip] = true;
}
}
// Sockets in $write = connection complete (may overlap with $except on error)
foreach ($write as $s) {
$ip = array_search($s, $candidates, true);
if ($ip === false) continue;
if ($ip === false) {
continue;
}
if (!isset($failed[$ip])) {
$found_tcp[] = $ip;
}
@fclose($s);
unset($candidates[$ip]);
}
// Close failed sockets too
foreach ($failed as $ip => $_) {
if (isset($candidates[$ip])) {
@fclose($candidates[$ip]);
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
}
}
}
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
foreach ($candidates as $s) {
@fclose($s);
}
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
$gateways = [];
foreach ($found_tcp as $ip) {
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
if (!$sock) continue;
if (!$sock) {
continue;
}
stream_set_timeout($sock, 2);
$key = base64_encode(random_bytes(16));
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
$dl = microtime(true) + 2;
while (microtime(true) < $dl && !feof($sock)) {
$line = fgets($sock, 256);
if ($line === false) break;
if ($line === false) {
break;
}
$resp .= $line;
if ($line === "\r\n") break;
if ($line === "\r\n") {
break;
}
}
fclose($sock);
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
echo json_encode([
'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]);
+16 -7
View File
@@ -1,16 +1,20 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$host = $parsed['host'] ?? '';
$host = strtolower($parsed['host'] ?? '');
$port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/';
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($host)) {
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
exit;
}
// Try to open a TCP connection with a 5-second timeout
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
if (!$sock) {
+17 -1
View File
@@ -8,6 +8,16 @@
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
// ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? '';
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$wsHost = $parsed['host'] ?? '';
$wsHost = strtolower($parsed['host'] ?? '');
$wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/';
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($wsHost)) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
exit;
}
// ── SSE headers ───────────────────────────────────────────────────────────────
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-store, must-revalidate');
+524
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%;
@@ -2009,6 +2177,63 @@ body.server-offline .bottom-nav {
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
.scan-status-msg.state-retry { color: #fb923c; }
/* — AI processing overlay (full-viewport, shown during Gemini Vision call) — */
.scan-ai-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-radius: var(--radius);
}
.scan-ai-overlay-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 28px;
background: rgba(255,255,255,0.07);
border: 1.5px solid rgba(255,255,255,0.18);
border-radius: 16px;
}
.scan-ai-overlay-label {
font-size: 0.65rem;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: monospace;
}
.scan-ai-overlay-msg {
font-size: 0.88rem;
color: #fff;
text-align: center;
max-width: 220px;
}
/* — Manual AI button (user-triggered only) — */
.scan-ai-manual-btn {
width: 100%;
margin-top: 10px;
font-size: 1.05rem;
padding: 14px 16px;
border-radius: 14px;
border: 2px solid var(--accent);
background: rgba(124,58,237,0.12);
color: var(--accent);
font-weight: 700;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.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 {
position: absolute;
@@ -2059,6 +2284,183 @@ body.server-offline .bottom-nav {
box-shadow: var(--shadow);
}
.scan-ai-match-box {
display: flex;
flex-direction: column;
gap: 14px;
}
.scan-ai-hero {
display: flex;
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: 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;
color: var(--text-muted);
}
.scan-ai-match-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.scan-ai-match-list-title {
font-size: 0.88rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
font-weight: 700;
}
.scan-ai-match-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.scan-ai-candidate-item {
border: 1px solid var(--border);
background: var(--bg-main);
border-radius: 12px;
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
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;
}
.scan-ai-candidate-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.scan-ai-candidate-name {
font-size: 1rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-meta {
font-size: 0.84rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-ai-candidate-cta {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 6px 10px;
flex-shrink: 0;
}
.scan-ai-match-empty {
font-size: 0.86rem;
color: var(--text-muted);
background: var(--bg-main);
border: 1px dashed var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.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;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.scan-ai-detected-pill {
font-size: 0.8rem;
color: var(--text-muted);
background: var(--bg-main);
border-radius: 999px;
border: 1px solid var(--border);
padding: 6px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* — Recent scans — */
.scan-recents {
display: flex;
@@ -2933,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);
@@ -4295,6 +4705,93 @@ body.server-offline .bottom-nav {
line-height: 1.5;
}
/* ===== RECIPE NUTRITION BLOCK ===== */
.recipe-nutrition-block {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: var(--radius-sm);
padding: 12px 14px 8px;
margin-top: 16px;
}
.recipe-section-heading {
font-size: 0.85rem;
font-weight: 700;
color: #15803d;
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.recipe-nutrition-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.recipe-nutrition-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.recipe-nutrition-icon { font-size: 1.2rem; }
.recipe-nutrition-value {
font-size: 0.95rem;
font-weight: 700;
color: #15803d;
}
.recipe-nutrition-label {
font-size: 0.65rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.recipe-nutrition-note {
font-size: 0.7rem;
color: #94a3b8;
text-align: center;
margin: 6px 0 0;
}
.recipe-nutrition-footnote {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 12px;
}
/* ===== RECIPE STORAGE CARD ===== */
.recipe-storage-card {
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: var(--radius-sm);
padding: 12px 14px 8px;
margin-top: 12px;
}
.recipe-storage-card .recipe-section-heading { color: #b45309; }
.recipe-storage-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.recipe-storage-badge {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 20px;
padding: 2px 12px;
font-size: 0.8rem;
font-weight: 600;
color: #92400e;
white-space: nowrap;
text-transform: capitalize;
}
.recipe-storage-days { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.recipe-storage-now { background: #fee2e2; border-color: #fca5a5; color: #b91c1c; }
.recipe-storage-tips {
font-size: 0.82rem;
color: #78350f;
margin: 2px 0 0;
line-height: 1.4;
}
.recipe-tools-banner {
display: flex;
flex-wrap: wrap;
@@ -4419,6 +4916,13 @@ body.server-offline .bottom-nav {
line-height: 1.3;
}
.recipe-ing-stock {
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.35;
opacity: 0.9;
}
/* ===== SHOPPING SECTION (REPARTO) HEADERS ===== */
.shopping-section-divider {
display: flex;
@@ -5939,6 +6443,12 @@ body.cooking-mode-active .app-header {
}
.banner-anomaly .alert-banner-title { color: #9a3412; }
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
.alert-banner.banner-dup-loss {
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
border-color: #dc2626;
}
.banner-dup-loss .alert-banner-title { color: #991b1b; }
.banner-dup-loss .alert-banner-counter .banner-dot.active { background: #dc2626; }
.alert-banner.banner-no-expiry {
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
border-color: #16a34a;
@@ -7838,6 +8348,8 @@ body.cooking-mode-active .app-header {
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
[data-theme="dark"] .alert-banner.banner-dup-loss { background: #2a0808; border-color: #dc2626; }
[data-theme="dark"] .banner-dup-loss .alert-banner-title { color: #fca5a5; }
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
@@ -7908,6 +8420,18 @@ body.cooking-mode-active .app-header {
/* ── Recipe components ── */
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
[data-theme="dark"] .recipe-nutrition-block { background: #052e16; border-color: #166534; }
[data-theme="dark"] .recipe-section-heading { color: #4ade80; }
[data-theme="dark"] .recipe-storage-card .recipe-section-heading { color: #fbbf24; }
[data-theme="dark"] .recipe-nutrition-value { color: #4ade80; }
[data-theme="dark"] .recipe-nutrition-label { color: #94a3b8; }
[data-theme="dark"] .recipe-nutrition-note { color: #64748b; }
[data-theme="dark"] .recipe-nutrition-footnote { color: var(--text-muted); }
[data-theme="dark"] .recipe-storage-card { background: #1c1400; border-color: #78350f; }
[data-theme="dark"] .recipe-storage-badge { background: #2a1e00; border-color: #92400e; color: #fde68a; }
[data-theme="dark"] .recipe-storage-days { background: #0c1a2e; border-color: #1d4ed8; color: #93c5fd; }
[data-theme="dark"] .recipe-storage-now { background: #2a0a0a; border-color: #b91c1c; color: #fca5a5; }
[data-theme="dark"] .recipe-storage-tips { color: #fde68a; }
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
+2759 -687
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
/**
* EverShelf core API token storage and auth headers.
*/
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
function getApiToken() {
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
}
function setApiToken(token) {
const t = (token || '').trim();
if (t) {
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
} else {
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
}
}
function apiAuthHeaders() {
const fromStorage = getApiToken();
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
const token = fromSettingsField || fromStorage;
if (!token) return {};
return { 'X-API-Token': token };
}
/** Fetch API token from server when loading the UI from the same origin. */
async function ensureApiToken() {
if (getApiToken()) return true;
try {
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
if (!res.ok) return false;
const data = await res.json();
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token) {
setApiToken(data.api_token);
return true;
}
} catch (_) { /* offline / network */ }
return !!getApiToken();
}
function _promptApiTokenIfNeeded() {
if (!window._apiTokenRequired) return;
if (getApiToken()) return;
const existing = document.getElementById('api-token-overlay');
if (existing) return;
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
const overlay = document.createElement('div');
overlay.id = 'api-token-overlay';
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal-content" style="max-width:420px;padding:20px">
<h3>${title}</h3>
<p class="settings-hint">${hint}</p>
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('api-token-save').onclick = () => {
const v = document.getElementById('api-token-input').value.trim();
if (v) {
setApiToken(v);
overlay.remove();
location.reload();
}
};
}
window.getApiToken = getApiToken;
window.setApiToken = setApiToken;
window.apiAuthHeaders = apiAuthHeaders;
window.ensureApiToken = ensureApiToken;
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
+11
View File
@@ -0,0 +1,11 @@
/**
* EverShelf core safe HTML escaping (loaded before app.js).
*/
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
File diff suppressed because one or more lines are too long
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.
+14 -8
View File
@@ -1,13 +1,19 @@
#!/bin/bash
# Daily backup of EverShelf database (local only)
# The database is NOT pushed to remote repositories.
# Runs via cron: creates a local timestamped backup copy
#
# Example crontab entry:
# 0 3 * * * /var/www/html/evershelf/backup.sh
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
set -euo pipefail
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKUP_DIR="${INSTALL_DIR}/data/backups"
ENV_FILE="${INSTALL_DIR}/.env"
RETENTION=3
if [ -f "$ENV_FILE" ]; then
val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2)
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
RETENTION="$val"
fi
fi
mkdir -p "$BACKUP_DIR"
@@ -19,5 +25,5 @@ fi
DATE=$(date '+%Y-%m-%d_%H%M')
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
# Keep only the last 7 backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
# Keep only the newest N backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
+2
View File
@@ -0,0 +1,2 @@
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
Require all denied
+38
View File
@@ -0,0 +1,38 @@
# EverShelf — Architecture (modular layout)
```
dispensa/
├── api/
│ ├── bootstrap.php # Shared init: env, security, DB, logger
│ ├── index.php # HTTP handlers + router (split planned per domain)
│ ├── database.php # SQLite schema & migrations
│ ├── logger.php # Rotating file logger (logs/)
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
│ ├── lib/
│ │ ├── env.php # .env loader
│ │ ├── constants.php # Paths & pricing constants
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
│ │ ├── github.php # Encrypted GitHub Issues token
│ │ └── cron_log.php # data/cron.log rotation
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
├── assets/
│ ├── js/
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
│ │ └── app.js # SPA logic (domain modules: future split)
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
├── data/ # Runtime data (.htaccess: deny all)
├── logs/ # Application logs (.htaccess: deny all)
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
```
## Security model
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
## Planned refactors
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
+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 {
@@ -101,6 +101,20 @@ class KioskActivity : AppCompatActivity() {
// Pending WebView permission request
private var pendingWebPermission: PermissionRequest? = null
private fun safeEvalJs(script: String) {
if (!::webView.isInitialized) return
if (isFinishing || isDestroyed) return
if (webView.visibility != View.VISIBLE) return
runCatching { webView.evaluateJavascript(script, null) }
.onFailure {
ErrorReporter.reportMessage(
type = "webview-js-bridge-error",
message = "Failed to deliver JS callback to WebView",
extra = mapOf("error" to (it.message ?: "unknown"))
)
}
}
companion object {
private const val FILE_CHOOSER_REQUEST = 1002
private const val PERMISSION_REQUEST_CODE = 1003
@@ -150,18 +164,18 @@ class KioskActivity : AppCompatActivity() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
}
}
@Deprecated("Deprecated in API 21")
override fun onError(utteranceId: String?) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
}
}
})
@@ -629,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")
@@ -643,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) {
@@ -693,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>
+78 -30
View File
@@ -11,9 +11,23 @@
<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=20260517a">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<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>
<!-- 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 -->
<script type="module">
// Lazy-load the embedding pipeline only when first needed.
@@ -25,11 +39,27 @@
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
window._categoryPipelinePromise = (async () => {
try {
const { pipeline, env } = await import(
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
);
// Keep WASM/model files in the browser cache; disable remote model check
// to avoid CORS issues with the self-hosted instance.
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'));
}
// 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(
@@ -64,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.25</span>
<span class="app-preloader-version" id="preloader-version">v1.7.41</span>
</div>
</div>
@@ -77,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.25</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.41</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -194,7 +224,7 @@
<!-- ===== INVENTORY LIST ===== -->
<section class="page" id="page-inventory">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
</div>
@@ -225,7 +255,7 @@
<!-- ===== SCAN PAGE ===== -->
<section class="page" id="page-scan">
<div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="scan.title">Scansiona</h2>
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
</div>
@@ -256,6 +286,14 @@
<span id="scan-status-method" class="scan-status-method"></span>
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
</div>
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
<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" data-i18n="scan.ai_overlay_label">Gemini Vision</span>
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
</div>
</div>
<!-- Success flash overlay -->
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
<div class="scan-confirm-check"></div>
@@ -274,6 +312,9 @@
<!-- Scan errors -->
<div class="scan-result" id="scan-result" style="display:none"></div>
<!-- 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">
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
@@ -333,7 +374,7 @@
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
<section class="page" id="page-action">
<div class="page-header">
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
</div>
<!-- Banner: shopping list scan context -->
@@ -356,7 +397,7 @@
<!-- ===== ADD TO INVENTORY FORM ===== -->
<section class="page" id="page-add">
<div class="page-header">
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
</div>
<div class="product-preview-small" id="add-product-preview"></div>
@@ -379,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>
@@ -419,7 +461,7 @@
<!-- ===== USE FROM INVENTORY FORM ===== -->
<section class="page" id="page-use">
<div class="page-header">
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="use.title">Usa / Consuma</h2>
</div>
<div class="product-preview-small" id="use-product-preview"></div>
@@ -458,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>
@@ -475,7 +520,7 @@
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
<section class="page" id="page-product-form">
<div class="page-header">
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 id="product-form-title">Nuovo Prodotto</h2>
</div>
<form class="form" onsubmit="submitProduct(event)">
@@ -663,7 +708,7 @@
<!-- ===== ALL PRODUCTS PAGE ===== -->
<section class="page" id="page-products">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
</div>
<div class="search-bar">
@@ -675,7 +720,7 @@
<!-- ===== RECIPE PAGE ===== -->
<section class="page" id="page-recipe">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
</div>
<div class="recipe-page-container">
@@ -689,7 +734,7 @@
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
<section class="page" id="page-shopping">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
</div>
<div class="shopping-container">
@@ -797,7 +842,7 @@
<!-- ===== AI IDENTIFICATION PAGE ===== -->
<section class="page" id="page-ai">
<div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
</div>
<div class="ai-container">
@@ -835,7 +880,7 @@
<!-- ===== SETTINGS PAGE ===== -->
<section class="page" id="page-settings">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
</div>
<div class="settings-tabs">
@@ -1183,6 +1228,9 @@
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
<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">
<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>
<!-- Security Tab -->
@@ -1192,10 +1240,10 @@
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<div class="form-group">
<label data-i18n="settings.security.token_label">Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
<input type="password" id="setting-settings-token" class="form-input" placeholder="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token API (API_TOKEN nel file .env). Il token viene salvato nel browser.</p>
</div>
<div class="settings-card">
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
@@ -1831,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
@@ -1941,6 +1989,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260518c"></script>
<script src="assets/js/app.js?v=20260606z"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+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.25",
"version": "1.7.41",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+9
View File
@@ -0,0 +1,9 @@
{
"name": "evershelf",
"private": true,
"scripts": {
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
"build": "npm run build:js && npm run build:css"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"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";
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
/**
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
*
* Usage:
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
*/
if ($argc < 3) {
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
exit(1);
}
require_once __DIR__ . '/../api/lib/github.php';
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Fix ownership and permissions for EverShelf runtime directories.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WEB_USER="${WEB_USER:-www-data}"
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
chmod 750 "${ROOT}/data" "${ROOT}/logs"
chmod 640 "${ROOT}/.env" 2>/dev/null || true
find "${ROOT}/data" -type f -exec chmod 660 {} \;
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
echo "Permissions updated for ${WEB_USER}"
+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/"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env php
<?php
/**
* One-time merge of duplicate product records (same normalized name + compatible brand).
* Opened-package splits remain as separate inventory rows on the canonical product.
*
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
*/
declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv, true);
$dbPath = __DIR__ . '/../data/evershelf.db';
if (!file_exists($dbPath)) {
fwrite(STDERR, "Database not found: $dbPath\n");
exit(1);
}
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
function normName(string $name): string {
return mb_strtolower(trim($name));
}
function normBrand(string $brand): string {
return mb_strtolower(trim($brand));
}
function brandsCompatible(string $a, string $b): bool {
$na = normBrand($a);
$nb = normBrand($b);
return $na === $nb || $na === '' || $nb === '';
}
function productScore(PDO $db, int $id): float {
$tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn();
$inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn();
return $tx * 10 + $inv;
}
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
$db->beginTransaction();
try {
$db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
$db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
$db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$byName = [];
foreach ($products as $p) {
$key = normName($p['name']);
if ($key === '') {
continue;
}
$byName[$key][] = $p;
}
$merged = 0;
foreach ($byName as $nameKey => $group) {
if (count($group) < 2) {
continue;
}
// Split into compatible-brand clusters
$clusters = [];
foreach ($group as $p) {
$placed = false;
foreach ($clusters as &$cluster) {
$ref = $cluster[0];
if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) {
$cluster[] = $p;
$placed = true;
break;
}
}
unset($cluster);
if (!$placed) {
$clusters[] = [$p];
}
}
foreach ($clusters as $cluster) {
if (count($cluster) < 2) {
continue;
}
usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id']));
$keep = (int)$cluster[0]['id'];
$keepName = $cluster[0]['name'];
for ($i = 1; $i < count($cluster); $i++) {
$drop = (int)$cluster[$i]['id'];
echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n";
if (!$dryRun) {
mergeProducts($db, $keep, $drop);
}
$merged++;
}
}
}
echo $dryRun
? "Dry run: $merged merge(s) would be performed.\n"
: "Done: $merged duplicate product(s) merged.\n";
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env php
<?php
/**
* One-time security migration: GitHub token encrypted .env, optional API_TOKEN.
*/
require_once __DIR__ . '/../api/lib/env.php';
require_once __DIR__ . '/../api/lib/github.php';
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
fwrite(STDERR, ".env not found\n");
exit(1);
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
$vars = loadEnv();
$changed = false;
// Migrate legacy XOR token from previous index.php if still in git history
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
$legacyKey = 'D1sp3ns4!Ev3r#26';
$encBin = hex2bin($legacyEnc);
$plain = '';
if ($encBin) {
for ($i = 0; $i < strlen($encBin); $i++) {
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
}
}
if ($plain !== '' && str_starts_with($plain, 'github_')) {
$newKey = bin2hex(random_bytes(16));
$enc = evershelfEncryptGhToken($plain, $newKey);
$lines[] = '';
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
$changed = true;
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
}
}
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
$token = bin2hex(random_bytes(24));
$lines[] = '';
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
$lines[] = 'API_TOKEN=' . $token;
$changed = true;
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
}
if ($changed) {
file_put_contents($envFile, implode("\n", $lines) . "\n");
chmod($envFile, 0640);
echo "Updated .env\n";
} else {
echo "No migration needed\n";
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Re-apply stock hints and 5% use-all rule to an archived recipe.
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
*/
define('CRON_MODE', true);
require __DIR__ . '/../api/index.php';
$id = (int)($argv[1] ?? 0);
if ($id <= 0) {
fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php <recipe_id>\n");
exit(1);
}
$db = getDB();
$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
fwrite(STDERR, "Recipe {$id} not found\n");
exit(1);
}
$recipe = json_decode($row['recipe_json'], true);
if (!is_array($recipe)) {
fwrite(STDERR, "Invalid recipe JSON for id {$id}\n");
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 = ?');
$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'])) {
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",
$ing['name'] ?? '?',
$ing['qty'] ?? '?',
$ing['stock_have'] ?? 0,
$ing['stock_unit'] ?? '',
$ing['stock_remain'] ?? 0,
$ing['stock_unit'] ?? '',
$useAll
);
}
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Sync translation files: ensure all locales have the same keys as it.json (reference)."""
from __future__ import annotations
import copy
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent / 'translations'
REF = 'it.json'
LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json']
# New keys added across all locales (nested path -> value per locale)
NEW_KEYS: dict[str, dict[str, str]] = {
'dashboard.banner_prediction_confirmed': {
'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni',
'en': '✅ Confirmed — forecasts will recalculate from your next entries',
'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet',
'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements',
'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros',
},
'dashboard.banner_anomaly_explain_fail': {
'it': 'Impossibile ottenere spiegazione AI',
'en': 'Could not get AI explanation',
'de': 'KI-Erklärung konnte nicht abgerufen werden',
'fr': 'Impossible d\'obtenir l\'explication IA',
'es': 'No se pudo obtener la explicación de IA',
},
'dashboard.banner_anomaly_dismissed': {
'it': 'Anomalia ignorata',
'en': 'Anomaly dismissed',
'de': 'Anomalie ignoriert',
'fr': 'Anomalie ignorée',
'es': 'Anomalía descartada',
},
'error.copy_failed': {
'it': 'Copia negli appunti non riuscita',
'en': 'Copy to clipboard failed',
'de': 'Kopieren in die Zwischenablage fehlgeschlagen',
'fr': 'Échec de la copie dans le presse-papiers',
'es': 'Error al copiar al portapapeles',
},
'error.invalid_quantity': {
'it': 'Quantità non valida',
'en': 'Invalid quantity',
'de': 'Ungültige Menge',
'fr': 'Quantité invalide',
'es': 'Cantidad no válida',
},
'dashboard.banner_finished_restore_prompt': {
'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})',
'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})',
'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})',
'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})',
'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})',
},
'time.just_now': {
'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora',
},
'time.seconds_ago': {
'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s',
},
'time.minutes_ago': {
'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min',
},
'time.hours_ago': {
'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h',
},
'time.days_ago': {
'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d',
},
'use.locations_short': {
'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones',
},
'move.moved_simple': {
'it': '📦 Spostato in {location}',
'en': '📦 Moved to {location}',
'de': '📦 Nach {location} verschoben',
'fr': '📦 Déplacé vers {location}',
'es': '📦 Movido a {location}',
},
'product.history_badge': {
'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial',
},
'ai.conservation_hint': {
'it': '🤖 AI: conserva in {location}',
'en': '🤖 AI: store in {location}',
'de': '🤖 KI: lagere in {location}',
'fr': '🤖 IA : conserve dans {location}',
'es': '🤖 IA: conserva en {location}',
},
'settings.kiosk_update_required': {
'it': '⚠️ Aggiorna il kiosk per usare questa funzione',
'en': '⚠️ Update the kiosk app to use this feature',
'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen',
'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction',
'es': '⚠️ Actualiza la app kiosk para usar esta función',
},
'shopping.bring_names_migrated': {
'it': '🔄 {n} nomi generalizzati in Bring!',
'en': '🔄 {n} names generalized in Bring!',
'de': '🔄 {n} Namen in Bring! verallgemeinert',
'fr': '🔄 {n} noms généralisés dans Bring !',
'es': '🔄 {n} nombres generalizados en Bring!',
},
'scan.mode_shopping_activated': {
'it': '🛒 Modalità Spesa attivata!',
'en': '🛒 Shopping mode activated!',
'de': '🛒 Einkaufsmodus aktiviert!',
'fr': '🛒 Mode courses activé !',
'es': '🛒 ¡Modo compras activado!',
},
'settings.scale.discover_scanning': {
'it': '🔍 Scansione rete locale per gateway bilancia…',
'en': '🔍 Scanning local network for scale gateway…',
'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…',
'fr': '🔍 Recherche du gateway balance sur le réseau local…',
'es': '🔍 Buscando pasarela de báscula en la red local…',
},
'settings.scale.discover_found': {
'it': '✅ Gateway trovato: {url}{more}',
'en': '✅ Gateway found: {url}{more}',
'de': '✅ Gateway gefunden: {url}{more}',
'fr': '✅ Gateway trouvé : {url}{more}',
'es': '✅ Pasarela encontrada: {url}{more}',
},
'settings.scale.discover_not_found': {
'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.',
'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.',
'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.',
'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.',
'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.',
},
'settings.scale.discover_failed': {
'it': '❌ Ricerca fallita: {error}',
'en': '❌ Discovery failed: {error}',
'de': '❌ Suche fehlgeschlagen: {error}',
'fr': '❌ Échec de la recherche : {error}',
'es': '❌ Búsqueda fallida: {error}',
},
'settings.scale.discover_auto': {
'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto',
},
'settings.scale.unknown_device': {
'it': 'Dispositivo sconosciuto',
'en': 'Unknown device',
'de': 'Unbekanntes Gerät',
'fr': 'Appareil inconnu',
'es': 'Dispositivo desconocido',
},
'product.from_history': {
'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)',
},
'recipes.ing_stock_line': {
'it': 'Hai {have} · restano {remain} dopo l\'uso',
'en': 'You have {have} · {remain} left after use',
'de': 'Du hast {have} · {remain} bleiben nach Gebrauch',
'fr': 'Vous avez {have} · il reste {remain} après usage',
'es': 'Tienes {have} · quedan {remain} después del uso',
},
'recipes.ing_use_all_note': {
'it': 'uso totale (<5% della confezione intera)',
'en': 'use all (<5% of full package left)',
'de': 'alles verwenden (<5% der Vollpackung)',
'fr': 'tout utiliser (<5% du conditionnement entier)',
'es': 'usar todo (<5% del envase completo)',
},
}
# fr/es gaps filled with proper translations (flat key -> value)
FR_FILL: dict[str, str] = {
'action.related_stock_title': 'Aussi à la maison',
'dashboard.banner_expired_action_modify': 'Modifier',
'dashboard.banner_expired_action_vacuum': 'Mettre sous vide',
'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.',
'scan.stock_in_pantry': 'Déjà à la maison :',
'scanner.expiry_found': 'Date trouvée',
'scanner.expiry_raw_label': 'Lu',
'scanner.expiry_read_fail': 'Impossible de lire la date.',
'settings.info.act_new_products': 'Nouveaux produits',
'settings.info.act_restock': 'Réapprovisionnements',
'settings.info.act_title': 'Activité mensuelle',
'settings.info.act_tx_month': 'Mouvements',
'settings.info.act_tx_year': 'Mouvements annuels',
'settings.info.act_use': 'Utilisations',
'settings.info.ai_calls': 'Appels',
'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.',
'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système',
'settings.info.ai_title': 'Gemini AI — Utilisation des tokens',
'settings.info.bring_days': 'jeton expire dans {n} jours',
'settings.info.bring_expired': 'jeton expiré',
'settings.info.by_action': 'Répartition par fonction',
'settings.info.by_model': 'Répartition par modèle',
'settings.info.cache_entries': 'produits',
'settings.info.calls_unit': 'appels',
'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.',
'settings.info.currency_title': 'Devise',
'settings.info.db_size': 'Base de données',
'settings.info.est_cost': 'Coût est.',
'settings.info.input_tok': 'Tokens entrée',
'settings.info.inv_active': 'Actifs',
'settings.info.inv_expired': 'Expirés',
'settings.info.inv_expiring': 'Expirent (7j)',
'settings.info.inv_finished': 'Terminés',
'settings.info.inv_products': 'Produits totaux',
'settings.info.inv_title': 'Inventaire',
'settings.info.last_backup': 'Dernière sauvegarde',
'settings.info.loading': 'Chargement…',
'settings.info.log_level': 'Niveau de log',
'settings.info.log_size': 'Logs',
'settings.info.output_tok': 'Tokens sortie',
'settings.info.price_cache': 'Cache prix',
'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
'settings.info.system_title': 'Système',
'settings.info.tab': 'Info',
'settings.info.total_tokens': 'Tokens totaux',
'settings.info.year_label': 'Année {year}',
'settings.tab_general': 'Général',
'settings.tts.test_sound_btn': '🔔 Test sonore',
'shopping.pantry_hint': 'Déjà à la maison : {qty}',
'startup.check_db_legacy': 'Ancienne BD (dispensa.db)',
'startup.check_scale': 'Passerelle balance',
'startup.check_tts': 'URL synthèse vocale',
'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :',
'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.',
'toast.vacuum_sealed': '{name} enregistré sous vide',
}
ES_FILL = {
'action.related_stock_title': 'También en casa',
'dashboard.banner_expired_action_modify': 'Editar',
'dashboard.banner_expired_action_vacuum': 'Poner al vacío',
'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.',
'scan.stock_in_pantry': 'Ya en despensa:',
'scanner.expiry_found': 'Fecha encontrada',
'scanner.expiry_raw_label': 'Leído',
'scanner.expiry_read_fail': 'No se puede leer la fecha.',
'settings.info.act_new_products': 'Productos nuevos',
'settings.info.act_restock': 'Reabastecimientos',
'settings.info.act_title': 'Actividad mensual',
'settings.info.act_tx_month': 'Movimientos',
'settings.info.act_tx_year': 'Movimientos anuales',
'settings.info.act_use': 'Usos',
'settings.info.ai_calls': 'Llamadas',
'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.',
'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema',
'settings.info.ai_title': 'Gemini AI — Uso de tokens',
'settings.info.bring_days': 'token expira en {n} días',
'settings.info.bring_expired': 'token expirado',
'settings.info.by_action': 'Desglose por función',
'settings.info.by_model': 'Desglose por modelo',
'settings.info.cache_entries': 'productos',
'settings.info.calls_unit': 'llamadas',
'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.',
'settings.info.currency_title': 'Moneda',
'settings.info.db_size': 'Base de datos',
'settings.info.est_cost': 'Coste est.',
'settings.info.input_tok': 'Tokens de entrada',
'settings.info.inv_active': 'Activos',
'settings.info.inv_expired': 'Caducados',
'settings.info.inv_expiring': 'Caducan (7d)',
'settings.info.inv_finished': 'Agotados',
'settings.info.inv_products': 'Productos totales',
'settings.info.inv_title': 'Inventario',
'settings.info.last_backup': 'Última copia',
'settings.info.loading': 'Cargando…',
'settings.info.log_level': 'Nivel de log',
'settings.info.log_size': 'Logs',
'settings.info.output_tok': 'Tokens de salida',
'settings.info.price_cache': 'Caché de precios',
'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
'settings.info.system_title': 'Sistema',
'settings.info.tab': 'Info',
'settings.info.total_tokens': 'Tokens totales',
'settings.info.year_label': 'Año {year}',
'settings.tab_general': 'General',
'settings.tts.test_sound_btn': '🔔 Prueba de sonido',
'shopping.pantry_hint': 'Ya en casa: {qty}',
'startup.check_db_legacy': 'BD antigua (dispensa.db)',
'startup.check_scale': 'Pasarela báscula',
'startup.check_tts': 'URL texto a voz',
'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:',
'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.',
'toast.vacuum_sealed': '{name} guardado al vacío',
}
def flatten(obj: dict, prefix: str = '') -> dict[str, str]:
out: dict[str, str] = {}
for k, v in obj.items():
key = f'{prefix}.{k}' if prefix else k
if isinstance(v, dict):
out.update(flatten(v, key))
else:
out[key] = v
return out
def set_nested(root: dict, dotted: str, value: str) -> None:
parts = dotted.split('.')
d = root
for p in parts[:-1]:
d = d.setdefault(p, {})
d[parts[-1]] = value
def main() -> None:
ref = json.loads((ROOT / REF).read_text(encoding='utf-8'))
ref_flat = flatten(ref)
en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8')))
for fname in LOCALES:
lang = fname.replace('.json', '')
path = ROOT / fname
data = json.loads(path.read_text(encoding='utf-8'))
flat = flatten(data)
# Fill missing keys from reference (Italian text as last resort via en)
for key, ref_val in ref_flat.items():
if key not in flat:
if lang == 'fr' and key in FR_FILL:
val = FR_FILL[key]
elif lang == 'es' and key in ES_FILL:
val = ES_FILL[key]
elif lang == 'en':
val = en_flat.get(key, ref_val)
else:
val = en_flat.get(key, ref_val)
set_nested(data, key, val)
flat[key] = val
# Inject new keys
for key, per_lang in NEW_KEYS.items():
set_nested(data, key, per_lang[lang if lang in per_lang else 'en'])
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(f'Updated {fname}')
if __name__ == '__main__':
main()
+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";
+1558 -1450
View File
File diff suppressed because it is too large Load Diff
+1563 -1450
View File
File diff suppressed because it is too large Load Diff
+1558 -1393
View File
File diff suppressed because it is too large Load Diff
+1558 -1393
View File
File diff suppressed because it is too large Load Diff
+1563 -1449
View File
File diff suppressed because it is too large Load Diff