Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 51f55071fa ci: bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 00:23:13 +00:00
71 changed files with 3407 additions and 13858 deletions
-5
View File
@@ -1,5 +0,0 @@
node_modules/
.git/
dist/
build/
*.log
+4 -18
View File
@@ -125,24 +125,10 @@ GDRIVE_FOLDER_ID=
GDRIVE_RETENTION_DAYS=30 GDRIVE_RETENTION_DAYS=30
# ── Security ───────────────────────────────────────────────────────────────── # ── Security ─────────────────────────────────────────────────────────────────
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA). # SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs. # Leave empty to allow anyone with access to the server to change settings.
API_TOKEN=
SETTINGS_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 # INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
# for Zeroconf discovery label and device name in Home Assistant). # for Zeroconf discovery label and device name in Home Assistant).
# Defaults to the server hostname if left empty. # Defaults to the server hostname if left empty.
@@ -174,5 +160,5 @@ HA_EXPIRY_DAYS=3
# DEMO_MODE: when true, all write operations are blocked (for public demos) # DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false DEMO_MODE=false
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB) # NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
CRON_LOG_MAX_BYTES=524288 # To rotate it, update the GH_ISSUE_TOKEN constant there.
+2 -18
View File
@@ -37,10 +37,8 @@ jobs:
id: version id: version
run: | run: |
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+') 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 "name=$VERSION" >> "$GITHUB_OUTPUT"
echo "code=$VCODE" >> "$GITHUB_OUTPUT" echo "Kiosk version: $VERSION"
echo "Kiosk version: $VERSION (versionCode $VCODE)"
- name: Build debug APK - name: Build debug APK
run: gradle assembleDebug --no-daemon run: gradle assembleDebug --no-daemon
@@ -77,21 +75,7 @@ jobs:
sleep 3 sleep 3
gh release create kiosk-latest \ gh release create kiosk-latest \
--title "EverShelf Kiosk Latest" \ --title "EverShelf Kiosk Latest" \
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \ --notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
--prerelease \ --prerelease \
artifacts/evershelf-kiosk.apk 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 }}
+2 -13
View File
@@ -43,18 +43,7 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Build Docker image - name: Build Docker image
run: | run: docker build -t evershelf-test .
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 - name: Test container starts
run: | run: |
@@ -217,7 +206,7 @@ jobs:
- name: Create release - name: Create release
if: steps.tag_check.outputs.exists == 'false' if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.version }}
name: "EverShelf ${{ steps.version.outputs.version }}" name: "EverShelf ${{ steps.version.outputs.version }}"
-1
View File
@@ -52,4 +52,3 @@ data/food_facts_cache.json
data/category_ai_cache.json data/category_ai_cache.json
assets/img/logo/*_backup.* assets/img/logo/*_backup.*
logs/*.log logs/*.log
assets/vendor/transformers/Xenova/
+3 -18
View File
@@ -1,23 +1,8 @@
RewriteEngine On RewriteEngine On
# Block sensitive files (Apache 2.4+) # Force HTTPS
<Files ".env"> RewriteCond %{HTTPS} !=on
Require all denied RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</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 # API routing
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
-105
View File
@@ -11,111 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.42] - 2026-06-11
### Added
- **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES.
- **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage.
- **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments.
### Fixed
- **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context.
- **SQLite database locked (#201#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`.
- **Smart shopping timeout (#203#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories.
- **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`.
- **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented.
## [1.7.41] - 2026-06-08
### Fixed
- **Docker/Traefik “Impossibile contattare il server”** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production.
- **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually.
- **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs.
## [1.7.40] - 2026-06-08
### Added
- **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”.
- **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list.
- **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen.
- **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue).
- **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token.
- **Ops CLI scripts** — `audit-finished-shopping.php`, `backfill-finished-shopping.php`, `sync-shopping-bring.php`, `install-transformers-model.sh` (offline Xenova classifier bootstrap).
### Fixed
- **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention.
- **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode.
- **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron).
- **Spesa scan crash** — `currentPage``_currentPageId` in `_applySpesaScanUI`.
- **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes.
- **Smart shopping purchased block** — Server-side blocklist + spesa mode sync prevents cron from re-adding bought items.
### Changed
- **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops.
## [1.7.39] - 2026-06-06
### Added
- **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans.
- **Spesa barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page).
- **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip.
- **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated.
- **Family sibling 24h dedup** — After confirming “Sì, tutto ok” on a similar in-stock product, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings).
- **Family sibling stock line** — Spesa prompt shows readable stock (e.g. `4 conf (da 20g)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES.
- **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page.
### Fixed
- **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched``enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes.
- **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included).
- **Spesa ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity.
- **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned.
- **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay.
- **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after spesa add.
### Changed
- **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~12s first hit); 30-minute negative cache for unknown codes.
- **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes.
## [1.7.38] - 2026-06-04
### Fixed
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
### Changed
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
## [1.7.37] - 2026-06-04
### Fixed
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
### Changed
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
## [1.7.36] - 2026-06-04
### Added
- **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 ## [1.7.35] - 2026-06-02
### Fixed ### Fixed
+2 -7
View File
@@ -6,12 +6,10 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
libonig-dev \ libonig-dev \
libgd-dev \ libgd-dev \
libzip-dev \
libicu-dev \
tesseract-ocr \ tesseract-ocr \
tesseract-ocr-ita \ tesseract-ocr-ita \
tesseract-ocr-eng \ tesseract-ocr-eng \
&& docker-php-ext-install pdo_sqlite curl mbstring gd zip intl \ && docker-php-ext-install pdo_sqlite curl mbstring gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*
# Enable Apache mod_rewrite and mod_headers # Enable Apache mod_rewrite and mod_headers
@@ -30,15 +28,12 @@ RUN mkdir -p /var/www/html/data/backups \
# Create .env from example if it doesn't exist (will be overridden by volume mount) # Create .env from example if it doesn't exist (will be overridden by volume mount)
RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.env || true RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.env || true
RUN chown www-data:www-data /var/www/html/.env && chmod 664 /var/www/html/.env
# Apache configuration: serve from app root # Apache configuration: serve from app root
RUN echo '<Directory /var/www/html>\n\ RUN echo '<Directory /var/www/html>\n\
AllowOverride All\n\ AllowOverride All\n\
Require all granted\n\ Require all granted\n\
</Directory>\n\ </Directory>' > /etc/apache2/conf-available/evershelf.conf \
# Traefik / reverse-proxy: treat forwarded HTTPS as on so .htaccess does not redirect-loop\n\
SetEnvIf X-Forwarded-Proto "https" HTTPS=on' > /etc/apache2/conf-available/evershelf.conf \
&& a2enconf evershelf && a2enconf evershelf
# Expose port 80 # Expose port 80
+491 -164
View File
@@ -1,223 +1,550 @@
# 🏠 EverShelf for Ricardo # 🏠 EverShelf
> Fork personnalisé d'EverShelf, adapté pour servir de backend stock/recettes à **Ricardo**, l'application bartender. Garde toutes les fonctionnalités d'EverShelf, avec des ajustements pour la gestion de bar (catégorie boissons, sous-catégories alcools, intégration directe avec Ricardo). > **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
---
<div align="center">
### 🚀 Try the live demo — no installation required!
**[▶ Open Live Demo](https://evershelfproject.dadaloop.it/demo)**
&nbsp;·&nbsp;
[🌐 Project Website](https://evershelfproject.dadaloop.it/)
&nbsp;·&nbsp;
[📖 Wiki](https://github.com/dadaloop82/EverShelf/wiki)
*The demo runs with mock pantry data. AI features are fully enabled. All write operations are safely sandboxed.*
</div>
--- ---
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/) [![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Compatible-2496ED.svg)](Dockerfile) [![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.33-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)
[![GitHub Discussions](https://img.shields.io/github/discussions/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/discussions)
[![CI](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml/badge.svg)](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J01ZNETZ)
--- ---
## ✨ Fonctionnalités principales > **⚠️ Name disambiguation:** There is an unrelated iOS app also called **EverShelf**, developed and published by [Joshumi Technologies LLC](https://evershelf.joshumi.com/) on the [Apple App Store](https://apps.apple.com/app/evershelf/id6759439940). That application is a **completely separate, independent product** with no affiliation, association, or collaboration with this open-source project. This repository has no connection to Joshumi Technologies LLC, its products, or its services.
### 📦 Gestion des stocks
- Inventaire alimentaire complet
- Gestion des emplacements :
- 🏠 Placard
- ❄️ Réfrigérateur
- 🧊 Congélateur
- 📍 Emplacements personnalisés, entièrement gérés depuis une page **🔧 Configuration** dédiée (ajout, modification, suppression sans toucher au code)
- Sous-catégorie dédiée aux boissons (vin, bière, spiritueux, soda, jus, eau...) pour filtrer et trier l'inventaire plus précisément
- Scan de codes-barres avec la caméra du téléphone
- Ajout rapide de produits
- Suivi des dates de péremption
- Gestion des produits ouverts
- Support des produits sous vide
- Détection des incohérences de stock
--- ---
## 🤖 Intelligence artificielle (Google Gemini) ## ✨ Features
EverShelf peut utiliser l'IA pour : ### 🏠 NEW — Home Assistant Integration
- 📸 Identifier un produit depuis une photo EverShelf has a **native Home Assistant integration** available on HACS.
- 📅 Lire automatiquement une date limite de consommation Connect your pantry to your smart home in minutes — no YAML, no manual sensor setup.
- 🧊 Proposer un stockage adapté
- 🍳 Générer des recettes selon votre inventaire
- 💬 Répondre aux questions sur vos produits
- 🛒 Améliorer les suggestions de courses
> L'IA est optionnelle. EverShelf fonctionne sans clé Gemini. [![Install via HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
&nbsp;
[![Add Integration](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
**What you get:**
| | |
|---|---|
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
| **5 languages** | English, Italian, German, French, Spanish |
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
--- ---
## 🛒 Liste de courses intelligente ### 📦 Inventory Management
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS; last 20 scanned products saved as tappable chips so you can re-select them without rescanning
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items; products sealed under vacuum are only flagged as expired after a configurable grace period past the printed date (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days, configurable in `.env`)
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
- Création automatique depuis les ruptures de stock ### 🤖 AI-Powered (Google Gemini)
- Prévisions de besoins - **Expiry date reading** — Photograph a label and extract the expiry date automatically
- Synchronisation avec Bring! - **Product identification** — Point your camera at any product for instant recognition
- Nettoyage automatique des doublons - **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
- Suggestions d'achat personnalisées - **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
- **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
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
### 🛒 Shopping List
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
- **Generic shopping names** — Products are grouped by type (e.g. "Milk", "Cold cuts", "Cooking cream") rather than brand, keeping the Bring! list clean and consolidated
- **Smart predictions** — Know what you'll need before you run out
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
### 🍳 Cooking Mode
- **♻️ Zero-waste tips** — For each cooking step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.), a dismissible ♻️ tip card appears with a practical reuse idea; tips are generated by Gemini as part of the recipe at no extra API cost; opt-in toggle in Settings (default OFF)
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
- **Recipe completion** — "Bon appétit!" announced via TTS when the last step is confirmed
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
### 📊 Dashboard
- **Waste tracking** — Monitor consumed vs. wasted products over 30 days
- **Anti-waste report** — Personalised waste rate vs. national average with annual kg estimate; shown above the expiring-items list
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and a discard action as the primary action
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
- **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
### 🌙 Appearance
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
- **Global settings tab** — A dedicated **⚙️ General** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
### Database Maintenance
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
- **Manual cleanup** — Trigger immediately via `GET /api/?action=db_cleanup`
- **Compact by default** — Fresh installs stay small; large accumulated databases shrink back to a few hundred KB within one cron cycle
### 📱 Progressive Web App
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
- **Installable** — Add to home screen for a native app experience
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
### 📶 Offline Mode
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
- **Auto-discovery** — Server scans LAN to find the gateway automatically
- **Auto weight reading** — When adding/using a product with unit g/ml, weight fills automatically
- **10g threshold** — Ignores readings that haven't changed enough between products - **Duplicate-reading prevention** — Server-side 12-second dedup window rejects a second scale-triggered deduction of the same product, guarding against BLE multi-fire- **ml conversion hint** — Shows "weight in grams → will be converted to ml" when product unit is ml
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
- **Real-time status** — Scale connection indicator always visible in the header
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
### 📺 Android Kiosk Mode (Add-on)
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
- **True kiosk lock** — Screen pinning blocks home/recent buttons
- **Setup wizard** — 6-step guided configuration (language, welcome, permissions, server URL, BLE scale scan, screensaver, summary)
- **Smart auto-discovery** — Scans the LAN in parallel (60 threads, TCP pre-check, ports 80/443/8080/8443) with real-time UI feedback; correctly identifies the device's Wi-Fi/Ethernet subnet (VPN and cellular interfaces are filtered out)
- **Built-in BLE scale gateway** — `GatewayService` foreground service; BLE scanning + WebSocket server `:8765` run directly inside the kiosk app. Select your scale in step 5 of the wizard — no external app required
- **Scale auto-configuration** — After selecting the BLE device, the wizard writes `scale_enabled` and `scale_gateway_url=ws://127.0.0.1:8765` to the server automatically
- **Camera & mic permissions** — Full hardware access for barcode scanning and voice; grant button transforms to a green confirmation after granting
- **Native TTS bridge** — Cooking mode voice readout uses the Android TextToSpeech engine directly, bypassing Web Speech API voice limitations; no offline voice packs required
- **Hard refresh** — ↻ button clears WebView cache to pick up web app updates
- **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available
- **SSL support** — Accepts self-signed certificates
- **Android kiosk app** — [`evershelf-kiosk/`](evershelf-kiosk/) — downloadable APK
--- ---
## 🍳 Mode cuisine ## 🚀 Quick Start
- Recettes étape par étape ### Prerequisites
- Mode mains libres - **Web server** with PHP 8.0+ (Apache or Nginx)
- Synthèse vocale (TTS) - **PHP extensions**: `pdo_sqlite`, `curl`, `mbstring`, `json`
- Minuteurs automatiques - **HTTPS** recommended (required for camera access on mobile)
- Suivi des ingrédients utilisés
- Conseils anti-gaspillage
--- ### Installation
## ♻️ Réduction du gaspillage #### Option A: Docker (recommended)
- Suivi des aliments consommés ou jetés
- Analyse des pertes
- Alertes de péremption
- Suggestions pour utiliser les produits bientôt périmés
---
## 🏡 Intégrations
### Home Assistant
Intégration native disponible :
- Capteurs de stock
- Dates de péremption
- Liste de courses
- Calendrier des produits
- Actions personnalisées
- Suggestions de recettes IA
Compatible avec une installation 100% locale.
---
## 📱 Application mobile / PWA
- Interface adaptée smartphone
- Installation comme une application
- Synchronisation multi-appareils
- Mode hors-ligne :
- consultation du stock
- actions mises en attente
- synchronisation automatique au retour réseau
---
## 📺 Mode tablette (Kiosque Android)
- Affichage plein écran
- Verrouillage kiosque
- Scan caméra
- Support TTS natif Android
- Découverte automatique du serveur
- Support des balances Bluetooth
---
## 🚀 Installation rapide
### Prérequis
- PHP 8.0+
- SQLite 3
- Extensions PHP :
- `pdo_sqlite`
- `curl`
- `mbstring`
- `json`
Docker est recommandé.
---
### 🐳 Installation Docker (CLI)
```bash ```bash
git clone https://git.mashome.fr/morgane/EverShelf.git # 1. Clone the repository
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf cd EverShelf
# 2. Create configuration file
cp .env.example .env cp .env.example .env
nano .env nano .env
# 3. Start with Docker Compose
docker compose up -d docker compose up -d
# → Open http://localhost:8080
``` ```
Puis ouvrez : #### Option B: Manual
http://localhost:8080
--- ```bash
### 🐳 Déploiement via Portainer # 1. Clone the repository
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf
1. Dans Portainer, va dans **Stacks****Add stack** # 2. Create configuration file
2. Donne un nom à la stack (ex : `evershelf`) cp .env.example .env
3. Colle le contenu de ton `docker-compose.yml` dans l'éditeur web (ou utilise l'option **Repository** en pointant vers `https://git.mashome.fr/morgane/EverShelf.git` et le chemin du fichier compose)
4. Renseigne tes variables d'environnement dans la section **Environment variables** (ou via un fichier `.env` à la racine du repo)
5. Clique sur **Deploy the stack**
Pour mettre à jour après une modification de code : # 3. Set permissions
- Va dans **Stacks** → ta stack → **Update the stack** chmod 755 data/
- Coche bien **« Re-pull image »** / **« Re-build image »** avant de valider — sinon Portainer redémarre le conteneur avec l'image déjà construite en cache, sans prendre en compte tes changements. chmod 664 data/.gitkeep
chown -R www-data:www-data data/
--- # 4. Edit your configuration
nano .env
## ⚙️ Configuration
Exemple de fichier `.env` :
```env
# IA Google Gemini (optionnel)
GEMINI_API_KEY=votre_cle
# Bring! (optionnel)
BRING_EMAIL=email@example.com
BRING_PASSWORD=motdepasse
# Sécurité API
API_TOKEN=
# Nettoyage automatique
RECIPE_RETENTION_DAYS=7
TRANSACTION_RETENTION_DAYS=90
``` ```
--- ### Configuration (.env)
## 🔒 Vie privée ```ini
# Required for AI features (get a key at https://aistudio.google.com/app/apikey)
GEMINI_API_KEY=your_api_key_here
EverShelf est conçu pour fonctionner en auto-hébergement : # Optional: Bring! shopping list integration
BRING_EMAIL=your_email@example.com
BRING_PASSWORD=your_password
- Pas de compte obligatoire # Optional: Text-to-Speech for cooking mode
- Pas de cloud imposé TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
- Données stockées localement TTS_TOKEN=your_long_lived_token
- SQLite comme base de données TTS_ENABLED=true
- Les fonctions IA utilisent uniquement les services configurés par l'utilisateur
# 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
# Optional: Vacuum-sealed expiry grace period
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
# Optional: Gemini cost rates (USD per million tokens, for the Info tab cost estimate)
GEMINI_COST_25F_IN=0.15
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
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
DEMO_MODE=false
# Optional: Logging
# LOG_LEVEL sets the minimum severity written to disk (DEBUG / INFO / WARN / ERROR)
# DEBUG also logs every SQL query executed against the database
LOG_LEVEL=INFO
LOG_ROTATE_HOURS=24 # hours before opening a new log file (default: 24)
LOG_MAX_FILES=14 # maximum number of rotated files to keep (default: 14)
```
### Web Server Configuration
<details>
<summary><strong>Apache (.htaccess)</strong></summary>
The app works out of the box with Apache if placed in the web root or a subdirectory. Make sure `mod_rewrite` is enabled and `AllowOverride All` is set.
```apache
<Directory /var/www/html/evershelf>
AllowOverride All
Require all granted
</Directory>
```
</details>
<details>
<summary><strong>Nginx</strong></summary>
```nginx
server {
listen 80;
server_name your-server.local;
root /var/www/html/evershelf;
index index.html;
location /api/ {
try_files $uri $uri/ =404;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
# Deny access to sensitive files
location ~ /\.env { deny all; }
location ~ /data/ { deny all; }
location ~ /backup\.sh { deny all; }
}
```
</details>
### HTTPS Setup (Recommended)
Camera access requires HTTPS on most mobile browsers. Options:
- **Let's Encrypt** with Certbot (for public-facing servers)
- **Self-signed certificate** (for local network only)
- **Reverse proxy** (e.g., Caddy, Traefik) with automatic TLS
### Cron Job (Optional)
Set up a cron job for smart shopping predictions:
```bash
# Run every 5 minutes
*/5 * * * * php /path/to/evershelf/api/cron_smart_shopping.php >> /path/to/evershelf/data/cron.log 2>&1
```
### Backup (Optional)
The included `backup.sh` creates local daily backups of your database:
```bash
# Run daily at 3 AM
0 3 * * * /path/to/evershelf/backup.sh
```
### Google Drive Backup (Optional)
EverShelf supports automatic daily backups to Google Drive via OAuth 2.0. This works on any server, including private IP / local network setups (no public domain required).
**Setup:**
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select or create a project.
2. Enable the **Google Drive API** (`APIs & Services → Enable APIs → Google Drive API`).
3. Go to `APIs & Services → Credentials → Create Credentials → OAuth client ID`.
4. Application type: **Web application**.
5. Add **`http://localhost`** as an Authorized Redirect URI (this is the key — it works even without a real domain).
6. Copy **Client ID** and **Client Secret** into EverShelf Settings → Backup.
7. Enter your **Google Drive Folder ID** (the last part of the folder URL).
8. Click **Authorize with Google** and sign in.
9. The browser will redirect to `http://localhost` and may show a connection error — **this is expected**. Copy the full URL from the address bar (e.g. `http://localhost/?code=4%2F0A...`) and paste it into the field that appears in EverShelf, then click **Submit**.
> **Note:** While the OAuth app is in *Testing* status in Google Cloud Console, you must add your Google account as a test user under `APIs & Services → OAuth consent screen → Test users`.
--- ---
## 🛠 Développement ## 🏗 Architecture
Technologies principales : ```
evershelf/
├── index.html # Single-page application (SPA)
├── manifest.json # PWA manifest
├── .env.example # Configuration template
├── backup.sh # Local database backup script
├── LICENSE # MIT License
├── api/
│ ├── index.php # Main API router (all endpoints)
│ ├── database.php # SQLite schema, migrations, helpers
│ └── cron_smart_shopping.php # Background job for predictions
├── assets/
│ ├── css/style.css # All application styles
│ ├── js/app.js # All application logic
│ └── img/ # Static images
└── data/ # Runtime data (gitignored)
├── evershelf.db # SQLite database (auto-created)
├── backups/ # Local DB backups
└── *.json # Token/cache files
- PHP evershelf-scale-gateway/ # ⚖️ Android BLE gateway [DEPRECATED — integrated into kiosk v1.6.0+]
- SQLite ├── README.md # Deprecation notice + legacy docs
- JavaScript └── app/src/ # Kotlin Android source (WebSocket + BLE)
- HTML/CSS
- Docker evershelf-kiosk/ # 📺 Android kiosk app (add-on)
├── README.md # Setup & feature docs
└── app/src/ # Kotlin Android source (WebView wrapper)
```
### API Endpoints
| Category | Action | Method | Description |
|----------|--------|--------|-------------|
| **Products** | `search_barcode` | GET | Find product by barcode |
| | `lookup_barcode` | GET | Look up barcode on Open Food Facts |
| | `product_save` | POST | Create or update a product |
| | `products_list` | GET | List all products |
| **Inventory** | `inventory_list` | GET | List inventory items |
| | `inventory_add` | POST | Add product to inventory |
| | `inventory_use` | POST | Use/consume from inventory |
| | `inventory_summary` | GET | Count by location |
| **AI** | `gemini_identify` | POST | Identify product from photo |
| | `gemini_expiry` | POST | Read expiry date from photo |
| | `gemini_chat` | POST | Chat with AI assistant |
| | `generate_recipe` | POST | Generate recipe from inventory |
| | `gemini_product_hint` | POST | Storage location + shelf-life hint |
| | `gemini_shopping_enrich` | POST | Enrich shopping suggestions with tips |
| | `gemini_anomaly_explain` | POST | Plain-language anomaly explanation |
| **Shopping** | `bring_list` | GET | Get Bring! shopping list |
| | `bring_add` | POST | Add items to Bring! |
| | `smart_shopping` | GET | Smart shopping predictions |
| **Settings** | `get_settings` | GET | Get server configuration |
| | `save_settings` | POST | Update server configuration |
--- ---
## 📜 Licence ## 🔒 Security Notes
Projet sous licence MIT. - **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
- **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)
- Consider adding **reverse-proxy authentication** (e.g. Authelia, Nginx `auth_basic`) if the server is accessible from the internet
--- ---
## 🙏 Crédits ## 🛠️ Development
Ce fork, **EverShelf for Ricardo**, est maintenu par Morgane pour servir de système de gestion de stock/recettes à l'application **Ricardo**. ```bash
# Run PHP's built-in server for local development
php -S localhost:8080 -t /path/to/evershelf
Projet original : # Check PHP syntax
php -l api/index.php
php -l api/database.php
```
https://github.com/dadaloop82/EverShelf The application uses no build tools — edit files directly and refresh.
Ce dépôt contient des améliorations et adaptations personnelles, incluant un système d'export/import avec fusion intelligente des données. ---
## 📋 Roadmap
Feature requests, bug reports and planned work are tracked in the [**EverShelf Roadmap**](https://github.com/users/dadaloop82/projects/2) GitHub Project.
---
## 🌐 Translations
The app supports multiple languages via JSON translation files in the `translations/` folder.
| Language | Status |
|----------|--------|
| 🇮🇹 Italian (it) | ✅ Complete (base) |
| 🇬🇧 English (en) | ✅ Complete |
| 🇩🇪 German (de) | ✅ Complete |
| 🇫🇷 French (fr) | ✅ Complete |
| 🇪🇸 Spanish (es) | ✅ Complete |
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
---
## 🤝 Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/my-feature`)
3. Commit your changes (`git commit -m 'Add my feature'`)
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.
```
translations/
├── it.json ✅ Italian (base)
├── en.json ✅ English
├── de.json ✅ German
├── fr.json ✅ French
├── es.json ✅ Spanish
├── pt.json ❌ Portuguese — wanted!
├── nl.json ❌ Dutch — wanted!
└── ... ❌ Your language here!
```
👉 See [issue #93](https://github.com/dadaloop82/EverShelf/issues/93) to claim a language.
### Other ways to contribute
| What | Skill needed |
|---|---|
| 🐛 Report a bug | None |
| 📖 Improve the wiki | Markdown |
| 🌍 Add a translation | JSON editing |
| 🎨 Fix a CSS/UI issue | CSS / HTML |
| ⚙️ Implement a feature | PHP / JS |
| ⭐ Star the repo | Clicking |
👉 Browse [`help wanted`](https://github.com/dadaloop82/EverShelf/labels/help%20wanted) issues for good starting points.
Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide (branch naming, code style, how to run locally).
---
## 💬 Community
Join the conversation in [GitHub Discussions](https://github.com/dadaloop82/EverShelf/discussions):
- **Vote on upcoming features** — tell us what to build next
- **Show your setup** — share your kitchen kiosk
- **Ask questions** — get help from the community
---
## 📄 License
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
---
## 👨‍💻 Author
**Stimpfl Daniel** — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
- Website: [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
---
## 📸 Screenshots
<div align="center">
![EverShelf demo — barcode scan, inventory management and AI recipe generation](assets/img/demo.gif)
</div>
For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required.
> Want to contribute additional screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
-16
View File
@@ -1,16 +0,0 @@
<?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';
+5 -28
View File
@@ -11,16 +11,14 @@ if (PHP_SAPI !== 'cli') {
exit('Forbidden'); exit('Forbidden');
} }
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped // Define CRON_MODE before loading index.php so the router is skipped
define('CRON_MODE', true); define('CRON_MODE', true);
require_once __DIR__ . '/bootstrap.php'; // Load all API functions without running the HTTP router
require_once __DIR__ . '/index.php'; require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json'; const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
try { try {
$db = getDB(); $db = getDB();
@@ -44,10 +42,9 @@ try {
$itemCount = count($decoded['items'] ?? []); $itemCount = count($decoded['items'] ?? []);
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n"; echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
// ── Bring! server-side sync ─────────────────────────────────────────── // ── Bring! server-side cleanup ────────────────────────────────────────
// After computing smart shopping, remove stale Bring! items and push every // After computing smart shopping, automatically remove stale Bring! items
// product that needs restocking (esauriti, quasi finiti, previsione). // and add/update critical ones. This runs fully server-side every cron cycle.
// Runs fully server-side every cron cycle (~5 min).
try { try {
$cleanupResult = bringCleanupObsolete($db); $cleanupResult = bringCleanupObsolete($db);
if (isset($cleanupResult['skipped'])) { if (isset($cleanupResult['skipped'])) {
@@ -58,21 +55,6 @@ try {
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n"; . ($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); $addResult = bringAutoAddCritical($db);
if (isset($addResult['skipped'])) { if (isset($addResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n"; echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
@@ -80,11 +62,6 @@ try {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0) echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n"; . ', 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) { } catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n"; echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
} }
+1 -291
View File
@@ -38,24 +38,8 @@ function _ensureDataDir(): void {
} }
} }
/** Ensure the SQLite DB and WAL sidecar files are writable (Docker volume first-boot). */
function _ensureDbWritable(): void {
if (!file_exists(DB_PATH)) {
return;
}
if (!is_writable(DB_PATH)) {
@chmod(DB_PATH, 0664);
}
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $sidecar) {
if (file_exists($sidecar) && !is_writable($sidecar)) {
@chmod($sidecar, 0664);
}
}
}
function getDB(): PDO { function getDB(): PDO {
_ensureDataDir(); _ensureDataDir();
_ensureDbWritable();
// logger.php is required by index.php before getDB() is called. // logger.php is required by index.php before getDB() is called.
// In cron context it may not be loaded yet — guard with class_exists. // In cron context it may not be loaded yet — guard with class_exists.
$useLogging = class_exists('LoggingPDO', false); $useLogging = class_exists('LoggingPDO', false);
@@ -69,7 +53,7 @@ function getDB(): PDO {
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite. $db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
// For SQLite, we use PRAGMA busy_timeout. // For SQLite, we use PRAGMA busy_timeout.
$db->exec('PRAGMA journal_mode = WAL;'); $db->exec('PRAGMA journal_mode = WAL;');
$db->exec('PRAGMA busy_timeout = 10000;'); // 10 s — cron + PWA writes can contend under WAL $db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec("PRAGMA journal_mode=WAL"); $db->exec("PRAGMA journal_mode=WAL");
@@ -88,29 +72,6 @@ function getDB(): PDO {
return $db; return $db;
} }
/**
* Retry a DB write when SQLite returns "database is locked" (concurrent cron + API).
*
* @template T
* @param callable(): T $fn
* @return T
*/
function dbWithRetry(callable $fn, int $maxAttempts = 4): mixed {
$attempt = 0;
while (true) {
try {
return $fn();
} catch (\PDOException $e) {
$attempt++;
$locked = str_contains($e->getMessage(), 'database is locked');
if (!$locked || $attempt >= $maxAttempts) {
throw $e;
}
usleep(150000 * $attempt); // 150 ms, 300 ms, 450 ms …
}
}
}
function initializeDB(PDO $db): void { function initializeDB(PDO $db): void {
$db->exec(" $db->exec("
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
@@ -186,28 +147,6 @@ function migrateDB(PDO $db): void {
try { $db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''"); } try { $db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; } catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
} }
if (!in_array('subcategory', $colNames)) {
try { $db->exec("ALTER TABLE products ADD COLUMN subcategory TEXT DEFAULT NULL"); }
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 // Migrate transactions CHECK constraint to allow 'waste' type
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn(); $sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
@@ -323,227 +262,6 @@ function migrateDB(PDO $db): void {
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
// Custom locations table (v1.9.0) — dynamic inventory locations managed from Settings
$locTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='locations'")->fetchAll();
if (empty($locTables)) {
$db->exec("
CREATE TABLE locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
icon TEXT DEFAULT '📦',
sort_order INTEGER DEFAULT 0,
is_builtin INTEGER DEFAULT 0
);
");
$db->exec("INSERT INTO locations (key, label, icon, sort_order, is_builtin) VALUES
('dispensa', 'Dispensa', '🗄️', 1, 1),
('frigo', 'Frigo', '🧊', 2, 1),
('freezer', 'Freezer', '❄️', 3, 1),
('altro', 'Altro', '📦', 4, 1)
");
}
// Custom subcategories table (v2.0) — sous-catégories par catégorie, gérables depuis Config
$subcatTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='subcategories'")->fetchAll();
if (empty($subcatTables)) {
$db->exec("
CREATE TABLE subcategories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
key TEXT NOT NULL,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
UNIQUE(category, key)
);
");
$db->exec("INSERT INTO subcategories (category, key, label, sort_order) VALUES
('latticini', 'lait', '🥛 Lait', 1),
('latticini', 'yaourt', '🥣 Yaourt', 2),
('latticini', 'fromage', '🧀 Fromage', 3),
('latticini', 'beurre', '🧈 Beurre', 4),
('latticini', 'creme', '🍦 Crème', 5),
('latticini', 'oeufs', '🥚 Œufs', 6),
('latticini', 'autre', '📦 Autre', 7),
('carne', 'poulet', '🍗 Poulet', 1),
('carne', 'boeuf', '🐄 Bœuf', 2),
('carne', 'porc', '🐖 Porc', 3),
('carne', 'agneau', '🐑 Agneau', 4),
('carne', 'charcuterie', '🥓 Charcuterie', 5),
('carne', 'autre', '📦 Autre', 6),
('pesce', 'poisson_frais', '🐟 Poisson frais', 1),
('pesce', 'poisson_surgele', '🧊 Poisson surgelé', 2),
('pesce', 'fruits_mer', '🦐 Fruits de mer', 3),
('pesce', 'conserve_poisson', '🥫 Conserve', 4),
('pesce', 'autre', '📦 Autre', 5),
('frutta', 'agrumes', '🍊 Agrumes', 1),
('frutta', 'baies', '🫐 Baies', 2),
('frutta', 'fruits_noyau', '🍑 Fruits à noyau', 3),
('frutta', 'fruits_tropicaux', '🍍 Fruits tropicaux', 4),
('frutta', 'autre', '📦 Autre', 5),
('verdura', 'legumes_feuilles', '🥬 Légumes feuilles', 1),
('verdura', 'legumes_racines', '🥕 Légumes racines', 2),
('verdura', 'legumineuses_fraiches', '🌱 Légumineuses fraîches', 3),
('verdura', 'autre', '📦 Autre', 4),
('pasta', 'pates', '🍝 Pâtes', 1),
('pasta', 'riz', '🍚 Riz', 2),
('pasta', 'semoule', '🌾 Semoule', 3),
('pasta', 'autre', '📦 Autre', 4),
('pane', 'pain_frais', '🍞 Pain frais', 1),
('pane', 'biscottes', '🥖 Biscottes', 2),
('pane', 'viennoiserie', '🥐 Viennoiserie', 3),
('pane', 'autre', '📦 Autre', 4),
('surgelati', 'plats_prepares', '🍱 Plats préparés', 1),
('surgelati', 'legumes_surgeles', '🧊 Légumes surgelés', 2),
('surgelati', 'glaces', '🍨 Glaces', 3),
('surgelati', 'viande_poisson_surgele', '🧊 Viande/poisson surgelé', 4),
('surgelati', 'autre', '📦 Autre', 5),
('bevande', 'vin', '🍷 Vin', 1),
('bevande', 'biere', '🍺 Bière', 2),
('bevande', 'spiritueux', '🥃 Spiritueux', 3),
('bevande', 'soda', '🥤 Soda', 4),
('bevande', 'jus', '🧃 Jus', 5),
('bevande', 'eau', '💧 Eau', 6),
('bevande', 'autre', '📦 Autre', 7),
('condimenti', 'huile', '🫒 Huile', 1),
('condimenti', 'vinaigre', '🍶 Vinaigre', 2),
('condimenti', 'sauce', '🥫 Sauce', 3),
('condimenti', 'epice', '🌿 Épice', 4),
('condimenti', 'autre', '📦 Autre', 5),
('snack', 'chocolat', '🍫 Chocolat', 1),
('snack', 'biscuit', '🍪 Biscuit', 2),
('snack', 'chips', '🥔 Chips', 3),
('snack', 'bonbon', '🍬 Bonbon', 4),
('snack', 'autre', '📦 Autre', 5),
('conserve', 'legumes_conserve', '🥫 Légumes', 1),
('conserve', 'fruits_conserve', '🥫 Fruits', 2),
('conserve', 'poisson_conserve', '🥫 Poisson', 3),
('conserve', 'confiture', '🍯 Confiture', 4),
('conserve', 'autre', '📦 Autre', 5),
('cereali', 'cereales_petitdej', '🥣 Céréales petit-déj', 1),
('cereali', 'legumineuses_seches', '🫘 Légumineuses sèches', 2),
('cereali', 'farine', '🌾 Farine', 3),
('cereali', 'autre', '📦 Autre', 4)
");
}
// Custom categories table (v2.1) — catégories produit, gérables depuis Config
$catTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='categories'")->fetchAll();
if (empty($catTables)) {
$db->exec("
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
icon TEXT DEFAULT '📦',
keywords TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
is_builtin INTEGER DEFAULT 0
);
");
$db->exec("INSERT INTO categories (key, label, icon, sort_order, is_builtin) VALUES
('latticini', 'Latticini', '🥛', 1, 1),
('carne', 'Carne', '🥩', 2, 1),
('pesce', 'Pesce', '🐟', 3, 1),
('frutta', 'Frutta', '🍎', 4, 1),
('verdura', 'Verdura', '🥬', 5, 1),
('pasta', 'Pasta', '🍝', 6, 1),
('pane', 'Pane', '🍞', 7, 1),
('surgelati', 'Surgelati', '🧊', 8, 1),
('bevande', 'Bevande', '🥤', 9, 1),
('condimenti', 'Condimenti', '🧂', 10, 1),
('snack', 'Snack', '🍪', 11, 1),
('conserve', 'Conserve', '🥫', 12, 1),
('cereali', 'Cereali', '🌾', 13, 1),
('igiene', 'Igiene', '🧴', 14, 1),
('pulizia', 'Pulizia', '🧹', 15, 1),
('altro', 'Altro', '📦', 16, 1)
");
}
// Recipe library (v2.2) — recettes ajoutées manuellement (cocktails, boissons...), distinctes du planning repas
$recipeLibTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe_library'")->fetchAll();
if (empty($recipeLibTables)) {
$db->exec("
CREATE TABLE recipe_library (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
recipe_json TEXT NOT NULL,
is_favorite INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
}
// Recipe tags (v2.3) — tags pour trier/filtrer "Mes recettes", gérables depuis Config
$recipeTagsTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe_tags'")->fetchAll();
if (empty($recipeTagsTables)) {
$db->exec("
CREATE TABLE recipe_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
icon TEXT DEFAULT '🏷️',
sort_order INTEGER DEFAULT 0
);
");
$db->exec("INSERT INTO recipe_tags (key, label, icon, sort_order) VALUES
('cocktail', 'Cocktail', '🍹', 1),
('sans_alcool', 'Sans alcool', '🚫', 2),
('shot', 'Shot', '🥃', 3),
('long_drink', 'Long drink', '🍺', 4),
('aperitif', 'Apéritif', '🍾', 5),
('digestif', 'Digestif', '❄️', 6),
('rhum', 'Rhum', '🥃', 7),
('vodka', 'Vodka', '🍸', 8),
('gin', 'Gin', '🌿', 9),
('whisky', 'Whisky', '🥃', 10),
('tequila', 'Tequila', '🌵', 11),
('vin', 'Vin', '🍷', 12),
('champagne', 'Champagne / Mousseux', '🍾', 13),
('acidule', 'Acidulé', '🍋', 14),
('sucre', 'Sucré', '🍬', 15),
('amer', 'Amer', '☕', 16),
('epice', 'Épicé', '🌶️', 17),
('fruite', 'Fruité', '🍊', 18),
('herbace', 'Herbacé', '🌿', 19),
('ete', 'Été', '☀️', 20),
('hiver', 'Hiver', '❄️', 21),
('soiree', 'Soirée', '🎉', 22),
('brunch', 'Brunch', '🥂', 23)
");
}
// Migration: add keywords column to recipe_tags if missing
$rtCols = array_column($db->query("PRAGMA table_info(recipe_tags)")->fetchAll(), 'name');
if (!in_array('keywords', $rtCols)) {
try { $db->exec("ALTER TABLE recipe_tags ADD COLUMN keywords TEXT DEFAULT ''"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
// Custom quantity units (v2.4) — unités personnalisées (ex: kg, L) avec facteur de conversion vers pz/g/ml, gérables depuis Config
$customUnitsTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='custom_units'")->fetchAll();
if (empty($customUnitsTables)) {
$db->exec("
CREATE TABLE custom_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
icon TEXT DEFAULT '📏',
base_unit TEXT NOT NULL DEFAULT 'g',
factor REAL NOT NULL DEFAULT 1,
sort_order INTEGER DEFAULT 0
);
");
}
// Add display_unit_key column to products if missing — unité personnalisée a afficher pour ce produit
$prodColsUnits = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
if (!in_array('display_unit_key', $prodColsUnits)) {
try { $db->exec("ALTER TABLE products ADD COLUMN display_unit_key TEXT DEFAULT NULL"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal // Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll(); $shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
if (empty($shopTables)) { if (empty($shopTables)) {
@@ -573,12 +291,6 @@ if (!in_array('display_unit_key', $prodColsUnits)) {
try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); } try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; } catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
} }
// Add tags column to products if missing (bugfix: insert referenced a column never created)
if (!in_array('tags', $prodCols2)) {
try { $db->exec("ALTER TABLE products ADD COLUMN tags TEXT DEFAULT NULL"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
} }
/** /**
@@ -731,7 +443,6 @@ 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('/\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('/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('/\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('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3; if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
if (preg_match('/\b(birra|beer)\b/', $n)) return 3; if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
@@ -809,7 +520,6 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
elseif (preg_match('/uova/', $n)) $days = 28; 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+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('/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('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3; 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; elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
+1562 -4866
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
<?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
@@ -1,28 +0,0 @@
<?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);
}
}
}
-40
View File
@@ -1,40 +0,0 @@
<?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();
if (isset($vars[$key]) && $vars[$key] !== '') {
return $vars[$key];
}
// Fallback to system/Docker environment variables (e.g. set via Portainer)
$sysVal = getenv($key);
if ($sysVal !== false && $sysVal !== '') {
return $sysVal;
}
return $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
@@ -1,69 +0,0 @@
<?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
@@ -1,293 +0,0 @@
<?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,7 +335,6 @@ class LoggingPDOStatement {
// Type hint: use PDO in all functions (LoggingPDO extends PDO). // Type hint: use PDO in all functions (LoggingPDO extends PDO).
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
class LoggingPDO extends \PDO { class LoggingPDO extends \PDO {
#[\ReturnTypeWillChange]
public function prepare(string $query, array $options = []): LoggingPDOStatement|false { public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
$stmt = parent::prepare($query, $options); $stmt = parent::prepare($query, $options);
if ($stmt === false) { if ($stmt === false) {
+50 -55
View File
@@ -1,53 +1,57 @@
<?php <?php
/** /**
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only). * 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", ...]}
*/ */
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Cache-Control: no-cache'); header('Cache-Control: no-cache');
evershelfSendCorsHeaders();
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
// 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); $port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) { if ($port < 1 || $port > 65535) $port = 8765;
$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 '';
} }
$serverIp = evershelfLocalLanIp(); $serverIp = localLanIp();
$parts = explode('.', $serverIp); $parts = explode('.', $serverIp);
if (count($parts) !== 4) { if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]); echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
exit; exit;
} }
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.'; $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 = []; $candidates = [];
for ($i = 1; $i <= 254; $i++) { for ($i = 1; $i <= 254; $i++) {
$ip = $subnet . $i; $ip = $subnet . $i;
@@ -70,28 +74,25 @@ while (!empty($candidates) && microtime(true) < $deadline) {
$read = null; $read = null;
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000); $usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
$n = @stream_select($read, $write, $except, 0, $usec); $n = @stream_select($read, $write, $except, 0, $usec);
if ($n === false || $n === 0) { if ($n === false || $n === 0) break;
break;
}
// Sockets in $except = connection refused/error
$failed = []; $failed = [];
foreach ($except as $s) { foreach ($except as $s) {
$ip = array_search($s, $candidates, true); $ip = array_search($s, $candidates, true);
if ($ip !== false) { if ($ip !== false) $failed[$ip] = true;
$failed[$ip] = true;
}
} }
// Sockets in $write = connection complete (may overlap with $except on error)
foreach ($write as $s) { foreach ($write as $s) {
$ip = array_search($s, $candidates, true); $ip = array_search($s, $candidates, true);
if ($ip === false) { if ($ip === false) continue;
continue;
}
if (!isset($failed[$ip])) { if (!isset($failed[$ip])) {
$found_tcp[] = $ip; $found_tcp[] = $ip;
} }
@fclose($s); @fclose($s);
unset($candidates[$ip]); unset($candidates[$ip]);
} }
// Close failed sockets too
foreach ($failed as $ip => $_) { foreach ($failed as $ip => $_) {
if (isset($candidates[$ip])) { if (isset($candidates[$ip])) {
@fclose($candidates[$ip]); @fclose($candidates[$ip]);
@@ -99,16 +100,13 @@ while (!empty($candidates) && microtime(true) < $deadline) {
} }
} }
} }
foreach ($candidates as $s) { foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
@fclose($s);
}
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
$gateways = []; $gateways = [];
foreach ($found_tcp as $ip) { foreach ($found_tcp as $ip) {
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2); $sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
if (!$sock) { if (!$sock) continue;
continue;
}
stream_set_timeout($sock, 2); stream_set_timeout($sock, 2);
$key = base64_encode(random_bytes(16)); $key = base64_encode(random_bytes(16));
@@ -126,13 +124,9 @@ foreach ($found_tcp as $ip) {
$dl = microtime(true) + 2; $dl = microtime(true) + 2;
while (microtime(true) < $dl && !feof($sock)) { while (microtime(true) < $dl && !feof($sock)) {
$line = fgets($sock, 256); $line = fgets($sock, 256);
if ($line === false) { if ($line === false) break;
break;
}
$resp .= $line; $resp .= $line;
if ($line === "\r\n") { if ($line === "\r\n") break;
break;
}
} }
fclose($sock); fclose($sock);
@@ -144,4 +138,5 @@ foreach ($found_tcp as $ip) {
echo json_encode([ echo json_encode([
'found' => $gateways, 'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24', 'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]); ]);
+7 -16
View File
@@ -1,20 +1,16 @@
<?php <?php
/** /**
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened) * 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
*/ */
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Cache-Control: no-cache'); 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'] ?? ''; $rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) { if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
@@ -23,7 +19,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
} }
$parsed = parse_url($rawUrl); $parsed = parse_url($rawUrl);
$host = strtolower($parsed['host'] ?? ''); $host = $parsed['host'] ?? '';
$port = (int)($parsed['port'] ?? 8765); $port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/'; $path = ($parsed['path'] ?? '') ?: '/';
@@ -32,11 +28,6 @@ if (!$host || $port < 1 || $port > 65535) {
exit; 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 // Try to open a TCP connection with a 5-second timeout
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5); $sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
if (!$sock) { if (!$sock) {
+1 -17
View File
@@ -8,16 +8,6 @@
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765 * 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 ────────────────────────────────────────────────────────── // ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? ''; $rawUrl = $_GET['url'] ?? '';
@@ -29,7 +19,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
} }
$parsed = parse_url($rawUrl); $parsed = parse_url($rawUrl);
$wsHost = strtolower($parsed['host'] ?? ''); $wsHost = $parsed['host'] ?? '';
$wsPort = (int)($parsed['port'] ?? 8765); $wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/'; $wsPath = ($parsed['path'] ?? '') ?: '/';
@@ -39,12 +29,6 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
exit; 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 ─────────────────────────────────────────────────────────────── // ── SSE headers ───────────────────────────────────────────────────────────────
header('Content-Type: text/event-stream'); header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-store, must-revalidate'); header('Cache-Control: no-cache, no-store, must-revalidate');
-524
View File
@@ -666,126 +666,6 @@ body.server-offline .bottom-nav {
flex-shrink: 0; 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 { @keyframes pulse-scan {
0%, 100% { box-shadow: 0 2px 8px rgba(0,0,0,0.2); } 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); } 50% { box-shadow: 0 2px 16px rgba(255,255,255,0.4); }
@@ -1847,41 +1727,6 @@ body.server-offline .bottom-nav {
border-color: var(--primary); border-color: var(--primary);
} }
.qty-control-with-unit {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.qty-control-with-unit .qty-control {
flex: 0 0 auto;
}
.qty-unit-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 50px;
padding: 0 12px;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
font-size: 1rem;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: lowercase;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.qty-unit-badge.qty-unit-muted {
background: var(--bg-card);
color: var(--primary);
border: 2px solid var(--primary);
}
/* ===== USE OPTIONS ===== */ /* ===== USE OPTIONS ===== */
.use-options { .use-options {
display: flex; display: flex;
@@ -2000,19 +1845,6 @@ body.server-offline .bottom-nav {
overflow: hidden; 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 { .scanner-viewport video {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -2177,63 +2009,6 @@ body.server-offline .bottom-nav {
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); } .scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
.scan-status-msg.state-retry { color: #fb923c; } .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) — */ /* — Viewport overlay controls (torch / zoom / flip) — */
.scan-viewport-controls { .scan-viewport-controls {
position: absolute; position: absolute;
@@ -2284,183 +2059,6 @@ body.server-offline .bottom-nav {
box-shadow: var(--shadow); 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 — */ /* — Recent scans — */
.scan-recents { .scan-recents {
display: flex; display: flex;
@@ -3335,14 +2933,6 @@ body.server-offline .bottom-nav {
font-style: italic; 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 { .smart-brand {
font-weight: 400; font-weight: 400;
color: var(--text-muted); color: var(--text-muted);
@@ -4705,93 +4295,6 @@ body.server-offline .bottom-nav {
line-height: 1.5; 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 { .recipe-tools-banner {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4916,13 +4419,6 @@ body.server-offline .bottom-nav {
line-height: 1.3; 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 (REPARTO) HEADERS ===== */
.shopping-section-divider { .shopping-section-divider {
display: flex; display: flex;
@@ -6443,12 +5939,6 @@ body.cooking-mode-active .app-header {
} }
.banner-anomaly .alert-banner-title { color: #9a3412; } .banner-anomaly .alert-banner-title { color: #9a3412; }
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; } .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 { .alert-banner.banner-no-expiry {
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%); background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
border-color: #16a34a; border-color: #16a34a;
@@ -8348,8 +7838,6 @@ body.cooking-mode-active .app-header {
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; } [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"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; } [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"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; } [data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
@@ -8420,18 +7908,6 @@ body.cooking-mode-active .app-header {
/* ── Recipe components ── */ /* ── Recipe components ── */
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; } [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-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; 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; } [data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
+925 -4012
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
/**
* 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
@@ -1,11 +0,0 @@
/**
* 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.
+7 -13
View File
@@ -1,19 +1,13 @@
#!/bin/bash #!/bin/bash
# Daily backup of EverShelf database (local only) # Daily backup of EverShelf database (local only)
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3) # 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
set -euo pipefail
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKUP_DIR="${INSTALL_DIR}/data/backups" 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" mkdir -p "$BACKUP_DIR"
@@ -25,5 +19,5 @@ fi
DATE=$(date '+%Y-%m-%d_%H%M') DATE=$(date '+%Y-%m-%d_%H%M')
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db" cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
# Keep only the newest N backups # Keep only the last 7 backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm -- ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
-2
View File
@@ -1,2 +0,0 @@
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
Require all denied
+2 -9
View File
@@ -5,21 +5,14 @@ services:
ports: ports:
- "8080:80" - "8080:80"
volumes: volumes:
# Persist database and runtime data
- evershelf_data:/var/www/html/data - evershelf_data:/var/www/html/data
# Mount your local .env configuration
- ./.env:/var/www/html/.env:ro - ./.env:/var/www/html/.env:ro
restart: unless-stopped restart: unless-stopped
environment: environment:
- TZ=Europe/Rome - TZ=Europe/Rome
networks:
- backend
- frontend
volumes: volumes:
evershelf_data: evershelf_data:
driver: local driver: local
networks:
frontend:
external: true
backend:
external: true
-38
View File
@@ -1,38 +0,0 @@
# 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" applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 20 versionCode = 18
versionName = "1.7.19" versionName = "1.7.17"
} }
signingConfigs { signingConfigs {
@@ -101,20 +101,6 @@ class KioskActivity : AppCompatActivity() {
// Pending WebView permission request // Pending WebView permission request
private var pendingWebPermission: PermissionRequest? = null 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 { companion object {
private const val FILE_CHOOSER_REQUEST = 1002 private const val FILE_CHOOSER_REQUEST = 1002
private const val PERMISSION_REQUEST_CODE = 1003 private const val PERMISSION_REQUEST_CODE = 1003
@@ -164,18 +150,18 @@ class KioskActivity : AppCompatActivity() {
override fun onStart(utteranceId: String?) {} override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) { override fun onDone(utteranceId: String?) {
runOnUiThread { runOnUiThread {
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')") webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
} }
} }
@Deprecated("Deprecated in API 21") @Deprecated("Deprecated in API 21")
override fun onError(utteranceId: String?) { override fun onError(utteranceId: String?) {
runOnUiThread { runOnUiThread {
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')") webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
} }
} }
override fun onError(utteranceId: String?, errorCode: Int) { override fun onError(utteranceId: String?, errorCode: Int) {
runOnUiThread { runOnUiThread {
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)") webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
} }
} }
}) })
@@ -643,79 +629,6 @@ class KioskActivity : AppCompatActivity() {
webView.evaluateJavascript("$jsCallback($escaped)", null) 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 { try {
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json") conn.setRequestProperty("Accept", "application/vnd.github+json")
@@ -730,16 +643,43 @@ class KioskActivity : AppCompatActivity() {
val body = conn.inputStream.bufferedReader().readText() val body = conn.inputStream.bufferedReader().readText()
conn.disconnect() conn.disconnect()
val json = JSONObject(body) 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 bodyText = json.optString("body", "")
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") } val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""") val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
.find(bodyText)?.groupValues?.get(1) .find(bodyText)?.groupValues?.get(1)
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?: norm(json.optString("tag_name", "")) ?: norm(latestTag)
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE) // Compare semver: returns true if `remote` is strictly greater than `local`
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -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 }
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 isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
val assets = json.optJSONArray("assets") val assets = json.optJSONArray("assets")
var kioskApkUrl = "" var kioskApkUrl = ""
if (assets != null) { if (assets != null) {
@@ -753,35 +693,38 @@ class KioskActivity : AppCompatActivity() {
} }
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
if (!needsUpdate(remoteKioskVersion, remoteVc)) { // Only flag an update when the remote version is parseable as semver AND
notifyJs(JSONObject().put("has_update", false)) // 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
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply() prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread return@Thread
} }
applyUpdate(remoteKioskVersion, kioskApkUrl)
// 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) }
} catch (e: Exception) { } catch (e: Exception) {
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error")) notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
} }
}.start() }.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, * 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. * restore the update banner immediately without a network round-trip.
@@ -540,11 +540,6 @@ class SetupActivity : AppCompatActivity() {
// Cancel auto-discover when leaving server step // Cancel auto-discover when leaving server step
if (step != 3) discoverCancelled.set(true) 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 // Scroll to top
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {} try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
} }
@@ -702,58 +697,6 @@ 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? { private fun openConn(urlStr: String): HttpURLConnection? {
return try { return try {
val conn = URL(urlStr).openConnection() val conn = URL(urlStr).openConnection()
@@ -829,52 +772,9 @@ class SetupActivity : AppCompatActivity() {
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" } runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
val ports = listOf(443, 80, 8080, 8443) 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( 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", "/api/index.php?action=get_settings",
"/dispensa/api/index.php?action=get_settings",
"/evershelf/api/index.php?action=get_settings", "/evershelf/api/index.php?action=get_settings",
) )
@@ -919,24 +819,30 @@ class SetupActivity : AppCompatActivity() {
// Full HTTP probe on reachable host // Full HTTP probe on reachable host
val scheme = if (port == 443 || port == 8443) "https" else "http" 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) { for (path in paths) {
if (discoverCancelled.get() || found.get()) break if (discoverCancelled.get() || found.get()) break
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it } 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) {}
} }
null null
} }
} }
// ── 3. Collect results until all tasks finish or a server is found ──── // ── 3. Collect results as they complete (not in submission order) ────
var result: String? = null var result: String? = null
var collected = 0 var collected = 0
while (collected < total && !discoverCancelled.get() && result == null) { while (collected < total && !discoverCancelled.get()) {
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue val future = cs.poll(3, TimeUnit.SECONDS) ?: break
collected++ collected++
val r = try { future.get() } catch (_: Exception) { null } val r = try { future.get() } catch (_: Exception) { null }
if (r != null && found.compareAndSet(false, true)) { if (r != null && found.compareAndSet(false, true)) {
@@ -1195,9 +1101,9 @@ class SetupActivity : AppCompatActivity() {
val lanIp = getDeviceLanIp() ?: "127.0.0.1" val lanIp = getDeviceLanIp() ?: "127.0.0.1"
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"") append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
} }
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"") if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"") if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"") if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
append("}") append("}")
} }
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply { val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
@@ -49,7 +49,7 @@
<string name="install_error_download">Download fehlgeschlagen</string> <string name="install_error_download">Download fehlgeschlagen</string>
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string> <string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
<string name="install_error_install">Installation fehlgeschlagen</string> <string name="install_error_install">Installation fehlgeschlagen</string>
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string> <string name="install_perm_detail">Aktiviere 'Unbekannte Apps installieren' in den Einstellungen, dann komm zurück.</string>
<string name="install_btn_retry">↩ Nochmal versuchen</string> <string name="install_btn_retry">↩ Nochmal versuchen</string>
<string name="btn_back">Zurück</string> <string name="btn_back">Zurück</string>
<string name="btn_launch">🚀 EverShelf starten</string> <string name="btn_launch">🚀 EverShelf starten</string>
@@ -72,11 +72,15 @@
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string> <string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string> <string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
<string name="setup_gemini_title">Google Gemini AI</string> <string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.\n\nZum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string> <string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → \"API-Schlüssel erhalten\"</string>
Zum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → "API-Schlüssel erhalten"</string>
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string> <string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
<string name="setup_bring_title">Bring! Einkaufsliste</string> <string name="setup_bring_title">Bring! Einkaufsliste</string>
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</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_email_hint">Bring!-E-Mail-Adresse</string> <string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
<string name="setup_bring_pass_hint">Bring!-Passwort</string> <string name="setup_bring_pass_hint">Bring!-Passwort</string>
<string name="setup_done_title">Alles bereit!</string> <string name="setup_done_title">Alles bereit!</string>
@@ -49,7 +49,7 @@
<string name="install_error_download">Descarga fallida</string> <string name="install_error_download">Descarga fallida</string>
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string> <string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
<string name="install_error_install">Instalación fallida</string> <string name="install_error_install">Instalación fallida</string>
<string name="install_perm_detail">Habilita \'Instalar apps desconocidas\' en los ajustes y vuelve aquí.</string> <string name="install_perm_detail">Habilita 'Instalar apps desconocidas' en los ajustes y vuelve aquí.</string>
<string name="install_btn_retry">↩ Reintentar</string> <string name="install_btn_retry">↩ Reintentar</string>
<string name="btn_back">Atrás</string> <string name="btn_back">Atrás</string>
<string name="btn_launch">🚀 Iniciar EverShelf</string> <string name="btn_launch">🚀 Iniciar EverShelf</string>
@@ -72,11 +72,15 @@
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string> <string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string> <string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
<string name="setup_gemini_title">Google Gemini AI</string> <string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.\n\nPara activarla, introduce tu clave API de Gemini gratuita.</string> <string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → \"Obtener clave API\"</string>
Para activarla, introduce tu clave API de Gemini gratuita.</string>
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → "Obtener clave API"</string>
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string> <string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
<string name="setup_bring_title">Bring! Lista de la compra</string> <string name="setup_bring_title">Bring! Lista de la compra</string>
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.\n\nIntroduce 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!.
Introduce tus credenciales de Bring! para activar la integración.</string>
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string> <string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
<string name="setup_bring_pass_hint">Contraseña de Bring!</string> <string name="setup_bring_pass_hint">Contraseña de Bring!</string>
<string name="setup_done_title">¡Todo listo!</string> <string name="setup_done_title">¡Todo listo!</string>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">EverShelf Kiosk</string> <string name="app_name">EverShelf Kiosk</string>
<string name="setup_enter_url">Veuillez d\'abord saisir une URL</string> <string name="setup_enter_url">Veuillez d'abord saisir une URL</string>
<string name="setup_testing">Test de connexion…</string> <string name="setup_testing">Test de connexion…</string>
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string> <string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string> <string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
<string name="setup_unreachable">Impossible d\'atteindre le serveur</string> <string name="setup_unreachable">Impossible d'atteindre le serveur</string>
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string> <string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string> <string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
<string name="setup_discovering">Analyse en cours…</string> <string name="setup_discovering">Analyse en cours…</string>
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string> <string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l\'URL manuellement.</string> <string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l'URL manuellement.</string>
<string name="setup_exit_title">Quitter la configuration ?</string> <string name="setup_exit_title">Quitter la configuration ?</string>
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l\'app.</string> <string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l'app.</string>
<string name="setup_exit_confirm">Quitter</string> <string name="setup_exit_confirm">Quitter</string>
<string name="setup_exit_cancel">Continuer</string> <string name="setup_exit_cancel">Continuer</string>
<string name="setup_step_back">← Retour</string> <string name="setup_step_back">← Retour</string>
@@ -22,20 +22,20 @@
<string name="wizard_step3_title">Balance intelligente</string> <string name="wizard_step3_title">Balance intelligente</string>
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string> <string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string> <string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
<string name="wizard_step3_yes">✅ Oui, j\'ai une balance</string> <string name="wizard_step3_yes">✅ Oui, j'ai une balance</string>
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string> <string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
<string name="ble_scanning">🔍 Scan en cours…</string> <string name="ble_scanning">🔍 Scan en cours…</string>
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string> <string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
<string name="ble_disconnected">Connexion perdue. Réessayer.</string> <string name="ble_disconnected">Connexion perdue. Réessayer.</string>
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu\'elle est allumée et à proximité, puis réessayez.</string> <string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu'elle est allumée et à proximité, puis réessayez.</string>
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string> <string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string> <string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
<string name="ble_scan_again">🔄 Scanner à nouveau</string> <string name="ble_scan_again">🔄 Scanner à nouveau</string>
<string name="ble_weight_received">Poids reçu — correspond-il à l\'affichage de la balance ?</string> <string name="ble_weight_received">Poids reçu — correspond-il à l'affichage de la balance ?</string>
<string name="wizard_gateway_installed">Balance enregistrée ✅</string> <string name="wizard_gateway_installed">Balance enregistrée ✅</string>
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string> <string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string> <string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l\'une d\'elles pour la sélectionner.</string> <string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l'une d'elles pour la sélectionner.</string>
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string> <string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string> <string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
<string name="wizard_gateway_update_available">Balance BLE trouvée</string> <string name="wizard_gateway_update_available">Balance BLE trouvée</string>
@@ -43,13 +43,13 @@
<string name="install_downloading">Téléchargement en cours…</string> <string name="install_downloading">Téléchargement en cours…</string>
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string> <string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
<string name="install_installing">Installation en cours…</string> <string name="install_installing">Installation en cours…</string>
<string name="install_confirm_detail">Confirmez l\'installation dans la boîte de dialogue ouverte.</string> <string name="install_confirm_detail">Confirmez l'installation dans la boîte de dialogue ouverte.</string>
<string name="install_success">Installé avec succès !</string> <string name="install_success">Installé avec succès !</string>
<string name="install_success_detail">L\'app a été mise à jour.</string> <string name="install_success_detail">L'app a été mise à jour.</string>
<string name="install_error_download">Téléchargement échoué</string> <string name="install_error_download">Téléchargement échoué</string>
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string> <string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
<string name="install_error_install">Installation échouée</string> <string name="install_error_install">Installation échouée</string>
<string name="install_perm_detail">Activez \'Installer des apps inconnues\' dans les paramètres, puis revenez ici.</string> <string name="install_perm_detail">Activez 'Installer des apps inconnues' dans les paramètres, puis revenez ici.</string>
<string name="install_btn_retry">↩ Réessayer</string> <string name="install_btn_retry">↩ Réessayer</string>
<string name="btn_back">Retour</string> <string name="btn_back">Retour</string>
<string name="btn_launch">🚀 Lancer EverShelf</string> <string name="btn_launch">🚀 Lancer EverShelf</string>
@@ -58,13 +58,13 @@
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string> <string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string> <string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
<string name="wizard_server_ok">Serveur accessible ✅</string> <string name="wizard_server_ok">Serveur accessible ✅</string>
<string name="wizard_server_ok_detail">Rapport d\'erreurs actif — les échecs d\'installation seront envoyés automatiquement aux GitHub Issues.</string> <string name="wizard_server_ok_detail">Rapport d'erreurs actif — les échecs d'installation seront envoyés automatiquement aux GitHub Issues.</string>
<string name="wizard_server_error">Serveur inaccessible ⚠️</string> <string name="wizard_server_error">Serveur inaccessible ⚠️</string>
<string name="wizard_server_error_detail">Les erreurs n\'atteindront pas GitHub Issues. Vérifiez l\'URL saisie à l\'étape 2.</string> <string name="wizard_server_error_detail">Les erreurs n'atteindront pas GitHub Issues. Vérifiez l'URL saisie à l'étape 2.</string>
<string name="setup_features_title">Fonctionnalités</string> <string name="setup_features_title">Fonctionnalités</string>
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string> <string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string> <string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d\'inactivité.</string> <string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d'inactivité.</string>
<string name="setup_prices_toggle_label">Prix liste de courses</string> <string name="setup_prices_toggle_label">Prix liste de courses</string>
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string> <string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
<string name="setup_mealplan_toggle_label">Plan de repas</string> <string name="setup_mealplan_toggle_label">Plan de repas</string>
@@ -72,11 +72,15 @@
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string> <string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string> <string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
<string name="setup_gemini_title">Google Gemini AI</string> <string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.\n\nPour l\'activer, entrez votre clé API Gemini gratuite.</string> <string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → \"Obtenir une clé API\"</string>
Pour l'activer, entrez votre clé API Gemini gratuite.</string>
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → "Obtenir une clé API"</string>
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string> <string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
<string name="setup_bring_title">Bring! Liste de courses</string> <string name="setup_bring_title">Bring! Liste de courses</string>
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l\'app Bring!.\n\nEntrez 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!.
Entrez vos identifiants Bring! pour activer l'intégration.</string>
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string> <string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
<string name="setup_bring_pass_hint">Mot de passe Bring!</string> <string name="setup_bring_pass_hint">Mot de passe Bring!</string>
<string name="setup_done_title">Tout est prêt !</string> <string name="setup_done_title">Tout est prêt !</string>
@@ -10,9 +10,9 @@
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
<string name="setup_discovering">Scansione in corso…</string> <string name="setup_discovering">Scansione in corso…</string>
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string> <string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string> <string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l'URL manualmente.</string>
<string name="setup_exit_title">Uscire dalla configurazione?</string> <string name="setup_exit_title">Uscire dalla configurazione?</string>
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string> <string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l'app.</string>
<string name="setup_exit_confirm">Esci</string> <string name="setup_exit_confirm">Esci</string>
<string name="setup_exit_cancel">Continua</string> <string name="setup_exit_cancel">Continua</string>
<string name="setup_step_back">← Indietro</string> <string name="setup_step_back">← Indietro</string>
@@ -28,28 +28,28 @@
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string> <string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
<string name="ble_disconnected">Connessione persa. Riprova.</string> <string name="ble_disconnected">Connessione persa. Riprova.</string>
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string> <string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
<string name="ble_select_from_list">Seleziona la tua bilancia dall\'elenco.</string> <string name="ble_select_from_list">Seleziona la tua bilancia dall'elenco.</string>
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string> <string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
<string name="ble_scan_again">🔄 Scansiona di nuovo</string> <string name="ble_scan_again">🔄 Scansiona di nuovo</string>
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string> <string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
<string name="wizard_gateway_installed">Bilancia salvata ✅</string> <string name="wizard_gateway_installed">Bilancia salvata ✅</string>
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string> <string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all'avvio.</string>
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string> <string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string> <string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string> <string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string> <string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string> <string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string> <string name="wizard_gateway_update_detail">Tocca la bilancia nell'elenco per connettersi.</string>
<string name="install_downloading">Scaricamento in corso…</string> <string name="install_downloading">Scaricamento in corso…</string>
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string> <string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
<string name="install_installing">Installazione in corso…</string> <string name="install_installing">Installazione in corso…</string>
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string> <string name="install_confirm_detail">Conferma l'installazione nel dialog che si è aperto.</string>
<string name="install_success">Installato con successo!</string> <string name="install_success">Installato con successo!</string>
<string name="install_success_detail">L\'app è stata aggiornata.</string> <string name="install_success_detail">L'app è stata aggiornata.</string>
<string name="install_error_download">Download fallito</string> <string name="install_error_download">Download fallito</string>
<string name="install_error_download_detail">Controlla la connessione e riprova.</string> <string name="install_error_download_detail">Controlla la connessione e riprova.</string>
<string name="install_error_install">Installazione fallita</string> <string name="install_error_install">Installazione fallita</string>
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string> <string name="install_perm_detail">Abilita 'Installa app sconosciute' nelle impostazioni, poi torna qui.</string>
<string name="install_btn_retry">↩ Riprova</string> <string name="install_btn_retry">↩ Riprova</string>
<string name="btn_back">Indietro</string> <string name="btn_back">Indietro</string>
<string name="btn_launch">🚀 Avvia EverShelf</string> <string name="btn_launch">🚀 Avvia EverShelf</string>
@@ -60,11 +60,11 @@
<string name="wizard_server_ok">Server raggiungibile ✅</string> <string name="wizard_server_ok">Server raggiungibile ✅</string>
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string> <string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
<string name="wizard_server_error">Server non raggiungibile ⚠️</string> <string name="wizard_server_error">Server non raggiungibile ⚠️</string>
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string> <string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l'URL inserito al passaggio 2.</string>
<string name="setup_features_title">Funzionalità</string> <string name="setup_features_title">Funzionalità</string>
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string> <string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string> <string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string> <string name="setup_screensaver_toggle_hint">Mostra l'overlay orologio dopo 5 min di inattività.</string>
<string name="setup_prices_toggle_label">Prezzi lista spesa</string> <string name="setup_prices_toggle_label">Prezzi lista spesa</string>
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string> <string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
<string name="setup_mealplan_toggle_label">Piano pasti</string> <string name="setup_mealplan_toggle_label">Piano pasti</string>
@@ -72,11 +72,15 @@
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string> <string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string> <string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
<string name="setup_gemini_title">Google Gemini AI</string> <string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.\n\nPer abilitarla, inserisci la tua chiave API Gemini gratuita.</string> <string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → \"Ottieni chiave API\"</string>
Per abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → "Ottieni chiave API"</string>
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string> <string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
<string name="setup_bring_title">Bring! Lista della spesa</string> <string name="setup_bring_title">Bring! Lista della spesa</string>
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l\'app Bring!.\n\nInserisci 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!.
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
<string name="setup_bring_email_hint">Email Bring!</string> <string name="setup_bring_email_hint">Email Bring!</string>
<string name="setup_bring_pass_hint">Password Bring!</string> <string name="setup_bring_pass_hint">Password Bring!</string>
<string name="setup_done_title">Tutto pronto!</string> <string name="setup_done_title">Tutto pronto!</string>
+78 -207
View File
@@ -11,23 +11,9 @@
<title>EverShelf</title> <title>EverShelf</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png"> <link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260606m"> <link rel="stylesheet" href="assets/css/style.css?v=20260517a">
<!-- Core modules (auth, DOM helpers) --> <!-- QuaggaJS for barcode scanning -->
<script src="assets/js/core/dom.js?v=20260603a"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></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 --> <!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
<script type="module"> <script type="module">
// Lazy-load the embedding pipeline only when first needed. // Lazy-load the embedding pipeline only when first needed.
@@ -39,27 +25,11 @@
if (window._categoryPipelinePromise) return window._categoryPipelinePromise; if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
window._categoryPipelinePromise = (async () => { window._categoryPipelinePromise = (async () => {
try { try {
const localBase = 'assets/vendor/transformers/'; const { pipeline, env } = await import(
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/'; 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
const modelProbe = localBase + 'Xenova/all-MiniLM-L6-v2/tokenizer.json'; );
let pipeline, env; // Keep WASM/model files in the browser cache; disable remote model check
try { // to avoid CORS issues with the self-hosted instance.
({ 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.allowRemoteModels = true;
env.useBrowserCache = true; env.useBrowserCache = true;
const pipe = await pipeline( const pipe = await pipeline(
@@ -94,7 +64,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div> <div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div> <div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button> <button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.42</span> <span class="app-preloader-version" id="preloader-version">v1.7.35</span>
</div> </div>
</div> </div>
@@ -107,7 +77,7 @@
<!-- Title — left-aligned; grows to fill space --> <!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap"> <div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')"> <h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.42</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.35</span>
</h1> </h1>
<!-- Update badge — shown alongside title, never replaces it --> <!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span> <span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -126,7 +96,6 @@
<button class="header-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title"> <button class="header-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
<svg class="gemini-icon" viewBox="0 0 24 24" width="24" height="24" fill="white"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg> <svg class="gemini-icon" viewBox="0 0 24 24" width="24" height="24" fill="white"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
</button> </button>
<button class="header-btn" onclick="startManualEntry()" title="Ajouter manuellement">✏️</button>
<button class="header-btn header-scan-btn" id="btn-header-scan" <button class="header-btn header-scan-btn" id="btn-header-scan"
title="Scansiona prodotto (tieni premuto per modalità spesa)" data-i18n-title="scan.hint"> title="Scansiona prodotto (tieni premuto per modalità spesa)" data-i18n-title="scan.hint">
📷 📷
@@ -148,6 +117,21 @@
<!-- ===== DASHBOARD ===== --> <!-- ===== DASHBOARD ===== -->
<section class="page active" id="page-dashboard"> <section class="page active" id="page-dashboard">
<div class="dashboard-stats" id="dashboard-stats"> <div class="dashboard-stats" id="dashboard-stats">
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
<span class="stat-icon">🗄️</span>
<span class="stat-value" id="stat-dispensa">-</span>
<span class="stat-label" data-i18n="locations.dispensa">Dispensa</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
<span class="stat-icon">🧊</span>
<span class="stat-value" id="stat-frigo">-</span>
<span class="stat-label" data-i18n="locations.frigo">Frigo</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
<span class="stat-icon">❄️</span>
<span class="stat-value" id="stat-freezer">-</span>
<span class="stat-label" data-i18n="locations.freezer">Freezer</span>
</div>
<div class="stat-card" onclick="showPage('shopping')"> <div class="stat-card" onclick="showPage('shopping')">
<span class="stat-icon">🛒</span> <span class="stat-icon">🛒</span>
<span class="stat-value" id="stat-spesa">-</span> <span class="stat-value" id="stat-spesa">-</span>
@@ -276,7 +260,7 @@
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none"> <div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
<div class="scan-ai-overlay-inner"> <div class="scan-ai-overlay-inner">
<div class="loading-spinner"></div> <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-label">Gemini Vision</span>
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span> <span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
</div> </div>
</div> </div>
@@ -298,8 +282,8 @@
<!-- Scan errors --> <!-- Scan errors -->
<div class="scan-result" id="scan-result" style="display:none"></div> <div class="scan-result" id="scan-result" style="display:none"></div>
<!-- Manual AI identification (only when user taps — never automatic) --> <!-- AI retry button (shown after visual identification fails) -->
<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> <button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
<!-- Recent scans --> <!-- Recent scans -->
<div class="scan-recents" id="scan-recents" style="display:none"> <div class="scan-recents" id="scan-recents" style="display:none">
@@ -390,7 +374,7 @@
<form class="form" onsubmit="submitAdd(event)"> <form class="form" onsubmit="submitAdd(event)">
<div class="form-group"> <div class="form-group">
<label data-i18n="add.location_label">📍 Dove lo metti?</label> <label data-i18n="add.location_label">📍 Dove lo metti?</label>
<div class="location-selector" id="location-selector-add"> <div class="location-selector">
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button> <button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button> <button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button> <button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
@@ -406,7 +390,6 @@
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input"> <input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button> <button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
</div> </div>
<span class="qty-unit-badge qty-unit-muted" id="add-quantity-unit" aria-live="polite">pz</span>
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()"> <select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
<option value="pz">pz</option> <option value="pz">pz</option>
<option value="conf">conf</option> <option value="conf">conf</option>
@@ -438,7 +421,8 @@
<p class="form-hint" id="add-vacuum-hint" style="display:none" data-i18n="add.vacuum_hint">La scadenza verrà estesa automaticamente</p> <p class="form-hint" id="add-vacuum-hint" style="display:none" data-i18n="add.vacuum_hint">La scadenza verrà estesa automaticamente</p>
</div> </div>
<div class="form-group" id="add-expiry-section"> <div class="form-group" id="add-expiry-section">
</div> <!-- Populated dynamically by showAddForm() -->
</div>
<button type="submit" class="btn btn-large btn-success full-width" data-i18n="add.submit">✅ Aggiungi</button> <button type="submit" class="btn btn-large btn-success full-width" data-i18n="add.submit">✅ Aggiungi</button>
</form> </form>
</section> </section>
@@ -485,14 +469,11 @@
</button> </button>
<div class="use-partial"> <div class="use-partial">
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p> <p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
<div class="qty-control-with-unit"> <div class="qty-control">
<div class="qty-control"> <button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)"></button>
<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"
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input" oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();"> <button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
</div>
<span class="qty-unit-badge" id="use-quantity-unit" aria-live="polite"></span>
</div> </div>
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button> <button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
</div> </div>
@@ -645,12 +626,6 @@
<option value="altro">📦 Altro</option> <option value="altro">📦 Altro</option>
</select> </select>
</div> </div>
<div class="form-group" id="pf-subcategory-group" style="display:none">
<label>📂 Sous-catégorie <span class="subcategory-required-mark" style="display:none;color:#e74c3c">*</span></label>
<select id="pf-subcategory" class="form-input">
<option value="">-- Aucune --</option>
</select>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group flex-1"> <div class="form-group flex-1">
<label data-i18n="product.unit_label">📏 Unità di misura</label> <label data-i18n="product.unit_label">📏 Unità di misura</label>
@@ -719,15 +694,6 @@
✨ Genera nuova ricetta ✨ Genera nuova ricetta
</button> </button>
<div id="recipe-archive" class="recipe-archive"></div> <div id="recipe-archive" class="recipe-archive"></div>
<div class="settings-section" style="margin-top:24px">
<h3 class="settings-section-title">📖 Mes recettes</h3>
<p class="settings-hint">Tes propres recettes (cocktails, boissons...), ajoutées à la main.</p>
<button class="btn btn-large btn-accent full-width" style="margin-top:10px" onclick="openRecipeLibraryForm()"> Ajouter une recette</button>
<button class="btn btn-large btn-secondary full-width" style="margin-top:8px" onclick="openRecipeLibraryImportForm()">📋 Importer texte brut</button>
<div id="recipe-library-tag-filter" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:14px"></div>
<div id="recipe-library-list" style="margin-top:14px"></div>
</div>
</div> </div>
</section> </section>
@@ -1229,7 +1195,14 @@
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button> <button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
</div> </div>
<div class="form-group" style="margin-top:14px"> <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> <label class="toggle-row">
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-barcode-ai-fallback">
<span class="toggle-slider"></span>
</span>
</label>
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1240,10 +1213,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> <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"> <div class="form-group">
<label data-i18n="settings.security.token_label">Token di accesso</label> <label data-i18n="settings.security.token_label">Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder"> <input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" 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> <button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div> </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 API (API_TOKEN nel file .env). Il token viene salvato nel browser.</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 per salvare le impostazioni.</p>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4> <h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
@@ -1590,17 +1563,6 @@
</div> </div>
<button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button> <button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button>
<div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div> <div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div>
<div style="margin-top:14px;padding-top:14px;border-top:1px solid var(--border,#e2e8f0)">
<h4 style="margin-bottom:6px">📦 Export / Import complet</h4>
<p class="settings-hint">Exporte la DB + config en un .zip, ou importe un export pour fusionner sans doublons.</p>
<a href="api/index.php?action=export_full" class="btn btn-large btn-accent full-width" style="text-decoration:none;display:block;text-align:center;margin-top:8px">📤 Exporter tout</a>
<div style="margin-top:8px">
<input type="file" id="import-merge-file" accept=".zip" style="display:none" onchange="_importMergeFile(this)">
<button class="btn btn-large btn-secondary full-width" onclick="document.getElementById('import-merge-file').click()">📥 Importer (fusion)</button>
</div>
<div id="import-merge-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
<!-- List of backups --> <!-- List of backups -->
<div id="backup-list-container" style="margin-top:14px"> <div id="backup-list-container" style="margin-top:14px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p> <p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
@@ -1707,6 +1669,18 @@
</div> </div>
</div> </div>
<!-- Kiosk app download banner (hidden inside kiosk WebView) -->
<div id="kiosk-download-banner" style="background:linear-gradient(135deg,rgba(16,185,129,0.08),rgba(5,150,105,0.12));border:1.5px solid rgba(16,185,129,0.25);border-radius:12px;padding:16px;margin-top:16px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
<span style="font-size:1.6rem">📺</span>
<div>
<p style="margin:0;font-weight:700;font-size:0.95rem;color:#065f46">EverShelf Kiosk</p>
<p class="settings-hint" style="margin:2px 0 0" data-i18n="settings.kiosk.hint">Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.</p>
</div>
</div>
<a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" class="btn btn-large btn-accent full-width" style="text-decoration:none;display:block;text-align:center;background:linear-gradient(135deg,#059669,#10b981);color:#fff" data-i18n="settings.kiosk.download_btn">📥 Scarica EverShelf Kiosk (APK)</a>
<p class="settings-hint" style="margin-top:8px" data-i18n="settings.kiosk.download_sub">Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: <code>evershelf-kiosk/</code></p>
</div>
<!-- Kiosk native settings panel (visible only inside kiosk WebView) --> <!-- Kiosk native settings panel (visible only inside kiosk WebView) -->
<div id="kiosk-native-settings-panel" style="display:none;background:rgba(99,102,241,0.06);border:1.5px solid rgba(99,102,241,0.2);border-radius:12px;padding:16px;margin-top:16px"> <div id="kiosk-native-settings-panel" style="display:none;background:rgba(99,102,241,0.06);border:1.5px solid rgba(99,102,241,0.2);border-radius:12px;padding:16px;margin-top:16px">
@@ -1739,124 +1713,25 @@
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()" data-i18n="btn.save_config">💾 Salva Configurazione</button> <button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()" data-i18n="btn.save_config">💾 Salva Configurazione</button>
<div id="settings-status" class="settings-status" style="display:none"></div> <div id="settings-status" class="settings-status" style="display:none"></div>
</section> <!-- About & Support -->
<div class="settings-section" style="margin-top:24px">
<!-- ===== CONFIGURATION PAGE ===== --> <h3 class="settings-section-title" data-i18n="about.title">About</h3>
<section class="page" id="page-config"> <div class="settings-row" style="justify-content:space-between;align-items:center">
<div class="page-header"> <span class="settings-label" data-i18n="about.version">Version</span>
<h2>🔧 Configuration</h2> <span id="about-version-label" class="settings-hint" style="font-family:monospace"></span>
</div>
<div class="config-tabs" style="display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap">
<button class="btn btn-small btn-primary config-tab-btn" data-tab="locations" onclick="showConfigTab('locations')">📍 Emplacements</button>
<button class="btn btn-small btn-secondary config-tab-btn" data-tab="categories" onclick="showConfigTab('categories')">📁 Catégories</button>
<button class="btn btn-small btn-secondary config-tab-btn" data-tab="subcategories" onclick="showConfigTab('subcategories')">📂 Sous-catégories</button>
<button class="btn btn-small btn-secondary config-tab-btn" data-tab="recipetags" onclick="showConfigTab('recipetags')">🏷️ Tags recettes</button>
<button class="btn btn-small btn-secondary config-tab-btn" data-tab="units" onclick="showConfigTab('units')">📏 Unités</button>
</div>
<div class="config-tab-content" id="config-tab-locations">
<div class="settings-card">
<h4>📍 Emplacements de stockage</h4>
<p class="settings-hint">Gère les emplacements disponibles pour ranger tes produits (Frigo, Cave, Bar...).</p>
<div id="locations-list-container" style="margin-top:10px">
<p class="settings-hint">Chargement…</p>
</div>
<div class="form-group mt-2">
<label> Nouvel emplacement</label>
<div class="barcode-input-row">
<input type="text" id="new-location-icon" class="form-input" style="max-width:70px;text-align:center" placeholder="📦" maxlength="4">
<input type="text" id="new-location-label" class="form-input" placeholder="Es: Cave, Bar, Garage..." onkeydown="if(event.key==='Enter'){event.preventDefault();addLocation()}">
<button class="btn btn-accent" onclick="addLocation()"></button>
</div>
</div>
</div> </div>
</div> <div style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
<button class="btn btn-outline full-width" onclick="reportBugManual()" id="btn-report-bug">
<div class="config-tab-content" id="config-tab-categories" style="display:none"> 🐛 <span data-i18n="about.report_bug">Segnala un problema</span>
<div class="settings-card"> </button>
<h4>📁 Catégories</h4> <p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.</p>
<p class="settings-hint">Gère les catégories de produits : icône, libellé, et mots-clés de détection automatique à partir du nom du produit.</p> <div style="display:flex;gap:8px">
<div id="categories-list-container" style="margin-top:10px"> <a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
<p class="settings-hint">Chargement…</p> href="https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md"
</div> target="_blank" rel="noopener" data-i18n="about.changelog">Changelog</a>
<div class="form-group mt-2"> <a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
<label> Nouvelle catégorie</label> href="https://github.com/dadaloop82/EverShelf"
<div class="barcode-input-row"> target="_blank" rel="noopener" data-i18n="about.github">GitHub</a>
<input type="text" id="new-category-icon" class="form-input" style="max-width:70px;text-align:center" placeholder="📦" maxlength="4">
<input type="text" id="new-category-label" class="form-input" placeholder="Es: Apéritifs, Bébé...">
<button class="btn btn-accent" onclick="addCategory()"></button>
</div>
<input type="text" id="new-category-keywords" class="form-input mt-1" placeholder="Mots-clés séparés par des virgules (ex: chips, apéro, biscuit salé)">
</div>
</div>
</div>
<div class="config-tab-content" id="config-tab-subcategories" style="display:none">
<div class="settings-card">
<h4>📂 Sous-catégories</h4>
<p class="settings-hint">Gère les sous-catégories disponibles pour chaque catégorie de produit.</p>
<div class="form-group">
<label>Catégorie</label>
<select id="subcat-config-category" class="form-input" onchange="onSubcatConfigCategoryChange()"></select>
</div>
<div class="form-group" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="subcat-config-required" onchange="toggleSubcategoryRequired()" style="width:auto">
<label for="subcat-config-required" style="margin:0">Sous-catégorie obligatoire pour cette catégorie</label>
</div>
<div id="subcat-list-container" style="margin-top:10px">
<p class="settings-hint">Chargement…</p>
</div>
<div class="form-group mt-2">
<label> Nouvelle sous-catégorie (pour la catégorie sélectionnée)</label>
<div class="barcode-input-row">
<input type="text" id="new-subcat-label" class="form-input" placeholder="Es: 🍷 Vin, 🍗 Poulet..." onkeydown="if(event.key==='Enter'){event.preventDefault();addSubcategoryRow()}">
<button class="btn btn-accent" onclick="addSubcategoryRow()"></button>
</div>
</div>
</div>
</div>
<div class="config-tab-content" id="config-tab-recipetags" style="display:none">
<div class="settings-card">
<h4>🏷️ Tags recettes</h4>
<p class="settings-hint">Gère les tags utilisés pour trier/filtrer "Mes recettes" (cocktails, boissons...).</p>
<div id="recipe-tags-list-container" style="margin-top:10px">
<p class="settings-hint">Chargement…</p>
</div>
<div class="form-group mt-2">
<label> Nouveau tag</label>
<div class="barcode-input-row">
<input type="text" id="new-recipe-tag-icon" class="form-input" style="max-width:70px;text-align:center" placeholder="🏷️" maxlength="4">
<input type="text" id="new-recipe-tag-label" class="form-input" placeholder="Es: Tiki, Sans alcool...">
<input type="text" id="new-recipe-tag-keywords" class="form-input" placeholder="Mots-clés (ex: citron, lime, vinaigre)" onkeydown="if(event.key==='Enter'){event.preventDefault();addRecipeTagConfig()}">
<button class="btn btn-accent" onclick="addRecipeTagConfig()"></button>
</div>
</div>
</div>
</div>
<div class="config-tab-content" id="config-tab-units" style="display:none">
<div class="settings-card">
<h4>📏 Unités</h4>
<p class="settings-hint">Ajoute des unités personnalisées (ex: kg, L) qui se convertissent automatiquement en g/ml/pz pour le stockage, mais s'affichent dans leur propre unité partout dans l'app.</p>
<div id="custom-units-list-container" style="margin-top:10px">
<p class="settings-hint">Chargement…</p>
</div>
<div class="form-group mt-2">
<label> Nouvelle unité</label>
<div class="barcode-input-row">
<input type="text" id="new-unit-icon" class="form-input" style="max-width:70px;text-align:center" placeholder="📏" maxlength="4">
<input type="text" id="new-unit-key" class="form-input" style="max-width:80px" placeholder="Es: kg" maxlength="10">
<input type="text" id="new-unit-label" class="form-input" placeholder="Es: kg (Kilogrammes)">
</div>
<div class="barcode-input-row" style="margin-top:8px">
<select id="new-unit-base" class="form-input">
<option value="g">g (poids)</option>
<option value="ml">ml (volume)</option>
<option value="pz">pz (pièces)</option>
</select>
<input type="number" id="new-unit-factor" class="form-input" placeholder="Facteur (ex: 1000)" min="0.001" step="any">
<button class="btn btn-accent" onclick="addCustomUnitConfig()"></button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1918,10 +1793,6 @@
<span class="nav-icon">📋</span> <span class="nav-icon">📋</span>
<span class="nav-label" data-i18n="nav.log">Storico</span> <span class="nav-label" data-i18n="nav.log">Storico</span>
</button> </button>
<button class="nav-btn" onclick="showPage('config')" data-page="config">
<span class="nav-icon">🔧</span>
<span class="nav-label">Config.</span>
</button>
<button class="nav-btn" onclick="showPage('settings')" data-page="settings"> <button class="nav-btn" onclick="showPage('settings')" data-page="settings">
<span class="nav-icon">⚙️</span> <span class="nav-icon">⚙️</span>
<span class="nav-label" data-i18n="nav.settings">Config</span> <span class="nav-label" data-i18n="nav.settings">Config</span>
@@ -1981,7 +1852,7 @@
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p> <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-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-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="confirm.cancel">Annulla</button> <button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="action.cancel">Annulla</button>
</div> </div>
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn"> <button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
✅ Chiudi ✅ Chiudi
@@ -2091,6 +1962,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260606z"></script> <script src="assets/js/app.js?v=20260518c"></script>
</body> </body>
</html> </html>
-1
View File
@@ -1 +0,0 @@
Require all denied
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.42", "version": "1.7.35",
"start_url": "/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
"theme_color": "#2d5016", "theme_color": "#2d5016",
-9
View File
@@ -1,9 +0,0 @@
{
"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"
}
}
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
{"version":"1.7.19","version_code":20}
-163
View File
@@ -1,163 +0,0 @@
#!/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
@@ -1,65 +0,0 @@
#!/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
@@ -1,79 +0,0 @@
#!/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
@@ -1,14 +0,0 @@
#!/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
@@ -1,12 +0,0 @@
#!/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}"
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env php
<?php
/** Reopen wrongly closed feature issues; close resolved auto-report bugs (English). */
declare(strict_types=1);
define('CRON_MODE', true);
require_once __DIR__ . '/../api/bootstrap.php';
require_once __DIR__ . '/../api/lib/github.php';
require_once __DIR__ . '/../api/lib/constants.php';
$token = _ghToken();
if ($token === '') {
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
exit(1);
}
function ghApi(string $token, string $method, string $url, array $payload = []): array {
$ch = curl_init($url);
$headers = [
'Authorization: token ' . $token,
'Accept: application/vnd.github+json',
'X-GitHub-Api-Version: 2022-11-28',
'User-Agent: EverShelf-Triage/1.0',
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20,
]);
if ($method === 'PATCH') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
} elseif ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
}
function comment(string $token, int $num, string $body): void {
$r = ghApi($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments", ['body' => $body]);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK comment #$num\n" : "FAIL comment #$num\n";
}
function closeIssue(string $token, int $num): void {
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'closed']);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK close #$num\n" : "FAIL close #$num\n";
}
function reopenIssue(string $token, int $num): void {
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'open']);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK reopen #$num\n" : "FAIL reopen #$num\n";
}
$reopen = [
125 => "Reopened: **voice commands in cooking mode** are not implemented yet (only TTS readout exists). This was closed by mistake during bulk triage — the feature backlog should stay open until hands-free step navigation ships.",
98 => "Reopened: **pin favourite products to the top of inventory** is not implemented yet (recipe favourites #124 are done, but product pinning is a separate request). Closed by mistake — keeping on the backlog.",
];
foreach ($reopen as $num => $msg) {
comment($token, $num, $msg);
reopenIssue($token, $num);
}
$bugs = [
201 => 'Fixed in latest develop: `inventory_use` and `shopping_add` now retry on `SQLITE_BUSY` via `dbWithRetry()` (same pattern as #198).',
202 => 'Fixed: Bring/internal `shopping_add` wrapped in `dbWithRetry()` to survive cron + PWA concurrent writes.',
203 => 'Fixed: `smartShopping()` / `smartShoppingCached()` now call `set_time_limit(120)` so large pantries no longer hit the 30s PHP fatal.',
204 => 'Fixed: same as #203 — smart shopping timeout caused HTTP 500; extended execution limit resolves the crash.',
];
foreach ($bugs as $num => $msg) {
comment($token, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._");
closeIssue($token, $num);
}
echo "Done.\n";
-26
View File
@@ -1,26 +0,0 @@
#!/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
@@ -1,111 +0,0 @@
#!/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
@@ -1,57 +0,0 @@
#!/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
@@ -1,64 +0,0 @@
#!/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
@@ -1,341 +0,0 @@
#!/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
@@ -1,44 +0,0 @@
<?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
@@ -1,98 +0,0 @@
#!/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";
+18 -112
View File
@@ -32,7 +32,6 @@
"reset_default": "↺ Standard wiederherstellen", "reset_default": "↺ Standard wiederherstellen",
"save_info": "💾 Info speichern", "save_info": "💾 Info speichern",
"retry": "🔄 Erneut versuchen", "retry": "🔄 Erneut versuchen",
"next": "Weiter →",
"yes_short": "Ja", "yes_short": "Ja",
"no_short": "Nein" "no_short": "Nein"
}, },
@@ -144,10 +143,8 @@
"banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.", "banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
"banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.", "banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.",
"banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.", "banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.",
"banner_finished_vanished": "Das Produkt erscheint nicht mehr im Bestand, aber die Buchungen deuten an, dass es nicht leer sein sollte.",
"banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.", "banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
"banner_finished_check": "Kannst du nachschauen?", "banner_finished_check": "Kannst du nachschauen?",
"banner_finished_action_restore": "{qty} {unit} wiederherstellen",
"banner_anomaly_phantom_title": "mehr Bestand als erwartet", "banner_anomaly_phantom_title": "mehr Bestand als erwartet",
"banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?", "banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht", "banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
@@ -167,11 +164,7 @@
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.", "banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
"banner_explain_title": "Gemini um eine Erklärung bitten", "banner_explain_title": "Gemini um eine Erklärung bitten",
"banner_explain_btn": "Erklären", "banner_explain_btn": "Erklären",
"banner_analyzing": "🤖 Analysiere…", "banner_analyzing": "🤖 Analysiere…"
"banner_prediction_confirmed": "✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet",
"banner_anomaly_explain_fail": "KI-Erklärung konnte nicht abgerufen werden",
"banner_anomaly_dismissed": "Anomalie ignoriert",
"banner_finished_restore_prompt": "Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})"
}, },
"inventory": { "inventory": {
"title": "Vorrat", "title": "Vorrat",
@@ -200,7 +193,6 @@
"mode_shopping": "🛒 Einkaufsmodus", "mode_shopping": "🛒 Einkaufsmodus",
"mode_shopping_end": "✅ Einkauf beenden", "mode_shopping_end": "✅ Einkauf beenden",
"spesa_btn": "🛒 Einkauf", "spesa_btn": "🛒 Einkauf",
"spesa_camera_hint": "Barcode mit der Kamera erfassen. Kein Barcode? Tippe unten auf «Mit KI erkennen».",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Barcode", "tab_barcode": "Barcode",
"tab_name": "Name", "tab_name": "Name",
@@ -236,38 +228,22 @@
"status_confirmed": "Bestätigt!", "status_confirmed": "Bestätigt!",
"status_parallel": "Kombinierter Scan aktiv...", "status_parallel": "Kombinierter Scan aktiv...",
"status_ocr_searching": "Ich lese die Barcode-Ziffern...", "status_ocr_searching": "Ich lese die Barcode-Ziffern...",
"status_digit_ocr": "Lese Ziffern unter dem Barcode...",
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...", "status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
"method_ai_ocr": "Gemini OCR", "method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision", "method_ai_vision": "Gemini Vision",
"method_local_ocr": "Ziffern-OCR",
"method_zbar": "ZBar",
"local_ocr_found": "Code aus Ziffern: {code}",
"ai_fallback_searching": "KI identifiziert Produkt...", "ai_fallback_searching": "KI identifiziert Produkt...",
"ai_fallback_found": "Produkt von KI erkannt", "ai_fallback_found": "Produkt von KI erkannt",
"ai_fallback_not_found": "KI: Produkt nicht erkannt", "ai_fallback_not_found": "KI: Produkt nicht erkannt",
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen", "ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...", "ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
"ai_retry_btn": "Mit KI erneut versuchen", "ai_retry_btn": "Mit KI erneut versuchen",
"ai_manual_btn": "🤖 Mit KI erkennen",
"ai_not_recognized": "KI: Produkt nicht erkannt. Erneut versuchen oder manuell eingeben.",
"ai_match_title": "Produkt von KI erkannt", "ai_match_title": "Produkt von KI erkannt",
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.", "ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
"ai_match_existing": "Aktuell in der Vorratskammer", "ai_match_existing": "Mogliche Treffer in der Vorratskammer",
"ai_match_finished": "Aufgebraucht / leer", "ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
"ai_match_catalog": "Im Katalog (ohne Bestand)", "ai_match_use_btn": "Dieses nutzen",
"ai_match_finished_badge": "aufgebraucht", "ai_match_add_btn": "\"{name}\" hinzufugen",
"ai_match_finished_hint": "Produkt aufgebraucht — Menge auffüllen", "ai_detected_label": "KI erkannt"
"ai_match_merged_existing": "Mit vorhandenem Katalogprodukt verknüpft",
"ai_match_none": "Keine ähnlichen Produkte — du kannst ein neues anlegen.",
"ai_match_use_btn": "Nutzen",
"ai_match_create_btn": " Neu anlegen: {name}",
"ai_match_add_btn": "{name} hinzufugen",
"ai_match_action_hint": "Tippe auf den gruenen Button, um dieses Produkt hinzuzufuegen",
"ai_match_or_similar": "Oder waehle ein aehnliches Produkt:",
"ai_detected_label": "KI erkannt",
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
}, },
"action": { "action": {
"title": "Was möchtest du tun?", "title": "Was möchtest du tun?",
@@ -306,9 +282,8 @@
"hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen", "hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
"scan_expiry_title": "📷 Ablaufdatum scannen", "scan_expiry_title": "📷 Ablaufdatum scannen",
"product_added": "✅ {name} hinzugefügt!{qty}", "product_added": "✅ {name} hinzugefügt!{qty}",
"duplicate_recent_confirm": "Du hast «{name}» gerade hinzugefuegt ({when}).\n\nDie Menge ist bereits {total}.\n\nUm {qty} erhoehen?",
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)", "suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
"history_badge_tip": "Durchschnitt der letzten {n} Einträge — wird bei jedem Kauf aktualisiert", "history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
"vacuum_question": "Vakuumiert?", "vacuum_question": "Vakuumiert?",
"vacuum_saved": "🔒 Als vakuumiert gespeichert" "vacuum_saved": "🔒 Als vakuumiert gespeichert"
}, },
@@ -341,29 +316,14 @@
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", "toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!", "toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?", "disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
"disambiguation_one_conf": "<strong>1 Packung</strong> aufgebraucht ({qty})",
"disambiguation_all": "🗑️ ALLES verbraucht ({qty})", "disambiguation_all": "🗑️ ALLES verbraucht ({qty})",
"toast_one_conf_finished": "📦 1 Packung von {name} verbraucht!",
"error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!", "error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!",
"use_all_confirm_title": "✅ Alles aufbrauchen", "use_all_confirm_title": "✅ Alles aufbrauchen",
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:", "use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
"use_all_confirm_btn": "✅ Ja, aufgebraucht", "use_all_confirm_btn": "✅ Ja, aufgebraucht",
"throw_all_confirm_title": "🗑️ Alles entsorgen", "throw_all_confirm_title": "🗑️ Alles entsorgen",
"throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?", "throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?",
"throw_all_confirm_btn": "🗑️ Ja, entsorgen", "throw_all_confirm_btn": "🗑️ Ja, entsorgen"
"locations_short": "Orte"
},
"waste": {
"reason_title": "Warum wirfst du es weg?",
"reason_subtitle": "Das hilft uns, ähnliche Verschwendung zu vermeiden.",
"reason_expired": "⏰ Abgelaufen",
"reason_spoiled": "🦠 Verdorben",
"reason_wrong_location": "📍 Falscher Lagerort",
"reason_kept_too_long": "⏳ Zu lange aufbewahrt",
"reason_bought_too_much": "🛒 Zu viel gekauft",
"reason_forgotten": "😴 Vergessen / nicht rechtzeitig genutzt",
"reason_bad_quality": "👎 Schlechte Qualität beim Kauf",
"reason_other": "❓ Sonstiges"
}, },
"product": { "product": {
"title_new": "Neues Produkt", "title_new": "Neues Produkt",
@@ -373,8 +333,6 @@
"name_label": "🏷️ Produktname *", "name_label": "🏷️ Produktname *",
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...", "name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
"brand_label": "🏢 Marke", "brand_label": "🏢 Marke",
"allergens_label": "Allergene:",
"ingredients_summary": "📋 Zutaten",
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...", "brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
"category_label": "📂 Kategorie", "category_label": "📂 Kategorie",
"unit_label": "📏 Maßeinheit", "unit_label": "📏 Maßeinheit",
@@ -405,9 +363,7 @@
"weight_label": "Gewicht", "weight_label": "Gewicht",
"origin_label": "Herkunft", "origin_label": "Herkunft",
"labels_label": "Etiketten", "labels_label": "Etiketten",
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:", "select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
"history_badge": "📊 Verlauf",
"from_history": " (Ø letzte 3)"
}, },
"products": { "products": {
"title": "📦 Alle Produkte", "title": "📦 Alle Produkte",
@@ -460,18 +416,7 @@
"load_error": "Fehler beim Laden", "load_error": "Fehler beim Laden",
"favorite": "Zu Favoriten hinzufügen", "favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"adjust_persons": "Personen", "adjust_persons": "Personen"
"nutrition_title": "Nährwerte (pro Portion)",
"nutrition_kcal": "Kalorien",
"nutrition_protein": "Protein",
"nutrition_carbs": "Kohlenhydrate",
"nutrition_fat": "Fett",
"nutrition_per_serving": "Geschätzte Werte pro Portion",
"storage_title": "Aufbewahrung von Resten",
"storage_days": "{n} Tage",
"storage_immediately": "Am besten sofort verzehren",
"ing_stock_line": "Du hast {have} · {remain} bleiben nach Gebrauch",
"ing_use_all_note": "alles verwenden (<5% der Vollpackung)"
}, },
"shopping": { "shopping": {
"title": "🛒 Einkaufsliste", "title": "🛒 Einkaufsliste",
@@ -531,18 +476,6 @@
"item_removed": "✅ {name} von der Liste entfernt!", "item_removed": "✅ {name} von der Liste entfernt!",
"urgency_spec_critical": "⚡ Dringend", "urgency_spec_critical": "⚡ Dringend",
"urgency_spec_high": "🟠 Bald", "urgency_spec_high": "🟠 Bald",
"urgency_spec_medium": "🟡 Bald",
"urgency_spec_low": "🔵 Prognose",
"family_sibling_title": "Ähnlich in {location}",
"family_sibling_check": "Prüfen: {name}",
"family_sibling_stock": "Du solltest haben: {qty}",
"family_sibling_location": "Standort: {location}",
"family_sibling_qty": "Menge: {qty}",
"family_sibling_purchased": "Gekauft am {date}",
"family_sibling_question": "Ist die Menge noch korrekt?",
"family_sibling_prompt": "Du hast auch {name}: {qty} auf Lager. Menge bestätigen?",
"family_sibling_yes": "Ja, passt",
"family_sibling_no": "Nein, aktualisieren",
"bring_add_n": "{n} zu Bring! hinzufügen", "bring_add_n": "{n} zu Bring! hinzufügen",
"bring_add_selected": "Ausgewählte zu Bring! hinzufügen", "bring_add_selected": "Ausgewählte zu Bring! hinzufügen",
"bring_adding": "Wird hinzugefügt...", "bring_adding": "Wird hinzugefügt...",
@@ -570,7 +503,6 @@
"remove_error": "Fehler beim Entfernen", "remove_error": "Fehler beim Entfernen",
"btn_fetch_prices": "Preise suchen", "btn_fetch_prices": "Preise suchen",
"price_total_label": "💰 Geschätzter Gesamtpreis:", "price_total_label": "💰 Geschätzter Gesamtpreis:",
"price_total_short": "geschätzte Ausgaben",
"price_loading": "Preise werden gesucht…", "price_loading": "Preise werden gesucht…",
"price_not_found": "Preis n/v", "price_not_found": "Preis n/v",
"suggest_loading": "Analyse läuft...", "suggest_loading": "Analyse läuft...",
@@ -580,8 +512,7 @@
"priority_low": "Niedrig", "priority_low": "Niedrig",
"smart_last_update": "Aktualisiert {time}", "smart_last_update": "Aktualisiert {time}",
"names_already_updated": "Alle Namen sind bereits aktuell", "names_already_updated": "Alle Namen sind bereits aktuell",
"pantry_hint": "Bereits zuhause: {qty}", "pantry_hint": "Bereits zuhause: {qty}"
"bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert"
}, },
"ai": { "ai": {
"title": "🤖 KI-Identifikation", "title": "🤖 KI-Identifikation",
@@ -592,8 +523,7 @@
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>", "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
"fields_filled": "✅ Felder von KI ausgefüllt", "fields_filled": "✅ Felder von KI ausgefüllt",
"use_data": "✅ KI-Daten verwenden", "use_data": "✅ KI-Daten verwenden",
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)", "use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)"
"conservation_hint": "🤖 KI: lagere in {location}"
}, },
"log": { "log": {
"title": "📒 Verlauf", "title": "📒 Verlauf",
@@ -759,8 +689,7 @@
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.", "devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
"detect_btn": "🔄 Kameras erkennen", "detect_btn": "🔄 Kameras erkennen",
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)", "ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini.", "ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini."
"ai_manual_hint": "Wenn der Barcode nicht lesbar ist, nutze den Button «Mit KI erkennen» unter der Kamera. Erfordert konfiguriertes Gemini."
}, },
"security": { "security": {
"title": "🔒 HTTPS-Zertifikat", "title": "🔒 HTTPS-Zertifikat",
@@ -849,13 +778,7 @@
"kiosk_title": "📡 BLE-Waage im Kiosk integriert", "kiosk_title": "📡 BLE-Waage im Kiosk integriert",
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.", "kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren", "kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>", "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>"
"discover_scanning": "🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…",
"discover_found": "✅ Gateway gefunden: {url}{more}",
"discover_not_found": "❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.",
"discover_failed": "❌ Suche fehlgeschlagen: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Unbekanntes Gerät"
}, },
"kiosk": { "kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.", "hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
@@ -1042,8 +965,7 @@
"sensor_copied": "YAML in die Zwischenablage kopiert!", "sensor_copied": "YAML in die Zwischenablage kopiert!",
"save_btn": "HA-Einstellungen speichern", "save_btn": "HA-Einstellungen speichern",
"ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren." "ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren."
}, }
"kiosk_update_required": "⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen"
}, },
"expiry": { "expiry": {
"today": "HEUTE", "today": "HEUTE",
@@ -1117,7 +1039,6 @@
"finished_all": "📤 {name} aufgebraucht!", "finished_all": "📤 {name} aufgebraucht!",
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert", "vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst", "product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
"ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt",
"appliance_added": "Gerät hinzugefügt", "appliance_added": "Gerät hinzugefügt",
"item_added": "{name} hinzugefügt" "item_added": "{name} hinzugefügt"
}, },
@@ -1189,9 +1110,7 @@
"offline_ops_pending": "{n} Aktionen ausstehend", "offline_ops_pending": "{n} Aktionen ausstehend",
"offline_synced": "{n} Aktionen synchronisiert", "offline_synced": "{n} Aktionen synchronisiert",
"offline_ai_disabled": "Offline nicht verfügbar", "offline_ai_disabled": "Offline nicht verfügbar",
"offline_cache_ready": "Offline — {n} Produkte im Cache", "offline_cache_ready": "Offline — {n} Produkte im Cache"
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
"invalid_quantity": "Ungültige Menge"
}, },
"confirm_placeholder_search": null, "confirm_placeholder_search": null,
"confirm": { "confirm": {
@@ -1313,8 +1232,7 @@
"stay_btn": "Nein, bleibt in {location}", "stay_btn": "Nein, bleibt in {location}",
"moved_toast": "📦 Offene Packung bewegt nach {location}", "moved_toast": "📦 Offene Packung bewegt nach {location}",
"vacuum_restore": "Vakuum wiederherstellen", "vacuum_restore": "Vakuum wiederherstellen",
"vacuum_seal_rest": "Rest vakuumieren", "vacuum_seal_rest": "Rest vakuumieren"
"moved_simple": "📦 Nach {location} verschoben"
}, },
"nova": { "nova": {
"1": "Unverarbeitet", "1": "Unverarbeitet",
@@ -1549,12 +1467,7 @@
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.", "error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
"retry": "Erneut versuchen", "retry": "Erneut versuchen",
"syncing_local": "Lokale Daten synchronisieren...", "syncing_local": "Lokale Daten synchronisieren...",
"sync_done": "Lokale Daten aktualisiert", "sync_done": "Lokale Daten aktualisiert"
"token_required": "API-Token erforderlich",
"token_autoconfig": "Zugriff wird konfiguriert...",
"token_prompt_title": "🔒 API-Token",
"token_prompt_hint": "Geben Sie den API_TOKEN-Wert aus der .env-Datei des Servers ein.",
"token_prompt_btn": "Weiter"
}, },
"stats_monthly": { "stats_monthly": {
"title": "Monatsstatistik", "title": "Monatsstatistik",
@@ -1567,12 +1480,5 @@
"top_used": "meistbenutzt", "top_used": "meistbenutzt",
"top_cats": "Hauptkategorien", "top_cats": "Hauptkategorien",
"source": "Transaktionsverlauf · aktueller Monat" "source": "Transaktionsverlauf · aktueller Monat"
},
"time": {
"just_now": "gerade eben",
"seconds_ago": "vor {n}s",
"minutes_ago": "vor {n} min",
"hours_ago": "vor {n} h",
"days_ago": "vor {n} T"
} }
} }
+18 -117
View File
@@ -32,7 +32,6 @@
"reset_default": "↺ Reset to default", "reset_default": "↺ Reset to default",
"save_info": "💾 Save information", "save_info": "💾 Save information",
"retry": "🔄 Retry", "retry": "🔄 Retry",
"next": "Next →",
"yes_short": "Yes", "yes_short": "Yes",
"no_short": "No" "no_short": "No"
}, },
@@ -144,10 +143,8 @@
"banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.", "banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
"banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.", "banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.",
"banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.", "banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.",
"banner_finished_vanished": "This product no longer appears in inventory, but recorded movements suggest it shouldn't be empty.",
"banner_finished_expected": "According to records you should still have {qty} {unit}.", "banner_finished_expected": "According to records you should still have {qty} {unit}.",
"banner_finished_check": "Can you check?", "banner_finished_check": "Can you check?",
"banner_finished_action_restore": "Restore {qty} {unit}",
"banner_anomaly_phantom_title": "you have more stock than expected", "banner_anomaly_phantom_title": "you have more stock than expected",
"banner_anomaly_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?", "banner_anomaly_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?",
"banner_anomaly_untracked_title": "stock not recorded as an entry", "banner_anomaly_untracked_title": "stock not recorded as an entry",
@@ -167,11 +164,7 @@
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.", "banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
"banner_explain_title": "Ask Gemini for an explanation", "banner_explain_title": "Ask Gemini for an explanation",
"banner_explain_btn": "Explain", "banner_explain_btn": "Explain",
"banner_analyzing": "🤖 Analyzing…", "banner_analyzing": "🤖 Analyzing…"
"banner_prediction_confirmed": "✅ Confirmed — forecasts will recalculate from your next entries",
"banner_anomaly_explain_fail": "Could not get AI explanation",
"banner_anomaly_dismissed": "Anomaly dismissed",
"banner_finished_restore_prompt": "How many {unit} of {name} do you still have? (system estimate: {qty})"
}, },
"inventory": { "inventory": {
"title": "Pantry", "title": "Pantry",
@@ -200,7 +193,6 @@
"mode_shopping": "🛒 Shopping Mode", "mode_shopping": "🛒 Shopping Mode",
"mode_shopping_end": "✅ End shopping", "mode_shopping_end": "✅ End shopping",
"spesa_btn": "🛒 Shopping", "spesa_btn": "🛒 Shopping",
"spesa_camera_hint": "Point the camera at the barcode. No barcode? Tap «Identify with AI» below.",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Barcode", "tab_barcode": "Barcode",
"tab_name": "Name", "tab_name": "Name",
@@ -236,38 +228,22 @@
"status_confirmed": "Confirmed!", "status_confirmed": "Confirmed!",
"status_parallel": "Using combined scan methods...", "status_parallel": "Using combined scan methods...",
"status_ocr_searching": "Reading the barcode digits...", "status_ocr_searching": "Reading the barcode digits...",
"status_digit_ocr": "Reading numbers below the barcode...",
"status_ai_visual_searching": "Now trying to recognize the product...", "status_ai_visual_searching": "Now trying to recognize the product...",
"method_ai_ocr": "Gemini OCR", "method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision", "method_ai_vision": "Gemini Vision",
"method_local_ocr": "Digit OCR",
"method_zbar": "ZBar",
"local_ocr_found": "Code from digits: {code}",
"ai_fallback_searching": "AI identifying product...", "ai_fallback_searching": "AI identifying product...",
"ai_fallback_found": "Product identified by AI", "ai_fallback_found": "Product identified by AI",
"ai_fallback_not_found": "AI: product not recognized", "ai_fallback_not_found": "AI: product not recognized",
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode", "ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision is analyzing the product...", "ai_overlay_msg": "Gemini Vision is analyzing the product...",
"ai_retry_btn": "Retry with AI", "ai_retry_btn": "Retry with AI",
"ai_manual_btn": "🤖 Identify with AI",
"ai_not_recognized": "AI could not recognize the product. Try again or add manually.",
"ai_match_title": "Product recognized by AI", "ai_match_title": "Product recognized by AI",
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.", "ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
"ai_match_existing": "Currently in pantry", "ai_match_existing": "Possible pantry matches",
"ai_match_finished": "Finished / depleted", "ai_match_none": "No similar pantry products found.",
"ai_match_catalog": "In catalog (no stock)", "ai_match_use_btn": "Use this",
"ai_match_finished_badge": "depleted", "ai_match_add_btn": "Add \"{name}\"",
"ai_match_finished_hint": "Finished product — restock the quantity", "ai_detected_label": "AI detected"
"ai_match_merged_existing": "Linked to an existing catalog product",
"ai_match_none": "No similar products found — you can create a new one.",
"ai_match_use_btn": "Use",
"ai_match_create_btn": " Create new: {name}",
"ai_match_add_btn": "Add {name}",
"ai_match_action_hint": "Tap the green button to add this product",
"ai_match_or_similar": "Or pick a similar product:",
"ai_detected_label": "AI detected",
"mode_shopping_activated": "🛒 Shopping mode activated!"
}, },
"action": { "action": {
"title": "What do you want to do?", "title": "What do you want to do?",
@@ -306,9 +282,8 @@
"hint_modify": "📝 You can change the date or scan it with the camera", "hint_modify": "📝 You can change the date or scan it with the camera",
"scan_expiry_title": "📷 Scan Expiry Date", "scan_expiry_title": "📷 Scan Expiry Date",
"product_added": "✅ {name} added!{qty}", "product_added": "✅ {name} added!{qty}",
"duplicate_recent_confirm": "You just added \"{name}\" ({when}).\n\nQuantity is already {total}.\n\nIncrease it by {qty}?",
"suffix_freezer_vacuum": "(freezer + vacuum sealed)", "suffix_freezer_vacuum": "(freezer + vacuum sealed)",
"history_badge_tip": "Average of the last {n} entries — updates with each new purchase", "history_badge_tip": "Average from {n} previous entries",
"vacuum_question": "Vacuum sealed?", "vacuum_question": "Vacuum sealed?",
"vacuum_saved": "🔒 Vacuum sealed!" "vacuum_saved": "🔒 Vacuum sealed!"
}, },
@@ -341,29 +316,14 @@
"toast_bring": "🛒 Product finished → added to Bring!", "toast_bring": "🛒 Product finished → added to Bring!",
"toast_opened_finished": "🔓 Opened package of {name} finished!", "toast_opened_finished": "🔓 Opened package of {name} finished!",
"disambiguation_hint": "What do you mean by \"all done\"?", "disambiguation_hint": "What do you mean by \"all done\"?",
"disambiguation_one_conf": "Finished <strong>1 package</strong> ({qty})",
"disambiguation_all": "🗑️ Finish EVERYTHING ({qty})", "disambiguation_all": "🗑️ Finish EVERYTHING ({qty})",
"toast_one_conf_finished": "📦 1 package of {name} finished!",
"error_exceeds_stock": "⚠️ You cannot use more than you have available!", "error_exceeds_stock": "⚠️ You cannot use more than you have available!",
"use_all_confirm_title": "✅ Finish everything", "use_all_confirm_title": "✅ Finish everything",
"use_all_confirm_msg": "Confirm that you have finished the product:", "use_all_confirm_msg": "Confirm that you have finished the product:",
"use_all_confirm_btn": "✅ Yes, finished", "use_all_confirm_btn": "✅ Yes, finished",
"throw_all_confirm_title": "🗑️ Discard everything", "throw_all_confirm_title": "🗑️ Discard everything",
"throw_all_confirm_msg": "Do you really want to throw away the whole product?", "throw_all_confirm_msg": "Do you really want to throw away the whole product?",
"throw_all_confirm_btn": "🗑️ Yes, discard", "throw_all_confirm_btn": "🗑️ Yes, discard"
"locations_short": "places"
},
"waste": {
"reason_title": "Why are you discarding it?",
"reason_subtitle": "This helps us prevent similar waste next time.",
"reason_expired": "⏰ Expired",
"reason_spoiled": "🦠 Spoiled / gone bad",
"reason_wrong_location": "📍 Wrong storage (fridge/freezer/pantry)",
"reason_kept_too_long": "⏳ Kept too long",
"reason_bought_too_much": "🛒 Bought too much",
"reason_forgotten": "😴 Forgotten / not used in time",
"reason_bad_quality": "👎 Poor quality when bought",
"reason_other": "❓ Other"
}, },
"product": { "product": {
"title_new": "New Product", "title_new": "New Product",
@@ -373,8 +333,6 @@
"name_label": "🏷️ Product Name *", "name_label": "🏷️ Product Name *",
"name_placeholder": "E.g.: Whole milk, Penne pasta...", "name_placeholder": "E.g.: Whole milk, Penne pasta...",
"brand_label": "🏢 Brand", "brand_label": "🏢 Brand",
"allergens_label": "Allergens:",
"ingredients_summary": "📋 Ingredients",
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...", "brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
"category_label": "📂 Category", "category_label": "📂 Category",
"unit_label": "📏 Unit of measure", "unit_label": "📏 Unit of measure",
@@ -405,9 +363,7 @@
"weight_label": "Weight", "weight_label": "Weight",
"origin_label": "Origin", "origin_label": "Origin",
"labels_label": "Labels", "labels_label": "Labels",
"select_variant": "Select the exact variant or use AI data:", "select_variant": "Select the exact variant or use AI data:"
"history_badge": "📊 history",
"from_history": " (last 3 avg)"
}, },
"products": { "products": {
"title": "📦 All Products", "title": "📦 All Products",
@@ -439,11 +395,6 @@
"regen_save_new": "💾 Save to archive & generate a new one", "regen_save_new": "💾 Save to archive & generate a new one",
"close_btn": "✅ Close", "close_btn": "✅ Close",
"ingredients_title": "🧾 Ingredients", "ingredients_title": "🧾 Ingredients",
"shopping_suggestions_intro": "For an alternative version you'd need (not in pantry — optional):",
"shopping_suggestions_add": "Add to shopping list",
"shopping_suggestions_added": "Added to shopping list",
"unit_for_input": "Unit of measure",
"enter_in": "Enter value in",
"tools_title": "Equipment needed", "tools_title": "Equipment needed",
"steps_title": "👨‍🍳 Steps", "steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available", "no_steps": "No steps available",
@@ -465,18 +416,7 @@
"load_error": "Loading error", "load_error": "Loading error",
"favorite": "Add to favourites", "favorite": "Add to favourites",
"unfavorite": "Remove from favourites", "unfavorite": "Remove from favourites",
"adjust_persons": "Persons", "adjust_persons": "Persons"
"nutrition_title": "Nutritional values (per serving)",
"nutrition_kcal": "Calories",
"nutrition_protein": "Protein",
"nutrition_carbs": "Carbs",
"nutrition_fat": "Fat",
"nutrition_per_serving": "Estimated values per serving",
"storage_title": "How to store leftovers",
"storage_days": "{n} days",
"storage_immediately": "Best eaten immediately",
"ing_stock_line": "You have {have} · {remain} left after use",
"ing_use_all_note": "use all (<5% of full package left)"
}, },
"shopping": { "shopping": {
"title": "🛒 Shopping List", "title": "🛒 Shopping List",
@@ -536,18 +476,6 @@
"item_removed": "✅ {name} removed from list!", "item_removed": "✅ {name} removed from list!",
"urgency_spec_critical": "⚡ Urgent", "urgency_spec_critical": "⚡ Urgent",
"urgency_spec_high": "🟠 Soon", "urgency_spec_high": "🟠 Soon",
"urgency_spec_medium": "🟡 Soon",
"urgency_spec_low": "🔵 Forecast",
"family_sibling_title": "Similar in {location}",
"family_sibling_check": "Check: {name}",
"family_sibling_stock": "You should have: {qty}",
"family_sibling_location": "Location: {location}",
"family_sibling_qty": "Quantity: {qty}",
"family_sibling_purchased": "Purchased on {date}",
"family_sibling_question": "Is the quantity still correct?",
"family_sibling_prompt": "You also have {name}: {qty} in stock. Confirm the quantity?",
"family_sibling_yes": "Yes, all good",
"family_sibling_no": "No, update",
"bring_add_n": "Add {n} to Bring!", "bring_add_n": "Add {n} to Bring!",
"bring_add_selected": "Add selected to Bring!", "bring_add_selected": "Add selected to Bring!",
"bring_adding": "Adding...", "bring_adding": "Adding...",
@@ -575,7 +503,6 @@
"remove_error": "Removal error", "remove_error": "Removal error",
"btn_fetch_prices": "Find prices", "btn_fetch_prices": "Find prices",
"price_total_label": "💰 Estimated total:", "price_total_label": "💰 Estimated total:",
"price_total_short": "estimated total",
"price_loading": "Looking up prices…", "price_loading": "Looking up prices…",
"price_not_found": "price n/a", "price_not_found": "price n/a",
"suggest_loading": "Analyzing...", "suggest_loading": "Analyzing...",
@@ -585,8 +512,7 @@
"priority_low": "Low", "priority_low": "Low",
"smart_last_update": "Updated {time}", "smart_last_update": "Updated {time}",
"names_already_updated": "All names are already up to date", "names_already_updated": "All names are already up to date",
"pantry_hint": "Already at home: {qty}", "pantry_hint": "Already at home: {qty}"
"bring_names_migrated": "🔄 {n} names generalized in Bring!"
}, },
"ai": { "ai": {
"title": "🤖 AI Identification", "title": "🤖 AI Identification",
@@ -597,8 +523,7 @@
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>", "no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
"fields_filled": "✅ Fields filled by AI", "fields_filled": "✅ Fields filled by AI",
"use_data": "✅ Use AI data", "use_data": "✅ Use AI data",
"use_data_no_barcode": "✅ Use AI data (no barcode)", "use_data_no_barcode": "✅ Use AI data (no barcode)"
"conservation_hint": "🤖 AI: store in {location}"
}, },
"log": { "log": {
"title": "📒 Operations Log", "title": "📒 Operations Log",
@@ -764,8 +689,7 @@
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.", "devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
"detect_btn": "🔄 Detect cameras", "detect_btn": "🔄 Detect cameras",
"ai_fallback_label": "AI visual identification (5s fallback)", "ai_fallback_label": "AI visual identification (5s fallback)",
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured.", "ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured."
"ai_manual_hint": "If the barcode cannot be read, use the «Identify with AI» button below the camera. Requires Gemini configured."
}, },
"security": { "security": {
"title": "🔒 HTTPS Certificate", "title": "🔒 HTTPS Certificate",
@@ -854,13 +778,7 @@
"kiosk_title": "📡 BLE Scale integrated in Kiosk", "kiosk_title": "📡 BLE Scale integrated in Kiosk",
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.", "kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale", "kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>", "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>"
"discover_scanning": "🔍 Scanning local network for scale gateway…",
"discover_found": "✅ Gateway found: {url}{more}",
"discover_not_found": "❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.",
"discover_failed": "❌ Discovery failed: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Unknown device"
}, },
"kiosk": { "kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.", "hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
@@ -1047,8 +965,7 @@
"sensor_copied": "YAML copied to clipboard!", "sensor_copied": "YAML copied to clipboard!",
"save_btn": "Save HA settings", "save_btn": "Save HA settings",
"ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors." "ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors."
}, }
"kiosk_update_required": "⚠️ Update the kiosk app to use this feature"
}, },
"expiry": { "expiry": {
"today": "TODAY", "today": "TODAY",
@@ -1122,7 +1039,6 @@
"finished_all": "📤 {name} finished!", "finished_all": "📤 {name} finished!",
"vacuum_sealed": "{name} saved as vacuum sealed", "vacuum_sealed": "{name} saved as vacuum sealed",
"product_finished_confirmed": "✅ Removed — add it again when you restock", "product_finished_confirmed": "✅ Removed — add it again when you restock",
"ghost_restored": "✅ {name}: restored {qty} {unit} to inventory",
"appliance_added": "Appliance added", "appliance_added": "Appliance added",
"item_added": "{name} added" "item_added": "{name} added"
}, },
@@ -1194,9 +1110,7 @@
"offline_ops_pending": "{n} operations pending", "offline_ops_pending": "{n} operations pending",
"offline_synced": "{n} operations synced", "offline_synced": "{n} operations synced",
"offline_ai_disabled": "Not available offline", "offline_ai_disabled": "Not available offline",
"offline_cache_ready": "Offline — {n} items cached", "offline_cache_ready": "Offline — {n} items cached"
"copy_failed": "Copy to clipboard failed",
"invalid_quantity": "Invalid quantity"
}, },
"confirm_placeholder_search": null, "confirm_placeholder_search": null,
"confirm": { "confirm": {
@@ -1318,8 +1232,7 @@
"stay_btn": "No, stay in {location}", "stay_btn": "No, stay in {location}",
"moved_toast": "📦 Opened package moved to {location}", "moved_toast": "📦 Opened package moved to {location}",
"vacuum_restore": "Restore vacuum sealed", "vacuum_restore": "Restore vacuum sealed",
"vacuum_seal_rest": "Vacuum seal the rest", "vacuum_seal_rest": "Vacuum seal the rest"
"moved_simple": "📦 Moved to {location}"
}, },
"nova": { "nova": {
"1": "Unprocessed", "1": "Unprocessed",
@@ -1554,12 +1467,7 @@
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.", "error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
"retry": "Retry", "retry": "Retry",
"syncing_local": "Syncing local data...", "syncing_local": "Syncing local data...",
"sync_done": "Local data synced", "sync_done": "Local data synced"
"token_required": "API token required",
"token_autoconfig": "Configuring access...",
"token_prompt_title": "🔒 API Token",
"token_prompt_hint": "Enter the API_TOKEN value from the server .env file.",
"token_prompt_btn": "Continue"
}, },
"stats_monthly": { "stats_monthly": {
"title": "Monthly Stats", "title": "Monthly Stats",
@@ -1572,12 +1480,5 @@
"top_used": "top used", "top_used": "top used",
"top_cats": "Top categories", "top_cats": "Top categories",
"source": "Transaction history · current month" "source": "Transaction history · current month"
},
"time": {
"just_now": "just now",
"seconds_ago": "{n}s ago",
"minutes_ago": "{n} min ago",
"hours_ago": "{n} h ago",
"days_ago": "{n} d ago"
} }
} }
+22 -173
View File
@@ -32,7 +32,6 @@
"reset_default": "↺ Restablecer valores por defecto", "reset_default": "↺ Restablecer valores por defecto",
"save_info": "💾 Guardar información", "save_info": "💾 Guardar información",
"retry": "🔄 Reintentar", "retry": "🔄 Reintentar",
"next": "Siguiente →",
"yes_short": "Sí", "yes_short": "Sí",
"no_short": "No" "no_short": "No"
}, },
@@ -142,10 +141,8 @@
"banner_prediction_more": "estimación anterior: {expected} {unit}{time}; cantidad actual: {actual} {unit}.", "banner_prediction_more": "estimación anterior: {expected} {unit}{time}; cantidad actual: {actual} {unit}.",
"banner_prediction_less": "estimación: {expected} {unit}{time}; cantidad actual: {actual} {unit}. Si tu ritmo de uso cambió, la previsión se actualiza automáticamente.", "banner_prediction_less": "estimación: {expected} {unit}{time}; cantidad actual: {actual} {unit}. Si tu ritmo de uso cambió, la previsión se actualiza automáticamente.",
"banner_finished_zero": "El inventario muestra cero, pero los movimientos registrados sugieren que no debería estar vacío.", "banner_finished_zero": "El inventario muestra cero, pero los movimientos registrados sugieren que no debería estar vacío.",
"banner_finished_vanished": "Este producto ya no aparece en el inventario, pero los movimientos registrados sugieren que no debería estar vacío.",
"banner_finished_expected": "Según los registros deberías tener todavía {qty} {unit}.", "banner_finished_expected": "Según los registros deberías tener todavía {qty} {unit}.",
"banner_finished_check": "¿Puedes comprobarlo?", "banner_finished_check": "¿Puedes comprobarlo?",
"banner_finished_action_restore": "Restaurar {qty} {unit}",
"banner_anomaly_phantom_title": "tienes más stock del esperado", "banner_anomaly_phantom_title": "tienes más stock del esperado",
"banner_anomaly_phantom_detail": "El inventario indica {inv_qty} {unit}, pero según los registros solo deberías tener {expected_qty} {unit}. ¿Añadiste stock sin registrarlo?", "banner_anomaly_phantom_detail": "El inventario indica {inv_qty} {unit}, pero según los registros solo deberías tener {expected_qty} {unit}. ¿Añadiste stock sin registrarlo?",
"banner_anomaly_untracked_title": "stock no registrado como entrada", "banner_anomaly_untracked_title": "stock no registrado como entrada",
@@ -165,13 +162,7 @@
"banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.", "banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.",
"banner_explain_title": "Pedir explicación a Gemini", "banner_explain_title": "Pedir explicación a Gemini",
"banner_explain_btn": "Explicar", "banner_explain_btn": "Explicar",
"banner_analyzing": "🤖 Analizando…", "banner_analyzing": "🤖 Analizando…"
"banner_expired_action_modify": "Editar",
"banner_expired_action_vacuum": "Poner al vacío",
"banner_prediction_confirmed": "✅ Confirmado — las previsiones se recalcularán con tus próximos registros",
"banner_anomaly_explain_fail": "No se pudo obtener la explicación de IA",
"banner_anomaly_dismissed": "Anomalía descartada",
"banner_finished_restore_prompt": "¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})"
}, },
"inventory": { "inventory": {
"title": "Despensa", "title": "Despensa",
@@ -200,7 +191,6 @@
"mode_shopping": "🛒 Modo compras", "mode_shopping": "🛒 Modo compras",
"mode_shopping_end": "✅ Finalizar compras", "mode_shopping_end": "✅ Finalizar compras",
"spesa_btn": "🛒 Compras", "spesa_btn": "🛒 Compras",
"spesa_camera_hint": "Enfoca el código con la cámara. ¿Sin código? Pulsa «Identificar con IA» abajo.",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Código de barras", "tab_barcode": "Código de barras",
"tab_name": "Nombre", "tab_name": "Nombre",
@@ -235,39 +225,22 @@
"status_confirmed": "Confirmado!", "status_confirmed": "Confirmado!",
"status_parallel": "Escaneo combinado activo...", "status_parallel": "Escaneo combinado activo...",
"status_ocr_searching": "Estoy leyendo los números del código de barras...", "status_ocr_searching": "Estoy leyendo los números del código de barras...",
"status_digit_ocr": "Leyendo los números bajo el código...",
"status_ai_visual_searching": "Ahora intento reconocer el producto...", "status_ai_visual_searching": "Ahora intento reconocer el producto...",
"method_ai_ocr": "Gemini OCR", "method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision", "method_ai_vision": "Gemini Vision",
"method_local_ocr": "OCR números",
"method_zbar": "ZBar",
"local_ocr_found": "Código desde los números: {code}",
"ai_fallback_searching": "Identificación de IA en curso...", "ai_fallback_searching": "Identificación de IA en curso...",
"ai_fallback_found": "Producto identificado por IA", "ai_fallback_found": "Producto identificado por IA",
"ai_fallback_not_found": "IA: producto no reconocido", "ai_fallback_not_found": "IA: producto no reconocido",
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código", "ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision está analizando el producto...", "ai_overlay_msg": "Gemini Vision está analizando el producto...",
"ai_retry_btn": "Reintentar con IA", "ai_retry_btn": "Reintentar con IA",
"ai_manual_btn": "🤖 Identificar con IA",
"ai_not_recognized": "IA: producto no reconocido. Reintenta o añádelo manualmente.",
"ai_match_title": "Producto reconocido por IA", "ai_match_title": "Producto reconocido por IA",
"ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.", "ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.",
"ai_match_existing": "Actualmente en despensa", "ai_match_existing": "Posibles coincidencias en despensa",
"ai_match_finished": "Agotados / terminados", "ai_match_none": "No se encontraron productos similares en despensa.",
"ai_match_catalog": "En catálogo (sin stock)", "ai_match_use_btn": "Usar este",
"ai_match_finished_badge": "agotado", "ai_match_add_btn": "Agregar \"{name}\"",
"ai_match_finished_hint": "Producto agotado — repón la cantidad", "ai_detected_label": "IA detecto"
"ai_match_merged_existing": "Vinculado a un producto existente del catálogo",
"ai_match_none": "Sin productos similares — puedes crear uno nuevo.",
"ai_match_use_btn": "Usar",
"ai_match_create_btn": " Crear nuevo: {name}",
"ai_match_add_btn": "Agregar {name}",
"ai_match_action_hint": "Toca el boton verde para agregar este producto",
"ai_match_or_similar": "O elige un producto similar:",
"ai_detected_label": "IA detecto",
"stock_in_pantry": "Ya en despensa:",
"mode_shopping_activated": "🛒 ¡Modo compras activado!"
}, },
"action": { "action": {
"title": "¿Qué quieres hacer?", "title": "¿Qué quieres hacer?",
@@ -281,8 +254,7 @@
"throw_btn": "🗑️ DESECHAR", "throw_btn": "🗑️ DESECHAR",
"throw_sub": "tirar", "throw_sub": "tirar",
"edit_sub": "caducidad, ubicación…", "edit_sub": "caducidad, ubicación…",
"create_recipe_btn": "Receta", "create_recipe_btn": "Receta"
"related_stock_title": "También en casa"
}, },
"add": { "add": {
"title": "Añadir a la despensa", "title": "Añadir a la despensa",
@@ -306,9 +278,8 @@
"hint_modify": "📝 Puedes cambiar la fecha o escanearla con la cámara", "hint_modify": "📝 Puedes cambiar la fecha o escanearla con la cámara",
"scan_expiry_title": "📷 Escanear fecha de caducidad", "scan_expiry_title": "📷 Escanear fecha de caducidad",
"product_added": "✅ ¡{name} añadido!{qty}", "product_added": "✅ ¡{name} añadido!{qty}",
"duplicate_recent_confirm": "Acabas de añadir «{name}» ({when}).\n\nLa cantidad ya es {total}.\n\n¿Aumentarla en {qty}?",
"suffix_freezer_vacuum": "(congelador + al vacío)", "suffix_freezer_vacuum": "(congelador + al vacío)",
"history_badge_tip": "Media de los últimos {n} registros — se actualiza con cada compra", "history_badge_tip": "Media de {n} entradas anteriores",
"vacuum_question": "¿Al vacío?", "vacuum_question": "¿Al vacío?",
"vacuum_saved": "🔒 ¡Al vacío!" "vacuum_saved": "🔒 ¡Al vacío!"
}, },
@@ -341,29 +312,14 @@
"toast_bring": "🛒 Producto terminado → añadido a Bring!", "toast_bring": "🛒 Producto terminado → añadido a Bring!",
"toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!", "toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!",
"disambiguation_hint": "¿Qué quieres decir con «todo terminado»?", "disambiguation_hint": "¿Qué quieres decir con «todo terminado»?",
"disambiguation_one_conf": "Terminado <strong>1 envase</strong> ({qty})",
"disambiguation_all": "🗑️ Terminar TODO ({qty})", "disambiguation_all": "🗑️ Terminar TODO ({qty})",
"toast_one_conf_finished": "📦 1 envase de {name} terminado!",
"error_exceeds_stock": "⚠️ ¡No puedes usar más de lo que tienes disponible!", "error_exceeds_stock": "⚠️ ¡No puedes usar más de lo que tienes disponible!",
"use_all_confirm_title": "✅ Terminar todo", "use_all_confirm_title": "✅ Terminar todo",
"use_all_confirm_msg": "Confirma que has terminado el producto:", "use_all_confirm_msg": "Confirma que has terminado el producto:",
"use_all_confirm_btn": "✅ Sí, terminado", "use_all_confirm_btn": "✅ Sí, terminado",
"throw_all_confirm_title": "🗑️ Desechar todo", "throw_all_confirm_title": "🗑️ Desechar todo",
"throw_all_confirm_msg": "¿Realmente quieres tirar todo el producto?", "throw_all_confirm_msg": "¿Realmente quieres tirar todo el producto?",
"throw_all_confirm_btn": "🗑️ Sí, desechar", "throw_all_confirm_btn": "🗑️ Sí, desechar"
"locations_short": "ubicaciones"
},
"waste": {
"reason_title": "¿Por qué lo tiras?",
"reason_subtitle": "Nos ayuda a evitar desperdicios similares.",
"reason_expired": "⏰ Caducado",
"reason_spoiled": "🦠 Estropeado",
"reason_wrong_location": "📍 Lugar de guardado incorrecto",
"reason_kept_too_long": "⏳ Guardado demasiado tiempo",
"reason_bought_too_much": "🛒 Comprado de más",
"reason_forgotten": "😴 Olvidado / no usado a tiempo",
"reason_bad_quality": "👎 Mala calidad al comprar",
"reason_other": "❓ Otro"
}, },
"product": { "product": {
"title_new": "Nuevo producto", "title_new": "Nuevo producto",
@@ -373,8 +329,6 @@
"name_label": "🏷️ Nombre del producto *", "name_label": "🏷️ Nombre del producto *",
"name_placeholder": "Ej.: Leche entera, Pasta penne...", "name_placeholder": "Ej.: Leche entera, Pasta penne...",
"brand_label": "🏢 Marca", "brand_label": "🏢 Marca",
"allergens_label": "Alérgenos:",
"ingredients_summary": "📋 Ingredientes",
"brand_placeholder": "Ej.: Barilla, Danone, Heinz...", "brand_placeholder": "Ej.: Barilla, Danone, Heinz...",
"category_label": "📂 Categoría", "category_label": "📂 Categoría",
"unit_label": "📏 Unidad de medida", "unit_label": "📏 Unidad de medida",
@@ -405,9 +359,7 @@
"weight_label": "Peso", "weight_label": "Peso",
"origin_label": "Origen", "origin_label": "Origen",
"labels_label": "Etiquetas", "labels_label": "Etiquetas",
"select_variant": "Selecciona la variante exacta o usa los datos de IA:", "select_variant": "Selecciona la variante exacta o usa los datos de IA:"
"history_badge": "📊 historial",
"from_history": " (media últimos 3)"
}, },
"products": { "products": {
"title": "📦 Todos los productos", "title": "📦 Todos los productos",
@@ -459,19 +411,7 @@
"load_error": "Error de carga", "load_error": "Error de carga",
"favorite": "Añadir a favoritos", "favorite": "Añadir a favoritos",
"unfavorite": "Quitar de favoritos", "unfavorite": "Quitar de favoritos",
"adjust_persons": "Personas", "adjust_persons": "Personas"
"nutrition_title": "Valores nutricionales (por ración)",
"nutrition_kcal": "Calorías",
"nutrition_protein": "Proteínas",
"nutrition_carbs": "Carbohidratos",
"nutrition_fat": "Grasas",
"nutrition_per_serving": "Valores estimados por ración",
"storage_title": "Cómo conservar las sobras",
"storage_days": "{n} días",
"storage_immediately": "Mejor consumir de inmediato",
"stream_interrupted": "Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.",
"ing_stock_line": "Tienes {have} · quedan {remain} después del uso",
"ing_use_all_note": "usar todo (<5% del envase completo)"
}, },
"shopping": { "shopping": {
"title": "🛒 Lista de la compra", "title": "🛒 Lista de la compra",
@@ -531,18 +471,6 @@
"item_removed": "✅ ¡{name} eliminado de la lista!", "item_removed": "✅ ¡{name} eliminado de la lista!",
"urgency_spec_critical": "⚡ Urgente", "urgency_spec_critical": "⚡ Urgente",
"urgency_spec_high": "🟠 Pronto", "urgency_spec_high": "🟠 Pronto",
"urgency_spec_medium": "🟡 Pronto",
"urgency_spec_low": "🔵 Previsión",
"family_sibling_title": "Similar en {location}",
"family_sibling_check": "Comprueba: {name}",
"family_sibling_stock": "Deberías tener: {qty}",
"family_sibling_location": "Ubicación: {location}",
"family_sibling_qty": "Cantidad: {qty}",
"family_sibling_purchased": "Comprado el {date}",
"family_sibling_question": "¿La cantidad sigue siendo correcta?",
"family_sibling_prompt": "También tienes {name}: {qty} en stock. ¿Confirmas la cantidad?",
"family_sibling_yes": "Sí, correcto",
"family_sibling_no": "No, actualizar",
"bring_add_n": "Añadir {n} a Bring!", "bring_add_n": "Añadir {n} a Bring!",
"bring_add_selected": "Añadir selección a Bring!", "bring_add_selected": "Añadir selección a Bring!",
"bring_adding": "Añadiendo...", "bring_adding": "Añadiendo...",
@@ -570,7 +498,6 @@
"remove_error": "Error al eliminar", "remove_error": "Error al eliminar",
"btn_fetch_prices": "Buscar precios", "btn_fetch_prices": "Buscar precios",
"price_total_label": "💰 Total estimado:", "price_total_label": "💰 Total estimado:",
"price_total_short": "total estimado",
"price_loading": "Buscando precios…", "price_loading": "Buscando precios…",
"price_not_found": "precio n/d", "price_not_found": "precio n/d",
"suggest_loading": "Analizando...", "suggest_loading": "Analizando...",
@@ -579,9 +506,7 @@
"priority_medium": "Media", "priority_medium": "Media",
"priority_low": "Baja", "priority_low": "Baja",
"smart_last_update": "Actualizado {time}", "smart_last_update": "Actualizado {time}",
"names_already_updated": "Todos los nombres ya están actualizados", "names_already_updated": "Todos los nombres ya están actualizados"
"pantry_hint": "Ya en casa: {qty}",
"bring_names_migrated": "🔄 {n} nombres generalizados en Bring!"
}, },
"ai": { "ai": {
"title": "🤖 Identificación IA", "title": "🤖 Identificación IA",
@@ -592,8 +517,7 @@
"no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>", "no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>",
"fields_filled": "✅ Campos rellenados por IA", "fields_filled": "✅ Campos rellenados por IA",
"use_data": "✅ Usar datos de IA", "use_data": "✅ Usar datos de IA",
"use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)", "use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)"
"conservation_hint": "🤖 IA: conserva en {location}"
}, },
"log": { "log": {
"title": "📒 Registro de operaciones", "title": "📒 Registro de operaciones",
@@ -759,8 +683,7 @@
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.", "devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
"detect_btn": "🔄 Detectar cámaras", "detect_btn": "🔄 Detectar cámaras",
"ai_fallback_label": "Identificación visual IA (repuesto 5s)", "ai_fallback_label": "Identificación visual IA (repuesto 5s)",
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado.", "ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado."
"ai_manual_hint": "Si el código no se lee, usa el botón «Identificar con IA» debajo de la cámara. Requiere Gemini configurado."
}, },
"security": { "security": {
"title": "🔒 Certificado HTTPS", "title": "🔒 Certificado HTTPS",
@@ -810,8 +733,7 @@
"heard_yes": "Sí, la escuché", "heard_yes": "Sí, la escuché",
"heard_no": "No, no escuché nada", "heard_no": "No, no escuché nada",
"test_ok_kiosk": "TTS funcionando.", "test_ok_kiosk": "TTS funcionando.",
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android.", "test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android."
"test_sound_btn": "🔔 Prueba de sonido"
}, },
"language": { "language": {
"title": "🌐 Idioma", "title": "🌐 Idioma",
@@ -849,13 +771,7 @@
"kiosk_title": "📡 Báscula BLE integrada en el kiosco", "kiosk_title": "📡 Báscula BLE integrada en el kiosco",
"kiosk_hint": "La báscula está gestionada directamente por la pasarela BLE interna del kiosco. Para vincular un nuevo dispositivo, usa el asistente de configuración.", "kiosk_hint": "La báscula está gestionada directamente por la pasarela BLE interna del kiosco. Para vincular un nuevo dispositivo, usa el asistente de configuración.",
"kiosk_reconfigure": "🔄 Reconfigurar báscula BLE", "kiosk_reconfigure": "🔄 Reconfigurar báscula BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>", "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>"
"discover_scanning": "🔍 Buscando pasarela de báscula en la red local…",
"discover_found": "✅ Pasarela encontrada: {url}{more}",
"discover_not_found": "❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.",
"discover_failed": "❌ Búsqueda fallida: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Dispositivo desconocido"
}, },
"kiosk": { "kiosk": {
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.", "hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
@@ -1001,49 +917,7 @@
"sensor_copied": "¡YAML copiado al portapapeles!", "sensor_copied": "¡YAML copiado al portapapeles!",
"save_btn": "Guardar ajustes HA", "save_btn": "Guardar ajustes HA",
"ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores." "ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores."
}, }
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Uso de tokens",
"ai_hint": "Consumo mensual y coste estimado para la clave API actual.",
"loading": "Cargando…",
"total_tokens": "Tokens totales",
"est_cost": "Coste est.",
"input_tok": "Tokens de entrada",
"output_tok": "Tokens de salida",
"ai_calls": "Llamadas",
"by_action": "Desglose por función",
"by_model": "Desglose por modelo",
"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.",
"system_title": "Sistema",
"db_size": "Base de datos",
"log_size": "Logs",
"log_level": "Nivel de log",
"ai_overview": "Resumen de IA, inventario y estado del sistema",
"calls_unit": "llamadas",
"inv_title": "Inventario",
"inv_active": "Activos",
"inv_products": "Productos totales",
"inv_expiring": "Caducan (7d)",
"inv_expired": "Caducados",
"inv_finished": "Agotados",
"act_title": "Actividad mensual",
"act_tx_month": "Movimientos",
"act_restock": "Reabastecimientos",
"act_use": "Usos",
"act_new_products": "Productos nuevos",
"act_tx_year": "Movimientos anuales",
"price_cache": "Caché de precios",
"cache_entries": "productos",
"last_backup": "Última copia",
"bring_days": "token expira en {n} días",
"bring_expired": "token expirado",
"year_label": "Año {year}",
"currency_title": "Moneda",
"currency_hint": "Moneda usada para todos los costes y precios en la app."
},
"tab_general": "General",
"kiosk_update_required": "⚠️ Actualiza la app kiosk para usar esta función"
}, },
"expiry": { "expiry": {
"today": "HOY", "today": "HOY",
@@ -1116,10 +990,8 @@
"thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)", "thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)",
"finished_all": "📤 ¡{name} terminado!", "finished_all": "📤 ¡{name} terminado!",
"product_finished_confirmed": "✅ Eliminado — añádelo de nuevo cuando reabastezcas", "product_finished_confirmed": "✅ Eliminado — añádelo de nuevo cuando reabastezcas",
"ghost_restored": "✅ {name}: restaurados {qty} {unit} en el inventario",
"appliance_added": "Electrodoméstico añadido", "appliance_added": "Electrodoméstico añadido",
"item_added": "{name} añadido", "item_added": "{name} añadido"
"vacuum_sealed": "{name} guardado al vacío"
}, },
"antiwaste": { "antiwaste": {
"title": "🌱 Informe anti-desperdicio", "title": "🌱 Informe anti-desperdicio",
@@ -1189,9 +1061,7 @@
"offline_ops_pending": "{n} operaciones pendientes", "offline_ops_pending": "{n} operaciones pendientes",
"offline_synced": "{n} operaciones sincronizadas", "offline_synced": "{n} operaciones sincronizadas",
"offline_ai_disabled": "No disponible sin conexión", "offline_ai_disabled": "No disponible sin conexión",
"offline_cache_ready": "Offline — {n} productos en caché", "offline_cache_ready": "Offline — {n} productos en caché"
"copy_failed": "Error al copiar al portapapeles",
"invalid_quantity": "Cantidad no válida"
}, },
"confirm_placeholder_search": null, "confirm_placeholder_search": null,
"confirm": { "confirm": {
@@ -1292,10 +1162,7 @@
"retake_btn": "🔄 Repetir", "retake_btn": "🔄 Repetir",
"camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.<br>Puedes introducir el código de barras manualmente o usar la identificación IA.", "camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.<br>Puedes introducir el código de barras manualmente o usar la identificación IA.",
"no_barcode": "Sin código de barras", "no_barcode": "Sin código de barras",
"save_new_btn": "🆕 Ninguno de estos — guardar como nuevo", "save_new_btn": "🆕 Ninguno de estos — guardar como nuevo"
"expiry_found": "Fecha encontrada",
"expiry_read_fail": "No se puede leer la fecha.",
"expiry_raw_label": "Leído"
}, },
"lowstock": { "lowstock": {
"title": "⚠️ ¡Stock bajo!", "title": "⚠️ ¡Stock bajo!",
@@ -1313,8 +1180,7 @@
"stay_btn": "No, quedarse en {location}", "stay_btn": "No, quedarse en {location}",
"moved_toast": "📦 Paquete abierto movido a {location}", "moved_toast": "📦 Paquete abierto movido a {location}",
"vacuum_restore": "🫙 Restaurar al vacío", "vacuum_restore": "🫙 Restaurar al vacío",
"vacuum_seal_rest": "🔒 Sellar el resto al vacío", "vacuum_seal_rest": "🔒 Sellar el resto al vacío"
"moved_simple": "📦 Movido a {location}"
}, },
"nova": { "nova": {
"1": "Sin procesar", "1": "Sin procesar",
@@ -1544,17 +1410,7 @@
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.", "error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
"retry": "Reintentar", "retry": "Reintentar",
"syncing_local": "Sincronizando datos locales...", "syncing_local": "Sincronizando datos locales...",
"sync_done": "Datos locales sincronizados", "sync_done": "Datos locales sincronizados"
"token_required": "Token API requerido",
"token_autoconfig": "Configurando acceso...",
"token_prompt_title": "🔒 Token API",
"token_prompt_hint": "Introduce el valor API_TOKEN del archivo .env del servidor.",
"token_prompt_btn": "Continuar",
"check_db_legacy": "BD antigua (dispensa.db)",
"check_tts": "URL texto a voz",
"check_scale": "Pasarela báscula",
"critical_error_intro": "La app no puede iniciarse por los siguientes problemas:",
"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."
}, },
"stats_monthly": { "stats_monthly": {
"title": "Estadísticas Mensuales", "title": "Estadísticas Mensuales",
@@ -1567,12 +1423,5 @@
"top_used": "más usado", "top_used": "más usado",
"top_cats": "Categorías principales", "top_cats": "Categorías principales",
"source": "Historial de transacciones · mes actual" "source": "Historial de transacciones · mes actual"
},
"time": {
"just_now": "ahora",
"seconds_ago": "hace {n}s",
"minutes_ago": "hace {n} min",
"hours_ago": "hace {n} h",
"days_ago": "hace {n} d"
} }
} }
+22 -179
View File
@@ -32,7 +32,6 @@
"reset_default": "↺ Rétablir les valeurs par défaut", "reset_default": "↺ Rétablir les valeurs par défaut",
"save_info": "💾 Enregistrer les informations", "save_info": "💾 Enregistrer les informations",
"retry": "🔄 Réessayer", "retry": "🔄 Réessayer",
"next": "Suivant →",
"yes_short": "Oui", "yes_short": "Oui",
"no_short": "Non" "no_short": "Non"
}, },
@@ -142,10 +141,8 @@
"banner_prediction_more": "estimation précédente : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}.", "banner_prediction_more": "estimation précédente : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}.",
"banner_prediction_less": "estimation : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}. Si votre rythme d'utilisation a changé, la prévision se met à jour automatiquement.", "banner_prediction_less": "estimation : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}. Si votre rythme d'utilisation a changé, la prévision se met à jour automatiquement.",
"banner_finished_zero": "L'inventaire indique zéro, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.", "banner_finished_zero": "L'inventaire indique zéro, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.",
"banner_finished_vanished": "Ce produit n'apparaît plus dans l'inventaire, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.",
"banner_finished_expected": "D'après les enregistrements vous devriez avoir encore {qty} {unit}.", "banner_finished_expected": "D'après les enregistrements vous devriez avoir encore {qty} {unit}.",
"banner_finished_check": "Pouvez-vous vérifier ?", "banner_finished_check": "Pouvez-vous vérifier ?",
"banner_finished_action_restore": "Restaurer {qty} {unit}",
"banner_anomaly_phantom_title": "vous avez plus de stock que prévu", "banner_anomaly_phantom_title": "vous avez plus de stock que prévu",
"banner_anomaly_phantom_detail": "L'inventaire indique {inv_qty} {unit}, mais selon les enregistrements vous ne devriez avoir que {expected_qty} {unit}. Avez-vous ajouté du stock sans l'enregistrer ?", "banner_anomaly_phantom_detail": "L'inventaire indique {inv_qty} {unit}, mais selon les enregistrements vous ne devriez avoir que {expected_qty} {unit}. Avez-vous ajouté du stock sans l'enregistrer ?",
"banner_anomaly_untracked_title": "stock non enregistré comme entrée", "banner_anomaly_untracked_title": "stock non enregistré comme entrée",
@@ -165,13 +162,7 @@
"banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.", "banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.",
"banner_explain_title": "Demander une explication à Gemini", "banner_explain_title": "Demander une explication à Gemini",
"banner_explain_btn": "Expliquer", "banner_explain_btn": "Expliquer",
"banner_analyzing": "🤖 Analyse en cours…", "banner_analyzing": "🤖 Analyse en cours…"
"banner_expired_action_modify": "Modifier",
"banner_expired_action_vacuum": "Mettre sous vide",
"banner_prediction_confirmed": "✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements",
"banner_anomaly_explain_fail": "Impossible d'obtenir l'explication IA",
"banner_anomaly_dismissed": "Anomalie ignorée",
"banner_finished_restore_prompt": "Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})"
}, },
"inventory": { "inventory": {
"title": "Garde-manger", "title": "Garde-manger",
@@ -200,7 +191,6 @@
"mode_shopping": "🛒 Mode courses", "mode_shopping": "🛒 Mode courses",
"mode_shopping_end": "✅ Terminer les courses", "mode_shopping_end": "✅ Terminer les courses",
"spesa_btn": "🛒 Courses", "spesa_btn": "🛒 Courses",
"spesa_camera_hint": "Visez le code-barres avec la caméra. Pas de code ? Appuyez sur « Identifier avec l'IA » ci-dessous.",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Code-barres", "tab_barcode": "Code-barres",
"tab_name": "Nom", "tab_name": "Nom",
@@ -235,39 +225,22 @@
"status_confirmed": "Confirmé !", "status_confirmed": "Confirmé !",
"status_parallel": "Scan combiné actif...", "status_parallel": "Scan combiné actif...",
"status_ocr_searching": "Je lis les chiffres du code-barres...", "status_ocr_searching": "Je lis les chiffres du code-barres...",
"status_digit_ocr": "Lecture des chiffres sous le code-barres...",
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...", "status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
"method_ai_ocr": "Gemini OCR", "method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision", "method_ai_vision": "Gemini Vision",
"method_local_ocr": "OCR chiffres",
"method_zbar": "ZBar",
"local_ocr_found": "Code depuis les chiffres : {code}",
"ai_fallback_searching": "Identification IA en cours...", "ai_fallback_searching": "Identification IA en cours...",
"ai_fallback_found": "Produit identifié par l'IA", "ai_fallback_found": "Produit identifié par l'IA",
"ai_fallback_not_found": "IA : produit non reconnu", "ai_fallback_not_found": "IA : produit non reconnu",
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres", "ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision analyse le produit...", "ai_overlay_msg": "Gemini Vision analyse le produit...",
"ai_retry_btn": "Reessayer avec l'IA", "ai_retry_btn": "Reessayer avec l'IA",
"ai_manual_btn": "🤖 Identifier avec l'IA",
"ai_not_recognized": "IA : produit non reconnu. Réessayez ou saisissez-le manuellement.",
"ai_match_title": "Produit reconnu par l'IA", "ai_match_title": "Produit reconnu par l'IA",
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.", "ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
"ai_match_existing": "Actuellement en stock", "ai_match_existing": "Correspondances possibles dans le stock",
"ai_match_finished": "Terminés / épuisés", "ai_match_none": "Aucun produit similaire trouve dans le stock.",
"ai_match_catalog": "Au catalogue (sans stock)", "ai_match_use_btn": "Utiliser celui-ci",
"ai_match_finished_badge": "épuisé", "ai_match_add_btn": "Ajouter \"{name}\"",
"ai_match_finished_hint": "Produit terminé — réapprovisionner la quantité", "ai_detected_label": "IA a detecte"
"ai_match_merged_existing": "Lié à un produit déjà au catalogue",
"ai_match_none": "Aucun produit similaire — vous pouvez en créer un nouveau.",
"ai_match_use_btn": "Utiliser",
"ai_match_create_btn": " Créer nouveau : {name}",
"ai_match_add_btn": "Ajouter {name}",
"ai_match_action_hint": "Appuyez sur le bouton vert pour ajouter ce produit",
"ai_match_or_similar": "Ou choisissez un produit similaire :",
"ai_detected_label": "IA a detecte",
"stock_in_pantry": "Déjà à la maison :",
"mode_shopping_activated": "🛒 Mode courses activé !"
}, },
"action": { "action": {
"title": "Que voulez-vous faire ?", "title": "Que voulez-vous faire ?",
@@ -281,8 +254,7 @@
"throw_btn": "🗑️ JETER", "throw_btn": "🗑️ JETER",
"throw_sub": "jeter", "throw_sub": "jeter",
"edit_sub": "péremption, emplacement…", "edit_sub": "péremption, emplacement…",
"create_recipe_btn": "Recette", "create_recipe_btn": "Recette"
"related_stock_title": "Aussi à la maison"
}, },
"add": { "add": {
"title": "Ajouter au garde-manger", "title": "Ajouter au garde-manger",
@@ -306,9 +278,8 @@
"hint_modify": "📝 Vous pouvez modifier la date ou la scanner avec la caméra", "hint_modify": "📝 Vous pouvez modifier la date ou la scanner avec la caméra",
"scan_expiry_title": "📷 Scanner la date de péremption", "scan_expiry_title": "📷 Scanner la date de péremption",
"product_added": "✅ {name} ajouté !{qty}", "product_added": "✅ {name} ajouté !{qty}",
"duplicate_recent_confirm": "Vous venez d'ajouter « {name} » ({when}).\n\nLa quantité est déjà {total}.\n\nL'augmenter de {qty} ?",
"suffix_freezer_vacuum": "(congélateur + sous vide)", "suffix_freezer_vacuum": "(congélateur + sous vide)",
"history_badge_tip": "Moyenne des {n} derniers ajouts — mise à jour à chaque achat", "history_badge_tip": "Moyenne de {n} entrées précédentes",
"vacuum_question": "Sous vide ?", "vacuum_question": "Sous vide ?",
"vacuum_saved": "🔒 Sous vide !" "vacuum_saved": "🔒 Sous vide !"
}, },
@@ -341,29 +312,14 @@
"toast_bring": "🛒 Produit terminé → ajouté à Bring !", "toast_bring": "🛒 Produit terminé → ajouté à Bring !",
"toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !", "toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !",
"disambiguation_hint": "Que voulez-vous dire par « tout fini » ?", "disambiguation_hint": "Que voulez-vous dire par « tout fini » ?",
"disambiguation_one_conf": "Terminer <strong>1 emballage</strong> ({qty})",
"disambiguation_all": "🗑️ Tout finir ({qty})", "disambiguation_all": "🗑️ Tout finir ({qty})",
"toast_one_conf_finished": "📦 1 emballage de {name} terminé !",
"error_exceeds_stock": "⚠️ Vous ne pouvez pas utiliser plus que ce que vous avez disponible !", "error_exceeds_stock": "⚠️ Vous ne pouvez pas utiliser plus que ce que vous avez disponible !",
"use_all_confirm_title": "✅ Tout terminer", "use_all_confirm_title": "✅ Tout terminer",
"use_all_confirm_msg": "Confirmez que vous avez terminé le produit :", "use_all_confirm_msg": "Confirmez que vous avez terminé le produit :",
"use_all_confirm_btn": "✅ Oui, terminé", "use_all_confirm_btn": "✅ Oui, terminé",
"throw_all_confirm_title": "🗑️ Tout jeter", "throw_all_confirm_title": "🗑️ Tout jeter",
"throw_all_confirm_msg": "Voulez-vous vraiment jeter tout le produit ?", "throw_all_confirm_msg": "Voulez-vous vraiment jeter tout le produit ?",
"throw_all_confirm_btn": "🗑️ Oui, jeter", "throw_all_confirm_btn": "🗑️ Oui, jeter"
"locations_short": "emplacements"
},
"waste": {
"reason_title": "Pourquoi le jetez-vous ?",
"reason_subtitle": "Cela nous aide à éviter des gaspillages similaires.",
"reason_expired": "⏰ Périmé",
"reason_spoiled": "🦠 Abîmé / gâté",
"reason_wrong_location": "📍 Mauvais emplacement",
"reason_kept_too_long": "⏳ Conservé trop longtemps",
"reason_bought_too_much": "🛒 Acheté en trop grande quantité",
"reason_forgotten": "😴 Oublié / pas utilisé à temps",
"reason_bad_quality": "👎 Mauvaise qualité à l'achat",
"reason_other": "❓ Autre"
}, },
"product": { "product": {
"title_new": "Nouveau produit", "title_new": "Nouveau produit",
@@ -373,8 +329,6 @@
"name_label": "🏷️ Nom du produit *", "name_label": "🏷️ Nom du produit *",
"name_placeholder": "Ex. : Lait entier, Pâtes penne...", "name_placeholder": "Ex. : Lait entier, Pâtes penne...",
"brand_label": "🏢 Marque", "brand_label": "🏢 Marque",
"allergens_label": "Allergènes :",
"ingredients_summary": "📋 Ingrédients",
"brand_placeholder": "Ex. : Barilla, Danone, Heinz...", "brand_placeholder": "Ex. : Barilla, Danone, Heinz...",
"category_label": "📂 Catégorie", "category_label": "📂 Catégorie",
"unit_label": "📏 Unité de mesure", "unit_label": "📏 Unité de mesure",
@@ -405,9 +359,7 @@
"weight_label": "Poids", "weight_label": "Poids",
"origin_label": "Origine", "origin_label": "Origine",
"labels_label": "Labels", "labels_label": "Labels",
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :", "select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :"
"history_badge": "📊 historique",
"from_history": " (moy. 3 derniers)"
}, },
"products": { "products": {
"title": "📦 Tous les produits", "title": "📦 Tous les produits",
@@ -459,25 +411,7 @@
"load_error": "Erreur de chargement", "load_error": "Erreur de chargement",
"favorite": "Ajouter aux favoris", "favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"adjust_persons": "Personnes", "adjust_persons": "Personnes"
"nutrition_title": "Valeurs nutritionnelles (par portion)",
"nutrition_kcal": "Calories",
"nutrition_protein": "Protéines",
"nutrition_carbs": "Glucides",
"nutrition_fat": "Lipides",
"nutrition_per_serving": "Valeurs estimées par portion",
"storage_title": "Comment conserver les restes",
"storage_days": "{n} jours",
"storage_immediately": "À consommer immédiatement",
"stream_interrupted": "Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.",
"ing_stock_line": "Vous avez {have} · il reste {remain} après usage",
"ing_use_all_note": "tout utiliser (<5% du conditionnement entier)",
"shopping_suggestions_intro": "Pour une variante, il faudrait (pas dans le garde-manger — optionnel) :",
"shopping_suggestions_add": "Ajouter à la liste de courses",
"shopping_suggestions_added": "Ajouté à la liste de courses",
"frozen_badge": "surgelé — du congélateur",
"unit_for_input": "Unité de mesure",
"enter_in": "Saisie en"
}, },
"shopping": { "shopping": {
"title": "🛒 Liste de courses", "title": "🛒 Liste de courses",
@@ -537,18 +471,6 @@
"item_removed": "✅ {name} retiré de la liste !", "item_removed": "✅ {name} retiré de la liste !",
"urgency_spec_critical": "⚡ Urgent", "urgency_spec_critical": "⚡ Urgent",
"urgency_spec_high": "🟠 Bientôt", "urgency_spec_high": "🟠 Bientôt",
"urgency_spec_medium": "🟡 Bientôt",
"urgency_spec_low": "🔵 Prévision",
"family_sibling_title": "Similaire dans {location}",
"family_sibling_check": "Vérifier : {name}",
"family_sibling_stock": "Vous devriez avoir : {qty}",
"family_sibling_location": "Emplacement : {location}",
"family_sibling_qty": "Quantité : {qty}",
"family_sibling_purchased": "Acheté le {date}",
"family_sibling_question": "La quantité est-elle encore correcte ?",
"family_sibling_prompt": "Tu as aussi {name} : {qty} en stock. Confirmer la quantité ?",
"family_sibling_yes": "Oui, c'est bon",
"family_sibling_no": "Non, mettre à jour",
"bring_add_n": "Ajouter {n} à Bring !", "bring_add_n": "Ajouter {n} à Bring !",
"bring_add_selected": "Ajouter la sélection à Bring !", "bring_add_selected": "Ajouter la sélection à Bring !",
"bring_adding": "Ajout en cours...", "bring_adding": "Ajout en cours...",
@@ -576,7 +498,6 @@
"remove_error": "Erreur de suppression", "remove_error": "Erreur de suppression",
"btn_fetch_prices": "Trouver les prix", "btn_fetch_prices": "Trouver les prix",
"price_total_label": "💰 Total estimé :", "price_total_label": "💰 Total estimé :",
"price_total_short": "total estimé",
"price_loading": "Recherche des prix…", "price_loading": "Recherche des prix…",
"price_not_found": "prix n/d", "price_not_found": "prix n/d",
"suggest_loading": "Analyse en cours...", "suggest_loading": "Analyse en cours...",
@@ -585,9 +506,7 @@
"priority_medium": "Moyenne", "priority_medium": "Moyenne",
"priority_low": "Faible", "priority_low": "Faible",
"smart_last_update": "Mis à jour {time}", "smart_last_update": "Mis à jour {time}",
"names_already_updated": "Tous les noms sont déjà à jour", "names_already_updated": "Tous les noms sont déjà à jour"
"pantry_hint": "Déjà à la maison : {qty}",
"bring_names_migrated": "🔄 {n} noms généralisés dans Bring !"
}, },
"ai": { "ai": {
"title": "🤖 Identification IA", "title": "🤖 Identification IA",
@@ -598,8 +517,7 @@
"no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>", "no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>",
"fields_filled": "✅ Champs remplis par l'IA", "fields_filled": "✅ Champs remplis par l'IA",
"use_data": "✅ Utiliser les données IA", "use_data": "✅ Utiliser les données IA",
"use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)", "use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)"
"conservation_hint": "🤖 IA : conserve dans {location}"
}, },
"log": { "log": {
"title": "📒 Journal des opérations", "title": "📒 Journal des opérations",
@@ -765,8 +683,7 @@
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.", "devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
"detect_btn": "🔄 Détecter les caméras", "detect_btn": "🔄 Détecter les caméras",
"ai_fallback_label": "Identification visuelle IA (repli 5s)", "ai_fallback_label": "Identification visuelle IA (repli 5s)",
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré.", "ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré."
"ai_manual_hint": "Si le code-barres ne se lit pas, utilisez le bouton « Identifier avec l'IA » sous la caméra. Nécessite Gemini configuré."
}, },
"security": { "security": {
"title": "🔒 Certificat HTTPS", "title": "🔒 Certificat HTTPS",
@@ -816,8 +733,7 @@
"heard_yes": "Oui, je l'ai entendu", "heard_yes": "Oui, je l'ai entendu",
"heard_no": "Non, je n'ai rien entendu", "heard_no": "Non, je n'ai rien entendu",
"test_ok_kiosk": "TTS fonctionne.", "test_ok_kiosk": "TTS fonctionne.",
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android.", "test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android."
"test_sound_btn": "🔔 Test sonore"
}, },
"language": { "language": {
"title": "🌐 Langue", "title": "🌐 Langue",
@@ -855,13 +771,7 @@
"kiosk_title": "📡 Balance BLE intégrée dans le kiosque", "kiosk_title": "📡 Balance BLE intégrée dans le kiosque",
"kiosk_hint": "La balance est directement gérée par la passerelle BLE interne du kiosque. Pour associer un nouvel appareil, utilisez l'assistant de configuration.", "kiosk_hint": "La balance est directement gérée par la passerelle BLE interne du kiosque. Pour associer un nouvel appareil, utilisez l'assistant de configuration.",
"kiosk_reconfigure": "🔄 Reconfigurer la balance BLE", "kiosk_reconfigure": "🔄 Reconfigurer la balance BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>", "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>"
"discover_scanning": "🔍 Recherche du gateway balance sur le réseau local…",
"discover_found": "✅ Gateway trouvé : {url}{more}",
"discover_not_found": "❌ Aucun gateway sur {subnet}. Lancez l'app Android sur le même Wi-Fi.",
"discover_failed": "❌ Échec de la recherche : {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Appareil inconnu"
}, },
"kiosk": { "kiosk": {
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.", "hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.",
@@ -1007,49 +917,7 @@
"sensor_copied": "YAML copié dans le presse-papiers !", "sensor_copied": "YAML copié dans le presse-papiers !",
"save_btn": "Enregistrer les paramètres HA", "save_btn": "Enregistrer les paramètres HA",
"ha_hint": "Si vous utilisez Home Assistant, utilisez l'onglet Home Assistant pour configurer TTS, webhooks et capteurs." "ha_hint": "Si vous utilisez Home Assistant, utilisez l'onglet Home Assistant pour configurer TTS, webhooks et capteurs."
}, }
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Utilisation des tokens",
"ai_hint": "Consommation mensuelle et coût estimé pour la clé API actuelle.",
"loading": "Chargement…",
"total_tokens": "Tokens totaux",
"est_cost": "Coût est.",
"input_tok": "Tokens entrée",
"output_tok": "Tokens sortie",
"ai_calls": "Appels",
"by_action": "Répartition par fonction",
"by_model": "Répartition par modèle",
"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.",
"system_title": "Système",
"db_size": "Base de données",
"log_size": "Logs",
"log_level": "Niveau de log",
"ai_overview": "Aperçu IA, inventaire et état du système",
"calls_unit": "appels",
"inv_title": "Inventaire",
"inv_active": "Actifs",
"inv_products": "Produits totaux",
"inv_expiring": "Expirent (7j)",
"inv_expired": "Expirés",
"inv_finished": "Terminés",
"act_title": "Activité mensuelle",
"act_tx_month": "Mouvements",
"act_restock": "Réapprovisionnements",
"act_use": "Utilisations",
"act_new_products": "Nouveaux produits",
"act_tx_year": "Mouvements annuels",
"price_cache": "Cache prix",
"cache_entries": "produits",
"last_backup": "Dernière sauvegarde",
"bring_days": "jeton expire dans {n} jours",
"bring_expired": "jeton expiré",
"year_label": "Année {year}",
"currency_title": "Devise",
"currency_hint": "Devise utilisée pour tous les coûts et prix dans l'app."
},
"tab_general": "Général",
"kiosk_update_required": "⚠️ Mettez à jour l'application kiosk pour utiliser cette fonction"
}, },
"expiry": { "expiry": {
"today": "AUJOURD'HUI", "today": "AUJOURD'HUI",
@@ -1122,10 +990,8 @@
"thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)", "thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)",
"finished_all": "📤 {name} terminé !", "finished_all": "📤 {name} terminé !",
"product_finished_confirmed": "✅ Supprimé — ajoutez-le à nouveau lors du réapprovisionnement", "product_finished_confirmed": "✅ Supprimé — ajoutez-le à nouveau lors du réapprovisionnement",
"ghost_restored": "✅ {name} : {qty} {unit} restaurés dans l'inventaire",
"appliance_added": "Appareil ajouté", "appliance_added": "Appareil ajouté",
"item_added": "{name} ajouté", "item_added": "{name} ajouté"
"vacuum_sealed": "{name} enregistré sous vide"
}, },
"antiwaste": { "antiwaste": {
"title": "🌱 Rapport anti-gaspi", "title": "🌱 Rapport anti-gaspi",
@@ -1195,9 +1061,7 @@
"offline_ops_pending": "{n} opérations en attente", "offline_ops_pending": "{n} opérations en attente",
"offline_synced": "{n} opérations synchronisées", "offline_synced": "{n} opérations synchronisées",
"offline_ai_disabled": "Indisponible hors ligne", "offline_ai_disabled": "Indisponible hors ligne",
"offline_cache_ready": "Offline — {n} produits en cache", "offline_cache_ready": "Offline — {n} produits en cache"
"copy_failed": "Échec de la copie dans le presse-papiers",
"invalid_quantity": "Quantité invalide"
}, },
"confirm_placeholder_search": null, "confirm_placeholder_search": null,
"confirm": { "confirm": {
@@ -1298,10 +1162,7 @@
"retake_btn": "🔄 Reprendre", "retake_btn": "🔄 Reprendre",
"camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.<br>Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.", "camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.<br>Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.",
"no_barcode": "Pas de code-barres", "no_barcode": "Pas de code-barres",
"save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau", "save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau"
"expiry_found": "Date trouvée",
"expiry_read_fail": "Impossible de lire la date.",
"expiry_raw_label": "Lu"
}, },
"lowstock": { "lowstock": {
"title": "⚠️ Stock faible !", "title": "⚠️ Stock faible !",
@@ -1319,8 +1180,7 @@
"stay_btn": "Non, rester dans {location}", "stay_btn": "Non, rester dans {location}",
"moved_toast": "📦 Emballage ouvert déplacé vers {location}", "moved_toast": "📦 Emballage ouvert déplacé vers {location}",
"vacuum_restore": "🫙 Restaurer sous vide", "vacuum_restore": "🫙 Restaurer sous vide",
"vacuum_seal_rest": "🔒 Mettre le reste sous vide", "vacuum_seal_rest": "🔒 Mettre le reste sous vide"
"moved_simple": "📦 Déplacé vers {location}"
}, },
"nova": { "nova": {
"1": "Non transformé", "1": "Non transformé",
@@ -1550,17 +1410,7 @@
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.", "error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
"retry": "Réessayer", "retry": "Réessayer",
"syncing_local": "Synchronisation des données locales...", "syncing_local": "Synchronisation des données locales...",
"sync_done": "Données locales synchronisées", "sync_done": "Données locales synchronisées"
"token_required": "Jeton API requis",
"token_autoconfig": "Configuration de l'accès...",
"token_prompt_title": "🔒 Jeton API",
"token_prompt_hint": "Saisissez la valeur API_TOKEN du fichier .env du serveur.",
"token_prompt_btn": "Continuer",
"check_db_legacy": "Ancienne BD (dispensa.db)",
"check_tts": "URL synthèse vocale",
"check_scale": "Passerelle balance",
"critical_error_intro": "L'application ne peut pas démarrer en raison des problèmes suivants :",
"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."
}, },
"stats_monthly": { "stats_monthly": {
"title": "Statistiques Mensuelles", "title": "Statistiques Mensuelles",
@@ -1573,12 +1423,5 @@
"top_used": "le plus utilisé", "top_used": "le plus utilisé",
"top_cats": "Catégories principales", "top_cats": "Catégories principales",
"source": "Historique des transactions · mois en cours" "source": "Historique des transactions · mois en cours"
},
"time": {
"just_now": "à l'instant",
"seconds_ago": "il y a {n}s",
"minutes_ago": "il y a {n} min",
"hours_ago": "il y a {n} h",
"days_ago": "il y a {n} j"
} }
} }
+19 -119
View File
@@ -32,7 +32,6 @@
"reset_default": "↺ Ripristina default", "reset_default": "↺ Ripristina default",
"save_info": "💾 Salva informazioni", "save_info": "💾 Salva informazioni",
"retry": "🔄 Riprova", "retry": "🔄 Riprova",
"next": "Avanti →",
"yes_short": "Sì", "yes_short": "Sì",
"no_short": "No" "no_short": "No"
}, },
@@ -144,10 +143,8 @@
"banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.", "banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
"banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.", "banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.",
"banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.", "banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.",
"banner_finished_vanished": "Il prodotto non compare più in inventario, ma i movimenti registrati dicono che non dovrebbe essere finito.",
"banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.", "banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
"banner_finished_check": "Puoi controllare?", "banner_finished_check": "Puoi controllare?",
"banner_finished_action_restore": "Ripristina {qty} {unit}",
"banner_anomaly_phantom_title": "hai più scorte del previsto", "banner_anomaly_phantom_title": "hai più scorte del previsto",
"banner_anomaly_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?", "banner_anomaly_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?",
"banner_anomaly_untracked_title": "scorte non registrate come entrata", "banner_anomaly_untracked_title": "scorte non registrate come entrata",
@@ -167,11 +164,7 @@
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.", "banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
"banner_explain_title": "Chiedi a Gemini una spiegazione", "banner_explain_title": "Chiedi a Gemini una spiegazione",
"banner_explain_btn": "Spiega", "banner_explain_btn": "Spiega",
"banner_analyzing": "🤖 Analizzo…", "banner_analyzing": "🤖 Analizzo…"
"banner_prediction_confirmed": "✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni",
"banner_anomaly_explain_fail": "Impossibile ottenere spiegazione AI",
"banner_anomaly_dismissed": "Anomalia ignorata",
"banner_finished_restore_prompt": "Quante {unit} di {name} hai ancora? (stima sistema: {qty})"
}, },
"inventory": { "inventory": {
"title": "Dispensa", "title": "Dispensa",
@@ -200,7 +193,6 @@
"mode_shopping": "🛒 Modalità Spesa", "mode_shopping": "🛒 Modalità Spesa",
"mode_shopping_end": "✅ Fine spesa", "mode_shopping_end": "✅ Fine spesa",
"spesa_btn": "🛒 Spesa", "spesa_btn": "🛒 Spesa",
"spesa_camera_hint": "Inquadra il codice con la telecamera. Senza barcode? Premi «Identifica con AI» sotto.",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Barcode", "tab_barcode": "Barcode",
"tab_name": "Nome", "tab_name": "Nome",
@@ -236,38 +228,22 @@
"status_confirmed": "Confermato!", "status_confirmed": "Confermato!",
"status_parallel": "Doppia scansione attiva...", "status_parallel": "Doppia scansione attiva...",
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...", "status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
"status_digit_ocr": "Leggo i numeri sotto il codice...",
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...", "status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
"method_ai_ocr": "Gemini OCR", "method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision", "method_ai_vision": "Gemini Vision",
"method_local_ocr": "OCR numeri",
"method_zbar": "ZBar",
"local_ocr_found": "Codice dai numeri: {code}",
"ai_fallback_searching": "Identificazione AI in corso...", "ai_fallback_searching": "Identificazione AI in corso...",
"ai_fallback_found": "Prodotto identificato dall'AI", "ai_fallback_found": "Prodotto identificato dall'AI",
"ai_fallback_not_found": "AI: prodotto non riconosciuto", "ai_fallback_not_found": "AI: prodotto non riconosciuto",
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode", "ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
"ai_overlay_label": "Gemini Vision",
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...", "ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
"ai_retry_btn": "Riprova con AI", "ai_retry_btn": "Riprova con AI",
"ai_manual_btn": "🤖 Identifica con AI",
"ai_not_recognized": "AI: prodotto non riconosciuto. Riprova o inserisci manualmente.",
"ai_match_title": "Prodotto riconosciuto con AI", "ai_match_title": "Prodotto riconosciuto con AI",
"ai_match_subtitle": "Scegli un prodotto esistente o creane uno nuovo con il nome rilevato.", "ai_match_subtitle": "Scegli se usare un prodotto gia presente oppure aggiungere quello rilevato.",
"ai_match_existing": "In dispensa adesso", "ai_match_existing": "Possibili corrispondenze in dispensa",
"ai_match_finished": "Finiti / esauriti", "ai_match_none": "Nessun prodotto simile trovato in dispensa.",
"ai_match_catalog": "Nel catalogo (senza scorte)", "ai_match_use_btn": "Usa questo",
"ai_match_finished_badge": "esaurito", "ai_match_add_btn": "Aggiungi \"{name}\"",
"ai_match_finished_hint": "Prodotto finito — reintegra la quantità", "ai_detected_label": "AI ha trovato"
"ai_match_merged_existing": "Collegato a un prodotto già presente nel catalogo",
"ai_match_none": "Nessun prodotto simile trovato — puoi crearne uno nuovo.",
"ai_match_use_btn": "Usa",
"ai_match_create_btn": " Crea nuovo: {name}",
"ai_match_add_btn": "Aggiungi {name}",
"ai_match_action_hint": "Tocca il pulsante verde per aggiungere questo prodotto",
"ai_match_or_similar": "Oppure scegli un prodotto simile:",
"ai_detected_label": "AI ha trovato",
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
}, },
"action": { "action": {
"title": "Cosa vuoi fare?", "title": "Cosa vuoi fare?",
@@ -306,9 +282,8 @@
"hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera", "hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera",
"scan_expiry_title": "📷 Scansiona Data Scadenza", "scan_expiry_title": "📷 Scansiona Data Scadenza",
"product_added": "✅ {name} aggiunto!{qty}", "product_added": "✅ {name} aggiunto!{qty}",
"duplicate_recent_confirm": "Hai appena aggiunto «{name}» ({when}).\n\nLa quantità è già {total}.\n\nVuoi aumentarla di {qty}?",
"suffix_freezer_vacuum": "(freezer + sotto vuoto)", "suffix_freezer_vacuum": "(freezer + sotto vuoto)",
"history_badge_tip": "Media degli ultimi {n} inserimenti — si aggiorna ad ogni nuovo acquisto", "history_badge_tip": "Media da {n} inserimenti precedenti",
"vacuum_question": "Messo sotto vuoto?", "vacuum_question": "Messo sotto vuoto?",
"vacuum_saved": "🔒 Sotto vuoto registrato" "vacuum_saved": "🔒 Sotto vuoto registrato"
}, },
@@ -341,29 +316,14 @@
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!", "toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!", "toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?", "disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
"disambiguation_one_conf": "Finita <strong>1 confezione</strong> ({qty})",
"disambiguation_all": "🗑️ Finito TUTTO ({qty})", "disambiguation_all": "🗑️ Finito TUTTO ({qty})",
"toast_one_conf_finished": "📦 1 confezione di {name} terminata!",
"error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!", "error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!",
"use_all_confirm_title": "✅ Finisci tutto", "use_all_confirm_title": "✅ Finisci tutto",
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:", "use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
"use_all_confirm_btn": "✅ Sì, finito", "use_all_confirm_btn": "✅ Sì, finito",
"throw_all_confirm_title": "🗑️ Butta tutto", "throw_all_confirm_title": "🗑️ Butta tutto",
"throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?", "throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?",
"throw_all_confirm_btn": "🗑️ Sì, butta", "throw_all_confirm_btn": "🗑️ Sì, butta"
"locations_short": "posti"
},
"waste": {
"reason_title": "Perché lo butti?",
"reason_subtitle": "Ci aiuta a evitare sprechi simili in futuro.",
"reason_expired": "⏰ Scaduto",
"reason_spoiled": "🦠 Andato a male / deperito",
"reason_wrong_location": "📍 Posto sbagliato (frigo/freezer/dispensa)",
"reason_kept_too_long": "⏳ Tenuto troppo a lungo",
"reason_bought_too_much": "🛒 Comprato troppo",
"reason_forgotten": "😴 Dimenticato / non usato in tempo",
"reason_bad_quality": "👎 Qualità scadente all'acquisto",
"reason_other": "❓ Altro"
}, },
"product": { "product": {
"title_new": "Nuovo Prodotto", "title_new": "Nuovo Prodotto",
@@ -373,8 +333,6 @@
"name_label": "🏷️ Nome Prodotto *", "name_label": "🏷️ Nome Prodotto *",
"name_placeholder": "Es: Latte intero, Pasta penne rigate...", "name_placeholder": "Es: Latte intero, Pasta penne rigate...",
"brand_label": "🏢 Marca", "brand_label": "🏢 Marca",
"allergens_label": "Allergeni:",
"ingredients_summary": "📋 Ingredienti",
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...", "brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
"category_label": "📂 Categoria", "category_label": "📂 Categoria",
"unit_label": "📏 Unità di misura", "unit_label": "📏 Unità di misura",
@@ -405,9 +363,7 @@
"weight_label": "Peso", "weight_label": "Peso",
"origin_label": "Origine", "origin_label": "Origine",
"labels_label": "Etichette", "labels_label": "Etichette",
"select_variant": "Seleziona la variante esatta o usa i dati AI:", "select_variant": "Seleziona la variante esatta o usa i dati AI:"
"history_badge": "📊 storico",
"from_history": " (media ultimi 3)"
}, },
"products": { "products": {
"title": "📦 Tutti i Prodotti", "title": "📦 Tutti i Prodotti",
@@ -439,12 +395,6 @@
"regen_save_new": "💾 Salva nell'archivio e genera una nuova", "regen_save_new": "💾 Salva nell'archivio e genera una nuova",
"close_btn": "✅ Chiudi", "close_btn": "✅ Chiudi",
"ingredients_title": "🧾 Ingredienti", "ingredients_title": "🧾 Ingredienti",
"shopping_suggestions_intro": "Per una variante servirebbe (non in dispensa — opzionale):",
"shopping_suggestions_add": "Aggiungi alla lista spesa",
"shopping_suggestions_added": "Aggiunto alla lista spesa",
"frozen_badge": "surgelato — dal freezer",
"unit_for_input": "Unità di misura",
"enter_in": "Inserimento in",
"tools_title": "Strumenti necessari", "tools_title": "Strumenti necessari",
"steps_title": "👨‍🍳 Procedimento", "steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile", "no_steps": "Nessun procedimento disponibile",
@@ -466,18 +416,7 @@
"load_error": "Errore nel caricamento", "load_error": "Errore nel caricamento",
"favorite": "Aggiungi ai preferiti", "favorite": "Aggiungi ai preferiti",
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"adjust_persons": "Persone", "adjust_persons": "Persone"
"nutrition_title": "Valori nutrizionali (per porzione)",
"nutrition_kcal": "Calorie",
"nutrition_protein": "Proteine",
"nutrition_carbs": "Carboidrati",
"nutrition_fat": "Grassi",
"nutrition_per_serving": "Valori stimati per porzione",
"storage_title": "Come conservare gli avanzi",
"storage_days": "{n} giorni",
"storage_immediately": "Da consumare subito",
"ing_stock_line": "Hai {have} · restano {remain} dopo l'uso",
"ing_use_all_note": "uso totale (<5% della confezione intera)"
}, },
"shopping": { "shopping": {
"title": "🛒 Lista della Spesa", "title": "🛒 Lista della Spesa",
@@ -537,18 +476,6 @@
"item_removed": "✅ {name} rimosso dalla lista!", "item_removed": "✅ {name} rimosso dalla lista!",
"urgency_spec_critical": "⚡ Urgente", "urgency_spec_critical": "⚡ Urgente",
"urgency_spec_high": "🟠 Presto", "urgency_spec_high": "🟠 Presto",
"urgency_spec_medium": "🟡 A breve",
"urgency_spec_low": "🔵 Previsione",
"family_sibling_title": "Simile in {location}",
"family_sibling_check": "Controlla: {name}",
"family_sibling_stock": "Dovresti avere: {qty}",
"family_sibling_location": "Si trova in: {location}",
"family_sibling_qty": "Quantità: {qty}",
"family_sibling_purchased": "Acquistato il {date}",
"family_sibling_question": "La quantità è ancora corretta?",
"family_sibling_prompt": "Hai anche {name}: ne hai {qty} in dispensa. Confermi la quantità?",
"family_sibling_yes": "Sì, tutto ok",
"family_sibling_no": "No, aggiorna",
"bring_add_n": "Aggiungi {n} a Bring!", "bring_add_n": "Aggiungi {n} a Bring!",
"bring_add_selected": "Aggiungi selezionati a Bring!", "bring_add_selected": "Aggiungi selezionati a Bring!",
"bring_adding": "Aggiunta in corso...", "bring_adding": "Aggiunta in corso...",
@@ -576,7 +503,6 @@
"remove_error": "Errore nella rimozione", "remove_error": "Errore nella rimozione",
"btn_fetch_prices": "Cerca i prezzi", "btn_fetch_prices": "Cerca i prezzi",
"price_total_label": "💰 Spesa stimata:", "price_total_label": "💰 Spesa stimata:",
"price_total_short": "spesa stimata",
"price_loading": "Ricerca prezzi…", "price_loading": "Ricerca prezzi…",
"price_not_found": "prezzo n/d", "price_not_found": "prezzo n/d",
"suggest_loading": "Analisi in corso...", "suggest_loading": "Analisi in corso...",
@@ -586,8 +512,7 @@
"priority_low": "Bassa", "priority_low": "Bassa",
"smart_last_update": "Aggiornato {time}", "smart_last_update": "Aggiornato {time}",
"names_already_updated": "Tutti i nomi sono già aggiornati", "names_already_updated": "Tutti i nomi sono già aggiornati",
"pantry_hint": "Hai gia {qty} in dispensa", "pantry_hint": "Hai gia {qty} in dispensa"
"bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!"
}, },
"ai": { "ai": {
"title": "🤖 Identificazione AI", "title": "🤖 Identificazione AI",
@@ -598,8 +523,7 @@
"no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>", "no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
"fields_filled": "✅ Campi compilati dall'AI", "fields_filled": "✅ Campi compilati dall'AI",
"use_data": "✅ Usa dati AI", "use_data": "✅ Usa dati AI",
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)", "use_data_no_barcode": "✅ Usa dati AI (senza barcode)"
"conservation_hint": "🤖 AI: conserva in {location}"
}, },
"log": { "log": {
"title": "📒 Storico", "title": "📒 Storico",
@@ -765,8 +689,7 @@
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.", "devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
"detect_btn": "🔄 Rileva fotocamere", "detect_btn": "🔄 Rileva fotocamere",
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)", "ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato.", "ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato."
"ai_manual_hint": "Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato."
}, },
"security": { "security": {
"title": "🔒 Certificato HTTPS", "title": "🔒 Certificato HTTPS",
@@ -855,13 +778,7 @@
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk", "kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.", "kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE", "kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>", "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>"
"discover_scanning": "🔍 Scansione rete locale per gateway bilancia…",
"discover_found": "✅ Gateway trovato: {url}{more}",
"discover_not_found": "❌ Nessun gateway su {subnet}. Avvia l'app Android sulla stessa Wi-Fi.",
"discover_failed": "❌ Ricerca fallita: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Dispositivo sconosciuto"
}, },
"kiosk": { "kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.", "hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
@@ -1048,8 +965,7 @@
"sensor_copied": "YAML copiato negli appunti!", "sensor_copied": "YAML copiato negli appunti!",
"save_btn": "Salva impostazioni HA", "save_btn": "Salva impostazioni HA",
"ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori." "ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori."
}, }
"kiosk_update_required": "⚠️ Aggiorna il kiosk per usare questa funzione"
}, },
"expiry": { "expiry": {
"today": "OGGI", "today": "OGGI",
@@ -1123,7 +1039,6 @@
"finished_all": "📤 {name} terminato!", "finished_all": "📤 {name} terminato!",
"vacuum_sealed": "{name} salvato come sottovuoto", "vacuum_sealed": "{name} salvato come sottovuoto",
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri", "product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
"ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario",
"appliance_added": "Elettrodomestico aggiunto", "appliance_added": "Elettrodomestico aggiunto",
"item_added": "{name} aggiunto" "item_added": "{name} aggiunto"
}, },
@@ -1195,9 +1110,7 @@
"offline_ops_pending": "{n} operazioni in attesa", "offline_ops_pending": "{n} operazioni in attesa",
"offline_synced": "{n} operazioni sincronizzate", "offline_synced": "{n} operazioni sincronizzate",
"offline_ai_disabled": "Non disponibile offline", "offline_ai_disabled": "Non disponibile offline",
"offline_cache_ready": "Offline — {n} prodotti in cache", "offline_cache_ready": "Offline — {n} prodotti in cache"
"copy_failed": "Copia negli appunti non riuscita",
"invalid_quantity": "Quantità non valida"
}, },
"confirm": { "confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?", "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
@@ -1318,8 +1231,7 @@
"stay_btn": "No, resta in {location}", "stay_btn": "No, resta in {location}",
"moved_toast": "📦 Confezione aperta spostata in {location}", "moved_toast": "📦 Confezione aperta spostata in {location}",
"vacuum_restore": "Torna sotto vuoto", "vacuum_restore": "Torna sotto vuoto",
"vacuum_seal_rest": "Metti sotto vuoto il resto", "vacuum_seal_rest": "Metti sotto vuoto il resto"
"moved_simple": "📦 Spostato in {location}"
}, },
"nova": { "nova": {
"1": "Non trasformato", "1": "Non trasformato",
@@ -1554,12 +1466,7 @@
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.", "error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
"retry": "Riprova", "retry": "Riprova",
"syncing_local": "Sincronizzazione dati locali...", "syncing_local": "Sincronizzazione dati locali...",
"sync_done": "Dati locali aggiornati", "sync_done": "Dati locali aggiornati"
"token_required": "Token API richiesto",
"token_autoconfig": "Configurazione accesso...",
"token_prompt_title": "🔒 Token API",
"token_prompt_hint": "Inserisci il valore API_TOKEN dal file .env del server.",
"token_prompt_btn": "Continua"
}, },
"stats_monthly": { "stats_monthly": {
"title": "Statistiche Mensili", "title": "Statistiche Mensili",
@@ -1572,12 +1479,5 @@
"top_used": "più usato", "top_used": "più usato",
"top_cats": "Categorie principali", "top_cats": "Categorie principali",
"source": "Storico transazioni · mese corrente" "source": "Storico transazioni · mese corrente"
},
"time": {
"just_now": "adesso",
"seconds_ago": "{n}s fa",
"minutes_ago": "{n} min fa",
"hours_ago": "{n} h fa",
"days_ago": "{n} gg fa"
} }
} }