Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcecc2430d | |||
| 33d5c4c370 | |||
| ddef591108 | |||
| ca98acc1f2 | |||
| 616818998d | |||
| ef73630cad | |||
| a6369765b0 | |||
| 5a50403e52 | |||
| 865cb561be | |||
| 18169417d3 | |||
| 22dd77c879 | |||
| bcbdf669a1 | |||
| c513b0b4ef | |||
| 3bfa89e61d | |||
| 427bcd20a4 | |||
| 9b026408e2 | |||
| 5abf5f9adf | |||
| e58eb0501a | |||
| ecf7ec53fd | |||
| 78310f9fe9 | |||
| b73748346e | |||
| 833afb3cfd | |||
| 726d371d26 | |||
| a4267ee420 | |||
| fef70cb97c | |||
| ca285f7a9d | |||
| 8176237b93 | |||
| 5ce935de49 | |||
| 751b18ba3c | |||
| 55d562e0a3 | |||
| 7c3fb41b43 | |||
| a84824327d | |||
| 409ea5d2e5 | |||
| 0de44ae341 | |||
| 5a4e16c30d | |||
| cfcc3ce49f | |||
| 3965c6ef44 | |||
| 99b65900c4 | |||
| 1505550a16 | |||
| b23edc39b3 | |||
| 371dda46f0 | |||
| 3c68ce0dd1 | |||
| 94f5649183 | |||
| cff250055c | |||
| 477978aed9 | |||
| bf08556556 | |||
| 594c9fa115 | |||
| 8c471c1b7f | |||
| 9075740454 | |||
| 3f242c1836 | |||
| 9c97798a21 | |||
| 0fb887756f | |||
| 37bf403412 | |||
| 3676793194 | |||
| 3f410022a7 | |||
| 7631018929 | |||
| bb16e441f5 | |||
| bacb98b4eb | |||
| b739cb3a47 | |||
| 754daa9989 | |||
| 80a915ac35 | |||
| b28c6591a9 | |||
| 1e40da7235 | |||
| 046355d6b0 | |||
| 3a33dc7173 | |||
| c0c1a312c6 | |||
| 8ae455d82c | |||
| 0c8eee404e | |||
| e746c3d05b | |||
| 6f1966113d | |||
| f1e07f4151 | |||
| 7d4b881d7c | |||
| 63faf402c2 | |||
| 8543106fed | |||
| 4d1a4be0ea | |||
| 0065987050 | |||
| ad101cc58f | |||
| 3a08929353 | |||
| f935790ab2 | |||
| 45ea60e305 | |||
| d364588011 | |||
| 9fc1dd3614 | |||
| 68484b8323 | |||
| e534ea2e96 | |||
| 08bb293963 | |||
| ae3254c195 | |||
| 77b053c573 | |||
| ab79339e8c | |||
| 6cba7132d5 | |||
| 32da374e57 | |||
| 8fda93044d | |||
| a97907dbe1 | |||
| 94eeba280a | |||
| 978088ae23 | |||
| d4c5b5b97c | |||
| 0ec2620926 | |||
| f65f65d38f | |||
| f3c1fd2e7b | |||
| 69eb319aa1 | |||
| 02bfc82e2b | |||
| b8e43388b6 | |||
| 6e532ebdd1 | |||
| 56a10bd1c4 | |||
| 621fb4f96e | |||
| 57ec6af58c | |||
| 11886578ab | |||
| a02abce26e | |||
| 79d1ca06c7 | |||
| d1ba63d9a0 | |||
| 0ca4df0d27 | |||
| fc324d55f5 | |||
| 99c35e4c18 | |||
| b12cd76acc | |||
| 477139d47c | |||
| 8b44432244 | |||
| 4f1717c4b1 | |||
| c2002977cd | |||
| f7fb4e8f33 | |||
| ef9c26bed6 | |||
| aaf4de5e6b | |||
| 56bc6a709f | |||
| b60994d745 | |||
| 6e86c19262 | |||
| 619b7b4517 | |||
| ca2e39bc49 | |||
| 42fcbef95b | |||
| 0619d1487c | |||
| 79fff10b48 | |||
| a6f59cabfb | |||
| eaf9ebc52e | |||
| df8211c8ac | |||
| 7fae0e08bf | |||
| 450095376c | |||
| 257fa4797d | |||
| 9acf952c10 | |||
| e0deb3481b | |||
| d35f975ab9 | |||
| 0502a4d132 | |||
| 2ea69fd223 | |||
| 01f28a2b09 | |||
| 4bedfdd0f4 | |||
| 6c63bb17a3 | |||
| 665d3097e7 | |||
| 43047276a6 | |||
| 51ad25926a | |||
| 33e552373c | |||
| fc124c87bf | |||
| b166a305f2 | |||
| 6377c45eaa | |||
| 8d18362d83 | |||
| cabb8b28d4 | |||
| 27257caa3e | |||
| 3d744f256a | |||
| 8e504cad28 | |||
| e6b3328fe9 | |||
| db7fc0df18 | |||
| 3bb6dc7155 | |||
| d75a6e76c6 | |||
| b28aca5e55 | |||
| 2fb782f8e1 | |||
| c08798d462 | |||
| f6fe0a55bd | |||
| fecbbafd38 | |||
| c0ad69cf11 | |||
| a2a1a5ba77 | |||
| 98553c7600 | |||
| 7d89d61b95 | |||
| 6b1d5f4c45 | |||
| 288e0c05f6 | |||
| e52aa6d699 | |||
| 9512e3a8df | |||
| 4a729d2d10 | |||
| 6ecb881d9f | |||
| 662c27d7b4 | |||
| df9624ad75 | |||
| df0a47e336 | |||
| b97845553e | |||
| 495d7a22eb | |||
| a1511c608a | |||
| a3a3b54a85 | |||
| 4ee2e5638b | |||
| a9f0527769 | |||
| bf27f7f462 | |||
| 85ccdaa6f6 | |||
| 16993135b9 | |||
| d1716fa6ff | |||
| 3ac42f7767 | |||
| eb19265586 | |||
| 8a69e6d941 | |||
| c5b0dbcf42 | |||
| 338bd7ff66 | |||
| c7532f90cd | |||
| 5831e3bcea | |||
| ec1aae2a25 | |||
| 4f9f44e230 | |||
| 9be8fb5cf3 | |||
| 00b1c35665 | |||
| 5dd3baea5d | |||
| 9d3cf05496 | |||
| 34dcb05c05 | |||
| dea1223faf | |||
| 7eda4a5eb9 | |||
| e72e57edf6 | |||
| b63deca795 | |||
| 217626ca2a | |||
| cf65e79010 | |||
| 46bbe0f8d3 | |||
| a0385cfb9b | |||
| 3a938dd7fb | |||
| 0d006625fd | |||
| d5b4a6c4da | |||
| d33b0ca2fe |
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.git/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
+18
-4
@@ -125,10 +125,24 @@ GDRIVE_FOLDER_ID=
|
||||
GDRIVE_RETENTION_DAYS=30
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────────────
|
||||
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
||||
# Leave empty to allow anyone with access to the server to change settings.
|
||||
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
|
||||
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
|
||||
API_TOKEN=
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard)
|
||||
CORS_ORIGIN=
|
||||
|
||||
# GitHub automatic issue reporting (encrypted storage recommended)
|
||||
# Option A — plain ( .env is gitignored ):
|
||||
# GH_ISSUE_TOKEN=ghp_...
|
||||
# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'):
|
||||
GH_ISSUE_TOKEN=
|
||||
GH_ISSUE_TOKEN_ENC=
|
||||
GH_ISSUE_TOKEN_KEY=
|
||||
|
||||
# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens.
|
||||
|
||||
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||
# for Zeroconf discovery label and device name in Home Assistant).
|
||||
# Defaults to the server hostname if left empty.
|
||||
@@ -160,5 +174,5 @@ HA_EXPIRY_DAYS=3
|
||||
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
||||
DEMO_MODE=false
|
||||
|
||||
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
|
||||
# To rotate it, update the GH_ISSUE_TOKEN constant there.
|
||||
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
|
||||
CRON_LOG_MAX_BYTES=524288
|
||||
|
||||
@@ -37,8 +37,10 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
||||
VCODE=$(grep 'versionCode' evershelf-kiosk/app/build.gradle.kts | grep -oP '\d+')
|
||||
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Kiosk version: $VERSION"
|
||||
echo "code=$VCODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Kiosk version: $VERSION (versionCode $VCODE)"
|
||||
|
||||
- name: Build debug APK
|
||||
run: gradle assembleDebug --no-daemon
|
||||
@@ -75,7 +77,21 @@ jobs:
|
||||
sleep 3
|
||||
gh release create kiosk-latest \
|
||||
--title "EverShelf Kiosk Latest" \
|
||||
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
|
||||
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
|
||||
--prerelease \
|
||||
artifacts/evershelf-kiosk.apk
|
||||
|
||||
- name: Publish APK to releases/ for LAN OTA
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk
|
||||
printf '{"version":"%s","version_code":%s}\n' \
|
||||
"${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \
|
||||
> releases/kiosk-version.json
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add releases/evershelf-kiosk.apk releases/kiosk-version.json
|
||||
git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
|
||||
|
||||
@@ -43,7 +43,18 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t evershelf-test .
|
||||
run: |
|
||||
set -e
|
||||
for attempt in 1 2 3; do
|
||||
echo "Docker build attempt $attempt/3..."
|
||||
if docker build -t evershelf-test .; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt failed — retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
echo "Docker build failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Test container starts
|
||||
run: |
|
||||
@@ -206,7 +217,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||
|
||||
@@ -52,3 +52,4 @@ data/food_facts_cache.json
|
||||
data/category_ai_cache.json
|
||||
assets/img/logo/*_backup.*
|
||||
logs/*.log
|
||||
assets/vendor/transformers/Xenova/
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
RewriteEngine On
|
||||
|
||||
# Force HTTPS
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
# Block sensitive files (Apache 2.4+)
|
||||
<Files ".env">
|
||||
Require all denied
|
||||
</Files>
|
||||
<Files ".env.example">
|
||||
Require all denied
|
||||
</Files>
|
||||
<Files "backup.sh">
|
||||
Require all denied
|
||||
</Files>
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Force HTTPS (skip when terminated TLS is forwarded — Traefik, Caddy, NPM, …)
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
|
||||
#RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# API routing
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
|
||||
+105
@@ -11,6 +11,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.42] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES.
|
||||
- **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage.
|
||||
- **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments.
|
||||
|
||||
### Fixed
|
||||
- **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context.
|
||||
- **SQLite database locked (#201–#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`.
|
||||
- **Smart shopping timeout (#203–#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories.
|
||||
- **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`.
|
||||
- **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented.
|
||||
|
||||
## [1.7.41] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
- **Docker/Traefik “Impossibile contattare il server”** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production.
|
||||
- **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually.
|
||||
- **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs.
|
||||
|
||||
## [1.7.40] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”.
|
||||
- **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list.
|
||||
- **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen.
|
||||
- **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue).
|
||||
- **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token.
|
||||
- **Ops CLI scripts** — `audit-finished-shopping.php`, `backfill-finished-shopping.php`, `sync-shopping-bring.php`, `install-transformers-model.sh` (offline Xenova classifier bootstrap).
|
||||
|
||||
### Fixed
|
||||
- **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention.
|
||||
- **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode.
|
||||
- **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron).
|
||||
- **Spesa scan crash** — `currentPage` → `_currentPageId` in `_applySpesaScanUI`.
|
||||
- **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes.
|
||||
- **Smart shopping purchased block** — Server-side blocklist + spesa mode sync prevents cron from re-adding bought items.
|
||||
|
||||
### Changed
|
||||
- **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops.
|
||||
|
||||
## [1.7.39] - 2026-06-06
|
||||
|
||||
### Added
|
||||
- **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans.
|
||||
- **Spesa barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page).
|
||||
- **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip.
|
||||
- **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated.
|
||||
- **Family sibling 24h dedup** — After confirming “Sì, tutto ok” on a similar in-stock product, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings).
|
||||
- **Family sibling stock line** — Spesa prompt shows readable stock (e.g. `4 conf (da 20g)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES.
|
||||
- **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page.
|
||||
|
||||
### Fixed
|
||||
- **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched` → `enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes.
|
||||
- **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included).
|
||||
- **Spesa ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity.
|
||||
- **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned.
|
||||
- **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay.
|
||||
- **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after spesa add.
|
||||
|
||||
### Changed
|
||||
- **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~1–2s first hit); 30-minute negative cache for unknown codes.
|
||||
- **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes.
|
||||
|
||||
## [1.7.38] - 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
|
||||
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
|
||||
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
|
||||
|
||||
### Changed
|
||||
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
|
||||
|
||||
## [1.7.37] - 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
|
||||
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
|
||||
|
||||
### Changed
|
||||
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
|
||||
|
||||
## [1.7.36] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
|
||||
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
|
||||
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
|
||||
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
|
||||
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
|
||||
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
|
||||
|
||||
### Fixed
|
||||
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
|
||||
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
|
||||
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
|
||||
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
|
||||
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
|
||||
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
|
||||
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
|
||||
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
|
||||
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
|
||||
|
||||
## [1.7.35] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
+8
-3
@@ -6,10 +6,12 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||
libcurl4-openssl-dev \
|
||||
libonig-dev \
|
||||
libgd-dev \
|
||||
libzip-dev \
|
||||
libicu-dev \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-ita \
|
||||
tesseract-ocr-eng \
|
||||
&& docker-php-ext-install pdo_sqlite curl mbstring gd \
|
||||
&& docker-php-ext-install pdo_sqlite curl mbstring gd zip intl \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable Apache mod_rewrite and mod_headers
|
||||
@@ -28,12 +30,15 @@ RUN mkdir -p /var/www/html/data/backups \
|
||||
|
||||
# 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 chown www-data:www-data /var/www/html/.env && chmod 664 /var/www/html/.env
|
||||
|
||||
# Apache configuration: serve from app root
|
||||
RUN echo '<Directory /var/www/html>\n\
|
||||
AllowOverride All\n\
|
||||
Require all granted\n\
|
||||
</Directory>' > /etc/apache2/conf-available/evershelf.conf \
|
||||
</Directory>\n\
|
||||
# Traefik / reverse-proxy: treat forwarded HTTPS as on so .htaccess does not redirect-loop\n\
|
||||
SetEnvIf X-Forwarded-Proto "https" HTTPS=on' > /etc/apache2/conf-available/evershelf.conf \
|
||||
&& a2enconf evershelf
|
||||
|
||||
# Expose port 80
|
||||
@@ -43,4 +48,4 @@ EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -f http://localhost/ || exit 1
|
||||
|
||||
CMD ["apache2-foreground"]
|
||||
CMD ["apache2-foreground"]
|
||||
@@ -1,550 +1,223 @@
|
||||
# 🏠 EverShelf
|
||||
# 🏠 EverShelf for 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)**
|
||||
·
|
||||
[🌐 Project Website](https://evershelfproject.dadaloop.it/)
|
||||
·
|
||||
[📖 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>
|
||||
> 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).
|
||||
|
||||
---
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.php.net/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
[](https://github.com/dadaloop82/EverShelf/discussions)
|
||||
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
||||
|
||||
[](https://ko-fi.com/J3J01ZNETZ)
|
||||
[](Dockerfile)
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ 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.
|
||||
## ✨ Fonctionnalités principales
|
||||
|
||||
### 📦 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
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
## 🤖 Intelligence artificielle (Google Gemini)
|
||||
|
||||
### 🏠 NEW — Home Assistant Integration
|
||||
EverShelf peut utiliser l'IA pour :
|
||||
|
||||
EverShelf has a **native Home Assistant integration** available on HACS.
|
||||
Connect your pantry to your smart home in minutes — no YAML, no manual sensor setup.
|
||||
- 📸 Identifier un produit depuis une photo
|
||||
- 📅 Lire automatiquement une date limite de consommation
|
||||
- 🧊 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
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
|
||||
|
||||
[](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)
|
||||
> L'IA est optionnelle. EverShelf fonctionne sans clé Gemini.
|
||||
|
||||
---
|
||||
|
||||
### 📦 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)")
|
||||
## 🛒 Liste de courses intelligente
|
||||
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||
- **Product identification** — Point your camera at any product for instant recognition
|
||||
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
|
||||
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **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
|
||||
- Création automatique depuis les ruptures de stock
|
||||
- Prévisions de besoins
|
||||
- Synchronisation avec Bring!
|
||||
- Nettoyage automatique des doublons
|
||||
- Suggestions d'achat personnalisées
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## 🍳 Mode cuisine
|
||||
|
||||
### Prerequisites
|
||||
- **Web server** with PHP 8.0+ (Apache or Nginx)
|
||||
- **PHP extensions**: `pdo_sqlite`, `curl`, `mbstring`, `json`
|
||||
- **HTTPS** recommended (required for camera access on mobile)
|
||||
- Recettes étape par étape
|
||||
- Mode mains libres
|
||||
- Synthèse vocale (TTS)
|
||||
- Minuteurs automatiques
|
||||
- Suivi des ingrédients utilisés
|
||||
- Conseils anti-gaspillage
|
||||
|
||||
### Installation
|
||||
---
|
||||
|
||||
#### Option A: Docker (recommended)
|
||||
## ♻️ Réduction du gaspillage
|
||||
|
||||
- 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
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
git clone https://git.mashome.fr/morgane/EverShelf.git
|
||||
cd EverShelf
|
||||
|
||||
# 2. Create configuration file
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# 3. Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# → Open http://localhost:8080
|
||||
```
|
||||
|
||||
#### Option B: Manual
|
||||
Puis ouvrez :
|
||||
http://localhost:8080
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
cd EverShelf
|
||||
---
|
||||
### 🐳 Déploiement via Portainer
|
||||
|
||||
# 2. Create configuration file
|
||||
cp .env.example .env
|
||||
1. Dans Portainer, va dans **Stacks** → **Add stack**
|
||||
2. Donne un nom à la stack (ex : `evershelf`)
|
||||
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**
|
||||
|
||||
# 3. Set permissions
|
||||
chmod 755 data/
|
||||
chmod 664 data/.gitkeep
|
||||
chown -R www-data:www-data data/
|
||||
|
||||
# 4. Edit your configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
### Configuration (.env)
|
||||
|
||||
```ini
|
||||
# Required for AI features (get a key at https://aistudio.google.com/app/apikey)
|
||||
GEMINI_API_KEY=your_api_key_here
|
||||
|
||||
# Optional: Bring! shopping list integration
|
||||
BRING_EMAIL=your_email@example.com
|
||||
BRING_PASSWORD=your_password
|
||||
|
||||
# Optional: Text-to-Speech for cooking mode
|
||||
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
||||
TTS_TOKEN=your_long_lived_token
|
||||
TTS_ENABLED=true
|
||||
|
||||
# Optional: DB retention and cleanup (applied automatically each cron cycle)
|
||||
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
|
||||
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
|
||||
|
||||
# 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`.
|
||||
Pour mettre à jour après une modification de code :
|
||||
- Va dans **Stacks** → ta stack → **Update the stack**
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
## ⚙️ Configuration
|
||||
|
||||
```
|
||||
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
|
||||
Exemple de fichier `.env` :
|
||||
|
||||
evershelf-scale-gateway/ # ⚖️ Android BLE gateway [DEPRECATED — integrated into kiosk v1.6.0+]
|
||||
├── README.md # Deprecation notice + legacy docs
|
||||
└── app/src/ # Kotlin Android source (WebSocket + BLE)
|
||||
```env
|
||||
# IA Google Gemini (optionnel)
|
||||
GEMINI_API_KEY=votre_cle
|
||||
|
||||
evershelf-kiosk/ # 📺 Android kiosk app (add-on)
|
||||
├── README.md # Setup & feature docs
|
||||
└── app/src/ # Kotlin Android source (WebView wrapper)
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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 |
|
||||
## 🔒 Vie privée
|
||||
|
||||
EverShelf est conçu pour fonctionner en auto-hébergement :
|
||||
|
||||
- Pas de compte obligatoire
|
||||
- Pas de cloud imposé
|
||||
- Données stockées localement
|
||||
- SQLite comme base de données
|
||||
- Les fonctions IA utilisent uniquement les services configurés par l'utilisateur
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Notes
|
||||
## 🛠️ Développement
|
||||
|
||||
- **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
|
||||
Technologies principales :
|
||||
|
||||
- PHP
|
||||
- SQLite
|
||||
- JavaScript
|
||||
- HTML/CSS
|
||||
- Docker
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
## 📜 Licence
|
||||
|
||||
```bash
|
||||
# Run PHP's built-in server for local development
|
||||
php -S localhost:8080 -t /path/to/evershelf
|
||||
|
||||
# Check PHP syntax
|
||||
php -l api/index.php
|
||||
php -l api/database.php
|
||||
```
|
||||
|
||||
The application uses no build tools — edit files directly and refresh.
|
||||
Projet sous licence MIT.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Roadmap
|
||||
## 🙏 Crédits
|
||||
|
||||
Feature requests, bug reports and planned work are tracked in the [**EverShelf Roadmap**](https://github.com/users/dadaloop82/projects/2) GitHub Project.
|
||||
Ce fork, **EverShelf for Ricardo**, est maintenu par Morgane pour servir de système de gestion de stock/recettes à l'application **Ricardo**.
|
||||
|
||||
---
|
||||
Projet original :
|
||||
|
||||
## 🌐 Translations
|
||||
https://github.com/dadaloop82/EverShelf
|
||||
|
||||
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">
|
||||
|
||||

|
||||
|
||||
</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!
|
||||
Ce dépôt contient des améliorations et adaptations personnelles, incluant un système d'export/import avec fusion intelligente des données.
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf API bootstrap — shared by HTTP router and cron.
|
||||
*/
|
||||
// Never emit HTML notices before JSON API responses (breaks fetch().json() in the PWA).
|
||||
if (!defined('CRON_MODE') && (getenv('DISPLAY_ERRORS') ?: '') !== '1') {
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
}
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/constants.php';
|
||||
require_once __DIR__ . '/lib/github.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
require_once __DIR__ . '/lib/cron_log.php';
|
||||
require_once __DIR__ . '/logger.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
// Define CRON_MODE before loading index.php so the router is skipped
|
||||
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
|
||||
define('CRON_MODE', true);
|
||||
|
||||
// Load all API functions without running the HTTP router
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/index.php';
|
||||
|
||||
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
|
||||
evershelfRotateCronLog();
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
|
||||
@@ -42,9 +44,10 @@ try {
|
||||
$itemCount = count($decoded['items'] ?? []);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
||||
|
||||
// ── Bring! server-side cleanup ────────────────────────────────────────
|
||||
// After computing smart shopping, automatically remove stale Bring! items
|
||||
// and add/update critical ones. This runs fully server-side every cron cycle.
|
||||
// ── Bring! server-side sync ───────────────────────────────────────────
|
||||
// After computing smart shopping, remove stale Bring! items and push every
|
||||
// product that needs restocking (esauriti, quasi finiti, previsione).
|
||||
// Runs fully server-side every cron cycle (~5 min).
|
||||
try {
|
||||
$cleanupResult = bringCleanupObsolete($db);
|
||||
if (isset($cleanupResult['skipped'])) {
|
||||
@@ -55,6 +58,21 @@ try {
|
||||
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
||||
}
|
||||
|
||||
$dedupeResult = bringDedupeGenerics($db);
|
||||
if (isset($dedupeResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe skipped: ' . $dedupeResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe — removed: ' . ($dedupeResult['removed'] ?? 0)
|
||||
. ', merged specs: ' . ($dedupeResult['merged'] ?? 0) . "\n";
|
||||
}
|
||||
|
||||
$specsResult = bringSyncSpecs($db);
|
||||
if (isset($specsResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs skipped: ' . $specsResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs — updated: ' . ($specsResult['updated'] ?? 0) . "\n";
|
||||
}
|
||||
|
||||
$addResult = bringAutoAddCritical($db);
|
||||
if (isset($addResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
||||
@@ -62,6 +80,11 @@ try {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
||||
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
||||
}
|
||||
|
||||
$dedupeFinal = bringDedupeGenerics($db);
|
||||
if (!isset($dedupeFinal['skipped']) && (($dedupeFinal['removed'] ?? 0) > 0)) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe (final) — removed: ' . ($dedupeFinal['removed'] ?? 0) . "\n";
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
|
||||
+292
-2
@@ -38,8 +38,24 @@ function _ensureDataDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the SQLite DB and WAL sidecar files are writable (Docker volume first-boot). */
|
||||
function _ensureDbWritable(): void {
|
||||
if (!file_exists(DB_PATH)) {
|
||||
return;
|
||||
}
|
||||
if (!is_writable(DB_PATH)) {
|
||||
@chmod(DB_PATH, 0664);
|
||||
}
|
||||
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $sidecar) {
|
||||
if (file_exists($sidecar) && !is_writable($sidecar)) {
|
||||
@chmod($sidecar, 0664);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDB(): PDO {
|
||||
_ensureDataDir();
|
||||
_ensureDbWritable();
|
||||
// logger.php is required by index.php before getDB() is called.
|
||||
// In cron context it may not be loaded yet — guard with class_exists.
|
||||
$useLogging = class_exists('LoggingPDO', false);
|
||||
@@ -53,7 +69,7 @@ function getDB(): PDO {
|
||||
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||
// For SQLite, we use PRAGMA busy_timeout.
|
||||
$db->exec('PRAGMA journal_mode = WAL;');
|
||||
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
|
||||
$db->exec('PRAGMA busy_timeout = 10000;'); // 10 s — cron + PWA writes can contend under WAL
|
||||
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
@@ -72,6 +88,29 @@ function getDB(): PDO {
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a DB write when SQLite returns "database is locked" (concurrent cron + API).
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $fn
|
||||
* @return T
|
||||
*/
|
||||
function dbWithRetry(callable $fn, int $maxAttempts = 4): mixed {
|
||||
$attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\PDOException $e) {
|
||||
$attempt++;
|
||||
$locked = str_contains($e->getMessage(), 'database is locked');
|
||||
if (!$locked || $attempt >= $maxAttempts) {
|
||||
throw $e;
|
||||
}
|
||||
usleep(150000 * $attempt); // 150 ms, 300 ms, 450 ms …
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDB(PDO $db): void {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
@@ -147,6 +186,28 @@ function migrateDB(PDO $db): void {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''"); }
|
||||
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
|
||||
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
|
||||
@@ -262,6 +323,227 @@ 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_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
|
||||
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
|
||||
if (empty($shopTables)) {
|
||||
@@ -291,6 +573,12 @@ function migrateDB(PDO $db): void {
|
||||
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; }
|
||||
}
|
||||
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,6 +731,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
|
||||
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
|
||||
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
|
||||
if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) return 7;
|
||||
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
|
||||
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
|
||||
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
|
||||
@@ -520,6 +809,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
elseif (preg_match('/uova/', $n)) $days = 28;
|
||||
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
|
||||
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
|
||||
elseif (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) $days = 7;
|
||||
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
|
||||
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
|
||||
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
|
||||
@@ -616,4 +906,4 @@ function getVacuumExpiryDaysPHP(int $baseDays): int {
|
||||
if ($baseDays <= 30) return (int)round($baseDays * 2.5);
|
||||
if ($baseDays <= 90) return (int)round($baseDays * 2.5);
|
||||
return (int)round($baseDays * 1.5);
|
||||
}
|
||||
}
|
||||
+4872
-1568
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — shared path constants.
|
||||
*/
|
||||
|
||||
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
|
||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
|
||||
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
|
||||
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
|
||||
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
|
||||
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
|
||||
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
|
||||
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
|
||||
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
|
||||
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
|
||||
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
|
||||
|
||||
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
|
||||
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
|
||||
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
|
||||
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Rotate data/cron.log — keep last N MB / lines.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/constants.php';
|
||||
|
||||
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
|
||||
$path = CRON_LOG_PATH;
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
|
||||
$size = filesize($path);
|
||||
if ($size === false || $size <= $maxBytes) {
|
||||
return;
|
||||
}
|
||||
for ($i = $keepRotated; $i >= 1; $i--) {
|
||||
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
|
||||
$to = $path . '.' . $i;
|
||||
if ($i === $keepRotated && file_exists($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
if (file_exists($from)) {
|
||||
@rename($from, $to);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
|
||||
*
|
||||
* Configure ONE of:
|
||||
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
|
||||
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
|
||||
*
|
||||
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
function evershelfDecryptGhToken(string $encB64, string $key): string {
|
||||
$raw = base64_decode($encB64, true);
|
||||
if ($raw === false || strlen($raw) < 28) {
|
||||
return '';
|
||||
}
|
||||
$iv = substr($raw, 0, 12);
|
||||
$tag = substr($raw, 12, 16);
|
||||
$cipher = substr($raw, 28);
|
||||
$plain = openssl_decrypt(
|
||||
$cipher,
|
||||
'aes-256-gcm',
|
||||
hash('sha256', $key, true),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return ($plain !== false) ? $plain : '';
|
||||
}
|
||||
|
||||
function evershelfEncryptGhToken(string $plain, string $key): string {
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt(
|
||||
$plain,
|
||||
'aes-256-gcm',
|
||||
hash('sha256', $key, true),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return base64_encode($iv . $tag . $cipher);
|
||||
}
|
||||
|
||||
/** Decode GitHub Issues token at runtime — never stored in source code. */
|
||||
function _ghToken(): string {
|
||||
static $token = null;
|
||||
if ($token !== null) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$plain = env('GH_ISSUE_TOKEN');
|
||||
if ($plain !== '') {
|
||||
$token = $plain;
|
||||
return $token;
|
||||
}
|
||||
|
||||
$enc = env('GH_ISSUE_TOKEN_ENC');
|
||||
$key = env('GH_ISSUE_TOKEN_KEY');
|
||||
if ($enc !== '' && $key !== '') {
|
||||
$token = evershelfDecryptGhToken($enc, $key);
|
||||
return $token;
|
||||
}
|
||||
|
||||
$token = '';
|
||||
return $token;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
|
||||
function evershelfEffectiveApiToken(): string {
|
||||
$api = env('API_TOKEN');
|
||||
if ($api !== '') {
|
||||
return $api;
|
||||
}
|
||||
return env('SETTINGS_TOKEN', '');
|
||||
}
|
||||
|
||||
function evershelfApiTokenRequired(): bool {
|
||||
return evershelfEffectiveApiToken() !== '';
|
||||
}
|
||||
|
||||
function evershelfGetProvidedApiToken(): string {
|
||||
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
|
||||
return (string)$_SERVER['HTTP_X_API_TOKEN'];
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
|
||||
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
|
||||
}
|
||||
if (isset($_GET['api_token'])) {
|
||||
return (string)$_GET['api_token'];
|
||||
}
|
||||
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return evershelfGetProvidedApiTokenFromHeaders();
|
||||
}
|
||||
|
||||
function evershelfApiTokenValid(): bool {
|
||||
$required = evershelfEffectiveApiToken();
|
||||
if ($required === '') {
|
||||
return true;
|
||||
}
|
||||
$provided = evershelfGetProvidedApiToken();
|
||||
return $provided !== '' && hash_equals($required, $provided);
|
||||
}
|
||||
|
||||
function evershelfGetProvidedApiTokenFromHeaders(): string {
|
||||
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
|
||||
}
|
||||
|
||||
/** Actions reachable without API token (telemetry + public probes). */
|
||||
function evershelfPublicActions(): array {
|
||||
return [
|
||||
'ping',
|
||||
'app_bootstrap',
|
||||
'check_update',
|
||||
'report_error',
|
||||
'report_bug',
|
||||
'client_log',
|
||||
'gdrive_oauth_callback',
|
||||
];
|
||||
}
|
||||
|
||||
/** GET actions that mutate state — require auth when token is configured. */
|
||||
function evershelfMutatingGetActions(): array {
|
||||
return ['db_cleanup', 'export_inventory'];
|
||||
}
|
||||
|
||||
function evershelfDestructiveActions(): array {
|
||||
return [
|
||||
'save_settings', 'db_cleanup',
|
||||
'backup_now', 'backup_delete', 'backup_restore',
|
||||
'gdrive_push', 'gdrive_oauth_exchange',
|
||||
'migrate_units',
|
||||
];
|
||||
}
|
||||
|
||||
function evershelfActionNeedsAuth(string $action, string $method): bool {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($action, evershelfPublicActions(), true)) {
|
||||
return false;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
return true;
|
||||
}
|
||||
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, evershelfDestructiveActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
// Protect all data reads when API token is set
|
||||
return true;
|
||||
}
|
||||
|
||||
function evershelfRequireApiAuth(string $action, string $method): void {
|
||||
if (!evershelfActionNeedsAuth($action, $method)) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'unauthorized',
|
||||
'api_token_required' => true,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function evershelfRequireAuthForSensitive(string $action): void {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function evershelfSendCorsHeaders(): void {
|
||||
$configured = env('CORS_ORIGIN', '');
|
||||
if ($configured === '') {
|
||||
// Same-origin SPA — do not emit wildcard CORS
|
||||
return;
|
||||
}
|
||||
if ($configured === '*') {
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
} else {
|
||||
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = array_filter(array_map('trim', explode(',', $configured)));
|
||||
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
|
||||
header('Access-Control-Allow-Origin: ' . $reqOrigin);
|
||||
header('Vary: Origin');
|
||||
}
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
|
||||
}
|
||||
|
||||
/** Read-only actions allowed in DEMO_MODE. */
|
||||
function evershelfDemoReadOnlyActions(): array {
|
||||
return [
|
||||
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
|
||||
'search_barcode', 'lookup_barcode', 'resolve_barcode', 'stock_for_name',
|
||||
'product_get', 'products_list', 'products_search', 'inventory_search', 'ai_product_suggest',
|
||||
'inventory_list', 'inventory_summary', 'inventory_finished_items',
|
||||
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
|
||||
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
|
||||
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
|
||||
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
|
||||
'recipes_list', 'chat_list', 'app_settings_get',
|
||||
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
|
||||
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
|
||||
'backup_list', 'export_inventory',
|
||||
];
|
||||
}
|
||||
|
||||
function evershelfDemoBlocksAction(string $action, string $method): bool {
|
||||
if (env('DEMO_MODE') !== 'true') {
|
||||
return false;
|
||||
}
|
||||
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
|
||||
return false;
|
||||
}
|
||||
// Block all AI generation in demo (cost + writes)
|
||||
if (str_starts_with($action, 'gemini_') || in_array($action, [
|
||||
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, evershelfMutatingGetActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
return !in_array($action, evershelfDemoReadOnlyActions(), true);
|
||||
}
|
||||
|
||||
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
|
||||
function evershelfAllowedScaleHosts(): array {
|
||||
$hosts = ['127.0.0.1', 'localhost', '::1'];
|
||||
$gw = env('SCALE_GATEWAY_URL', '');
|
||||
if ($gw !== '') {
|
||||
$p = parse_url($gw);
|
||||
if (!empty($p['host'])) {
|
||||
$hosts[] = strtolower($p['host']);
|
||||
}
|
||||
}
|
||||
// Server's own LAN IP — gateway may bind here on kiosk LAN
|
||||
if (function_exists('gethostname')) {
|
||||
$lan = gethostbyname(gethostname());
|
||||
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
|
||||
$hosts[] = $lan;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($hosts));
|
||||
}
|
||||
|
||||
function evershelfScaleHostAllowed(string $host): bool {
|
||||
$host = strtolower(trim($host));
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
foreach (evershelfAllowedScaleHosts() as $allowed) {
|
||||
if ($host === strtolower($allowed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
|
||||
$serverIp = evershelfLocalLanIp();
|
||||
if ($serverIp !== '') {
|
||||
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
|
||||
if (str_starts_with($host, $subnet . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function evershelfLocalLanIp(): string {
|
||||
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($sock) {
|
||||
@socket_connect($sock, '8.8.8.8', 53);
|
||||
@socket_getsockname($sock, $ip);
|
||||
socket_close($sock);
|
||||
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request comes from the EverShelf web UI on the same host.
|
||||
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
|
||||
*/
|
||||
function evershelfIsSameOriginBrowser(): bool {
|
||||
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin !== '') {
|
||||
$oh = parse_url($origin, PHP_URL_HOST);
|
||||
return $oh && strtolower($oh) === $host;
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$rh = parse_url($referer, PHP_URL_HOST);
|
||||
return $rh && strtolower($rh) === $host;
|
||||
}
|
||||
|
||||
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
|
||||
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
|
||||
function evershelfRequireScaleAccess(): void {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfIsSameOriginBrowser()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
@@ -335,6 +335,7 @@ class LoggingPDOStatement {
|
||||
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
class LoggingPDO extends \PDO {
|
||||
#[\ReturnTypeWillChange]
|
||||
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
||||
$stmt = parent::prepare($query, $options);
|
||||
if ($stmt === false) {
|
||||
|
||||
+56
-51
@@ -1,57 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Auto-discovery
|
||||
*
|
||||
* Scans the server's local /24 subnet for any host responding on the gateway
|
||||
* port (default 8765) and confirms it with a WebSocket handshake.
|
||||
*
|
||||
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
|
||||
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
evershelfSendCorsHeaders();
|
||||
|
||||
$port = (int)($_GET['port'] ?? 8765);
|
||||
if ($port < 1 || $port > 65535) $port = 8765;
|
||||
|
||||
// ── Determine server LAN IP ────────────────────────────────────────────────
|
||||
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
|
||||
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
|
||||
function localLanIp(): string {
|
||||
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($sock) {
|
||||
@socket_connect($sock, '8.8.8.8', 53);
|
||||
@socket_getsockname($sock, $ip);
|
||||
socket_close($sock);
|
||||
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
}
|
||||
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
|
||||
$ifaces = @net_get_interfaces();
|
||||
if ($ifaces) {
|
||||
foreach ($ifaces as $name => $info) {
|
||||
if ($name === 'lo') continue;
|
||||
foreach ($info['unicast'] ?? [] as $u) {
|
||||
$ip = $u['address'] ?? '';
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$serverIp = localLanIp();
|
||||
// Simple rate limit: max 6 scans per minute per IP
|
||||
$rlDir = dirname(__DIR__) . '/data/rate_limits';
|
||||
if (!is_dir($rlDir)) {
|
||||
@mkdir($rlDir, 0755, true);
|
||||
}
|
||||
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
|
||||
$now = time();
|
||||
$hits = [];
|
||||
if (file_exists($rlFile)) {
|
||||
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
|
||||
}
|
||||
if (count($hits) >= 6) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Too many discovery scans']);
|
||||
exit;
|
||||
}
|
||||
$hits[] = $now;
|
||||
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
|
||||
|
||||
$port = (int)($_GET['port'] ?? 8765);
|
||||
if ($port < 1 || $port > 65535) {
|
||||
$port = 8765;
|
||||
}
|
||||
|
||||
$serverIp = evershelfLocalLanIp();
|
||||
$parts = explode('.', $serverIp);
|
||||
if (count($parts) !== 4) {
|
||||
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
|
||||
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
|
||||
exit;
|
||||
}
|
||||
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
|
||||
|
||||
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
|
||||
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
|
||||
// Total scan budget: 1.5 seconds.
|
||||
|
||||
$candidates = [];
|
||||
for ($i = 1; $i <= 254; $i++) {
|
||||
$ip = $subnet . $i;
|
||||
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
$read = null;
|
||||
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
||||
$n = @stream_select($read, $write, $except, 0, $usec);
|
||||
if ($n === false || $n === 0) break;
|
||||
if ($n === false || $n === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Sockets in $except = connection refused/error
|
||||
$failed = [];
|
||||
foreach ($except as $s) {
|
||||
$ip = array_search($s, $candidates, true);
|
||||
if ($ip !== false) $failed[$ip] = true;
|
||||
if ($ip !== false) {
|
||||
$failed[$ip] = true;
|
||||
}
|
||||
}
|
||||
// Sockets in $write = connection complete (may overlap with $except on error)
|
||||
foreach ($write as $s) {
|
||||
$ip = array_search($s, $candidates, true);
|
||||
if ($ip === false) continue;
|
||||
if ($ip === false) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($failed[$ip])) {
|
||||
$found_tcp[] = $ip;
|
||||
}
|
||||
@fclose($s);
|
||||
unset($candidates[$ip]);
|
||||
}
|
||||
// Close failed sockets too
|
||||
foreach ($failed as $ip => $_) {
|
||||
if (isset($candidates[$ip])) {
|
||||
@fclose($candidates[$ip]);
|
||||
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
|
||||
foreach ($candidates as $s) {
|
||||
@fclose($s);
|
||||
}
|
||||
|
||||
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
|
||||
$gateways = [];
|
||||
foreach ($found_tcp as $ip) {
|
||||
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
||||
if (!$sock) continue;
|
||||
if (!$sock) {
|
||||
continue;
|
||||
}
|
||||
stream_set_timeout($sock, 2);
|
||||
|
||||
$key = base64_encode(random_bytes(16));
|
||||
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
|
||||
$dl = microtime(true) + 2;
|
||||
while (microtime(true) < $dl && !feof($sock)) {
|
||||
$line = fgets($sock, 256);
|
||||
if ($line === false) break;
|
||||
if ($line === false) {
|
||||
break;
|
||||
}
|
||||
$resp .= $line;
|
||||
if ($line === "\r\n") break;
|
||||
if ($line === "\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose($sock);
|
||||
|
||||
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
|
||||
echo json_encode([
|
||||
'found' => $gateways,
|
||||
'subnet' => rtrim($subnet, '.') . '.0/24',
|
||||
'server_ip' => $serverIp,
|
||||
]);
|
||||
|
||||
+16
-7
@@ -1,16 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Connection ping / test
|
||||
*
|
||||
* Performs a WebSocket handshake with the gateway and returns
|
||||
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
|
||||
*
|
||||
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
$host = strtolower($parsed['host'] ?? '');
|
||||
$port = (int)($parsed['port'] ?? 8765);
|
||||
$path = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!evershelfScaleHostAllowed($host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Try to open a TCP connection with a 5-second timeout
|
||||
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
||||
if (!$sock) {
|
||||
|
||||
+17
-1
@@ -8,6 +8,16 @@
|
||||
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────────────
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$wsHost = $parsed['host'] ?? '';
|
||||
$wsHost = strtolower($parsed['host'] ?? '');
|
||||
$wsPort = (int)($parsed['port'] ?? 8765);
|
||||
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!evershelfScaleHostAllowed($wsHost)) {
|
||||
header('Content-Type: text/event-stream');
|
||||
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── SSE headers ───────────────────────────────────────────────────────────────
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
@@ -666,6 +666,126 @@ body.server-offline .bottom-nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Family sibling hint (spesa mode) — compact card with thumbnail */
|
||||
.family-sibling-prompt {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9998;
|
||||
background: linear-gradient(145deg, #1e3a5f 0%, #0f2744 100%);
|
||||
color: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
max-width: min(440px, calc(100vw - 20px));
|
||||
width: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
animation: family-sibling-in 0.22s ease-out;
|
||||
}
|
||||
@keyframes family-sibling-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
.family-sibling-prompt-body {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.family-sibling-prompt-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.family-sibling-prompt-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.family-sibling-prompt-icon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.family-sibling-prompt-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.family-sibling-prompt-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.family-sibling-prompt-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.family-sibling-prompt-stock {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin: 4px 0 0;
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.family-sibling-prompt-meta {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
.family-sibling-prompt-question {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.25;
|
||||
margin: 2px 0 0;
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
.family-sibling-prompt-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.family-sibling-prompt-actions button {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
min-height: 38px;
|
||||
}
|
||||
.family-sibling-prompt-yes {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
.family-sibling-prompt-no {
|
||||
background: #475569;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes pulse-scan {
|
||||
0%, 100% { box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
|
||||
50% { box-shadow: 0 2px 16px rgba(255,255,255,0.4); }
|
||||
@@ -1727,6 +1847,41 @@ body.server-offline .bottom-nav {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.qty-control-with-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qty-control-with-unit .qty-control {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.qty-unit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
height: 50px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.qty-unit-badge.qty-unit-muted {
|
||||
background: var(--bg-card);
|
||||
color: var(--primary);
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
/* ===== USE OPTIONS ===== */
|
||||
.use-options {
|
||||
display: flex;
|
||||
@@ -1845,6 +2000,19 @@ body.server-offline .bottom-nav {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Spesa mode: hide manual barcode field only — tabs and normal viewport stay */
|
||||
#page-scan.spesa-scan-layout .barcode-input-row {
|
||||
display: none !important;
|
||||
}
|
||||
.spesa-scan-barcode-hint {
|
||||
margin: 0;
|
||||
padding: 6px 4px 2px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.scanner-viewport video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -2009,6 +2177,63 @@ body.server-offline .bottom-nav {
|
||||
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
|
||||
.scan-status-msg.state-retry { color: #fb923c; }
|
||||
|
||||
/* — AI processing overlay (full-viewport, shown during Gemini Vision call) — */
|
||||
.scan-ai-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.scan-ai-overlay-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px 28px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1.5px solid rgba(255,255,255,0.18);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.scan-ai-overlay-label {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.scan-ai-overlay-msg {
|
||||
font-size: 0.88rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
/* — Manual AI button (user-triggered only) — */
|
||||
.scan-ai-manual-btn {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 1.05rem;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid var(--accent);
|
||||
background: rgba(124,58,237,0.12);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.scan-ai-manual-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.scan-ai-manual-btn:active:not(:disabled) { background: rgba(124,58,237,0.22); }
|
||||
|
||||
/* — Viewport overlay controls (torch / zoom / flip) — */
|
||||
.scan-viewport-controls {
|
||||
position: absolute;
|
||||
@@ -2059,6 +2284,183 @@ body.server-offline .bottom-nav {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.scan-ai-match-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.scan-ai-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, rgba(124,58,237,0.12), rgba(34,197,94,0.1));
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.scan-ai-hero-icon {
|
||||
font-size: 2.6rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scan-ai-hero-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.scan-ai-match-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.scan-ai-hero-name {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.scan-ai-hero-brand {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scan-ai-action-hint {
|
||||
margin: -6px 0 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.scan-ai-or-divider {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.scan-ai-match-subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.scan-ai-match-list-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.scan-ai-match-list-title {
|
||||
font-size: 0.88rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.scan-ai-match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.scan-ai-candidate-item {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-main);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.scan-ai-candidate-item:active { transform: scale(0.99); }
|
||||
.scan-ai-candidate-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.scan-ai-candidate-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.scan-ai-candidate-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scan-ai-candidate-info {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
.scan-ai-candidate-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scan-ai-candidate-meta {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scan-ai-candidate-cta {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scan-ai-match-empty {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-main);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.scan-ai-add-btn {
|
||||
width: 100%;
|
||||
font-size: 1.15rem !important;
|
||||
font-weight: 800 !important;
|
||||
padding: 16px 20px !important;
|
||||
min-height: 54px;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 4px 14px rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
.scan-ai-detected-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.scan-ai-detected-pill {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-main);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* — Recent scans — */
|
||||
.scan-recents {
|
||||
display: flex;
|
||||
@@ -2933,6 +3335,14 @@ body.server-offline .bottom-nav {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.shopping-item-specific {
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.smart-brand {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
@@ -4295,6 +4705,93 @@ body.server-offline .bottom-nav {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== RECIPE NUTRITION BLOCK ===== */
|
||||
.recipe-nutrition-block {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.recipe-section-heading {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #15803d;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.recipe-nutrition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.recipe-nutrition-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.recipe-nutrition-icon { font-size: 1.2rem; }
|
||||
.recipe-nutrition-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #15803d;
|
||||
}
|
||||
.recipe-nutrition-label {
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.recipe-nutrition-note {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.recipe-nutrition-footnote {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ===== RECIPE STORAGE CARD ===== */
|
||||
.recipe-storage-card {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.recipe-storage-card .recipe-section-heading { color: #b45309; }
|
||||
.recipe-storage-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.recipe-storage-badge {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 20px;
|
||||
padding: 2px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.recipe-storage-days { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
|
||||
.recipe-storage-now { background: #fee2e2; border-color: #fca5a5; color: #b91c1c; }
|
||||
.recipe-storage-tips {
|
||||
font-size: 0.82rem;
|
||||
color: #78350f;
|
||||
margin: 2px 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recipe-tools-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -4419,6 +4916,13 @@ body.server-offline .bottom-nav {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.recipe-ing-stock {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== SHOPPING SECTION (REPARTO) HEADERS ===== */
|
||||
.shopping-section-divider {
|
||||
display: flex;
|
||||
@@ -5939,6 +6443,12 @@ body.cooking-mode-active .app-header {
|
||||
}
|
||||
.banner-anomaly .alert-banner-title { color: #9a3412; }
|
||||
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
|
||||
.alert-banner.banner-dup-loss {
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.banner-dup-loss .alert-banner-title { color: #991b1b; }
|
||||
.banner-dup-loss .alert-banner-counter .banner-dot.active { background: #dc2626; }
|
||||
.alert-banner.banner-no-expiry {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
|
||||
border-color: #16a34a;
|
||||
@@ -7838,6 +8348,8 @@ body.cooking-mode-active .app-header {
|
||||
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
|
||||
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
|
||||
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
|
||||
[data-theme="dark"] .alert-banner.banner-dup-loss { background: #2a0808; border-color: #dc2626; }
|
||||
[data-theme="dark"] .banner-dup-loss .alert-banner-title { color: #fca5a5; }
|
||||
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
|
||||
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
|
||||
|
||||
@@ -7908,6 +8420,18 @@ body.cooking-mode-active .app-header {
|
||||
|
||||
/* ── Recipe components ── */
|
||||
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-nutrition-block { background: #052e16; border-color: #166534; }
|
||||
[data-theme="dark"] .recipe-section-heading { color: #4ade80; }
|
||||
[data-theme="dark"] .recipe-storage-card .recipe-section-heading { color: #fbbf24; }
|
||||
[data-theme="dark"] .recipe-nutrition-value { color: #4ade80; }
|
||||
[data-theme="dark"] .recipe-nutrition-label { color: #94a3b8; }
|
||||
[data-theme="dark"] .recipe-nutrition-note { color: #64748b; }
|
||||
[data-theme="dark"] .recipe-nutrition-footnote { color: var(--text-muted); }
|
||||
[data-theme="dark"] .recipe-storage-card { background: #1c1400; border-color: #78350f; }
|
||||
[data-theme="dark"] .recipe-storage-badge { background: #2a1e00; border-color: #92400e; color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-storage-days { background: #0c1a2e; border-color: #1d4ed8; color: #93c5fd; }
|
||||
[data-theme="dark"] .recipe-storage-now { background: #2a0a0a; border-color: #b91c1c; color: #fca5a5; }
|
||||
[data-theme="dark"] .recipe-storage-tips { color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||
|
||||
+4031
-944
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* EverShelf core — API token storage and auth headers.
|
||||
*/
|
||||
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
|
||||
|
||||
function getApiToken() {
|
||||
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setApiToken(token) {
|
||||
const t = (token || '').trim();
|
||||
if (t) {
|
||||
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
|
||||
} else {
|
||||
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function apiAuthHeaders() {
|
||||
const fromStorage = getApiToken();
|
||||
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
|
||||
const token = fromSettingsField || fromStorage;
|
||||
if (!token) return {};
|
||||
return { 'X-API-Token': token };
|
||||
}
|
||||
|
||||
/** Fetch API token from server when loading the UI from the same origin. */
|
||||
async function ensureApiToken() {
|
||||
if (getApiToken()) return true;
|
||||
try {
|
||||
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
window._apiTokenRequired = !!data.api_token_required;
|
||||
if (data.api_token) {
|
||||
setApiToken(data.api_token);
|
||||
return true;
|
||||
}
|
||||
} catch (_) { /* offline / network */ }
|
||||
return !!getApiToken();
|
||||
}
|
||||
|
||||
function _promptApiTokenIfNeeded() {
|
||||
if (!window._apiTokenRequired) return;
|
||||
if (getApiToken()) return;
|
||||
const existing = document.getElementById('api-token-overlay');
|
||||
if (existing) return;
|
||||
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
|
||||
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
|
||||
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'api-token-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px;padding:20px">
|
||||
<h3>${title}</h3>
|
||||
<p class="settings-hint">${hint}</p>
|
||||
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
|
||||
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('api-token-save').onclick = () => {
|
||||
const v = document.getElementById('api-token-input').value.trim();
|
||||
if (v) {
|
||||
setApiToken(v);
|
||||
overlay.remove();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.getApiToken = getApiToken;
|
||||
window.setApiToken = setApiToken;
|
||||
window.apiAuthHeaders = apiAuthHeaders;
|
||||
window.ensureApiToken = ensureApiToken;
|
||||
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* EverShelf core — safe HTML escaping (loaded before app.js).
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
window.escapeHtml = escapeHtml;
|
||||
Vendored
+4
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+281
File diff suppressed because one or more lines are too long
+281
File diff suppressed because one or more lines are too long
+3
File diff suppressed because one or more lines are too long
+3
File diff suppressed because one or more lines are too long
+107
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+26
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
@@ -1,13 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Daily backup of EverShelf database (local only)
|
||||
# The database is NOT pushed to remote repositories.
|
||||
# Runs via cron: creates a local timestamped backup copy
|
||||
#
|
||||
# Example crontab entry:
|
||||
# 0 3 * * * /var/www/html/evershelf/backup.sh
|
||||
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
|
||||
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
||||
ENV_FILE="${INSTALL_DIR}/.env"
|
||||
|
||||
RETENTION=3
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2)
|
||||
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
|
||||
RETENTION="$val"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
@@ -19,5 +25,5 @@ fi
|
||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
||||
|
||||
# Keep only the last 7 backups
|
||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
||||
# Keep only the newest N backups
|
||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
|
||||
Require all denied
|
||||
+9
-2
@@ -5,14 +5,21 @@ services:
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
# Persist database and runtime data
|
||||
- evershelf_data:/var/www/html/data
|
||||
# Mount your local .env configuration
|
||||
- ./.env:/var/www/html/.env:ro
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Rome
|
||||
networks:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
evershelf_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
external: true
|
||||
backend:
|
||||
external: true
|
||||
@@ -0,0 +1,38 @@
|
||||
# EverShelf — Architecture (modular layout)
|
||||
|
||||
```
|
||||
dispensa/
|
||||
├── api/
|
||||
│ ├── bootstrap.php # Shared init: env, security, DB, logger
|
||||
│ ├── index.php # HTTP handlers + router (split planned per domain)
|
||||
│ ├── database.php # SQLite schema & migrations
|
||||
│ ├── logger.php # Rotating file logger (logs/)
|
||||
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
|
||||
│ ├── lib/
|
||||
│ │ ├── env.php # .env loader
|
||||
│ │ ├── constants.php # Paths & pricing constants
|
||||
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
|
||||
│ │ ├── github.php # Encrypted GitHub Issues token
|
||||
│ │ └── cron_log.php # data/cron.log rotation
|
||||
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
|
||||
├── assets/
|
||||
│ ├── js/
|
||||
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
|
||||
│ │ └── app.js # SPA logic (domain modules: future split)
|
||||
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
|
||||
├── data/ # Runtime data (.htaccess: deny all)
|
||||
├── logs/ # Application logs (.htaccess: deny all)
|
||||
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
|
||||
```
|
||||
|
||||
## Security model
|
||||
|
||||
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
|
||||
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
|
||||
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
|
||||
|
||||
## Planned refactors
|
||||
|
||||
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
|
||||
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
|
||||
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 18
|
||||
versionName = "1.7.17"
|
||||
versionCode = 20
|
||||
versionName = "1.7.19"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -101,6 +101,20 @@ class KioskActivity : AppCompatActivity() {
|
||||
// Pending WebView permission request
|
||||
private var pendingWebPermission: PermissionRequest? = null
|
||||
|
||||
private fun safeEvalJs(script: String) {
|
||||
if (!::webView.isInitialized) return
|
||||
if (isFinishing || isDestroyed) return
|
||||
if (webView.visibility != View.VISIBLE) return
|
||||
runCatching { webView.evaluateJavascript(script, null) }
|
||||
.onFailure {
|
||||
ErrorReporter.reportMessage(
|
||||
type = "webview-js-bridge-error",
|
||||
message = "Failed to deliver JS callback to WebView",
|
||||
extra = mapOf("error" to (it.message ?: "unknown"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_CHOOSER_REQUEST = 1002
|
||||
private const val PERMISSION_REQUEST_CODE = 1003
|
||||
@@ -150,18 +164,18 @@ class KioskActivity : AppCompatActivity() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
|
||||
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
|
||||
}
|
||||
}
|
||||
@Deprecated("Deprecated in API 21")
|
||||
override fun onError(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
|
||||
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
|
||||
}
|
||||
}
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
|
||||
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -629,6 +643,79 @@ class KioskActivity : AppCompatActivity() {
|
||||
webView.evaluateJavascript("$jsCallback($escaped)", null)
|
||||
}
|
||||
}
|
||||
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
val installedVc: Long = try {
|
||||
val pi = packageManager.getPackageInfo(packageName, 0)
|
||||
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
|
||||
else @Suppress("DEPRECATION") pi.versionCode.toLong()
|
||||
} catch (_: Exception) { -1L }
|
||||
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(r.size, l.size)) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when {
|
||||
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
|
||||
currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) ->
|
||||
semverNewer(remoteVersion, currentKiosk)
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun applyUpdate(remoteVersion: String, apkUrl: String) {
|
||||
val result = JSONObject()
|
||||
.put("has_update", true)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", remoteVersion)
|
||||
.put("apk_url", apkUrl)
|
||||
notifyJs(result)
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteVersion)
|
||||
.putString(KEY_PENDING_UPDATE_URL, apkUrl)
|
||||
.apply()
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteVersion", apkUrl) }
|
||||
}
|
||||
|
||||
// 1) Prefer LAN/self-hosted update (no GitHub required)
|
||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/')
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
try {
|
||||
val localApi = "$baseUrl/api/index.php?action=kiosk_update"
|
||||
val conn = openTrustedConnection(localApi)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
if (conn.responseCode == 200) {
|
||||
val localJson = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
conn.disconnect()
|
||||
if (localJson.optBoolean("success")) {
|
||||
val remoteVersion = localJson.optString("version", "")
|
||||
val remoteVc = localJson.optLong("version_code", -1L)
|
||||
val apkUrl = localJson.optString("apk_url", "")
|
||||
if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) {
|
||||
applyUpdate(remoteVersion, apkUrl)
|
||||
return@Thread
|
||||
}
|
||||
if (!needsUpdate(remoteVersion, remoteVc)) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("source", "local"))
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
} else conn.disconnect()
|
||||
} catch (_: Exception) { /* fall through to GitHub */ }
|
||||
}
|
||||
|
||||
// 2) GitHub release fallback (requires internet)
|
||||
try {
|
||||
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
@@ -643,43 +730,16 @@ class KioskActivity : AppCompatActivity() {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val json = JSONObject(body)
|
||||
val latestTag = json.optString("tag_name", "")
|
||||
if (latestTag.isEmpty()) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", "no tag"))
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
|
||||
// Extract the actual kiosk version from the release body text.
|
||||
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
|
||||
// Fall back to stripping the tag prefix if body parsing fails.
|
||||
val bodyText = json.optString("body", "")
|
||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
|
||||
.find(bodyText)?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: norm(latestTag)
|
||||
?: norm(json.optString("tag_name", ""))
|
||||
|
||||
// Compare semver: returns true if `remote` is strictly greater than `local`
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val len = maxOf(r.size, l.size)
|
||||
for (i in 0 until len) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
|
||||
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
|
||||
|
||||
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
|
||||
val assets = json.optJSONArray("assets")
|
||||
var kioskApkUrl = ""
|
||||
if (assets != null) {
|
||||
@@ -693,38 +753,35 @@ class KioskActivity : AppCompatActivity() {
|
||||
}
|
||||
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||
|
||||
// Only flag an update when the remote version is parseable as semver AND
|
||||
// strictly greater than the installed version.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
|
||||
semverNewer(remoteKioskVersion, currentKiosk)
|
||||
|
||||
val result = JSONObject()
|
||||
.put("has_update", kioskNeedsUpdate)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", remoteKioskVersion)
|
||||
.put("apk_url", kioskApkUrl)
|
||||
|
||||
notifyJs(result)
|
||||
|
||||
if (!kioskNeedsUpdate) {
|
||||
// Clear any stale pending update if the current version is now up to date
|
||||
if (!needsUpdate(remoteKioskVersion, remoteVc)) {
|
||||
notifyJs(JSONObject().put("has_update", false))
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// Persist the pending update so the banner reappears after a crash/restart
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
|
||||
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
|
||||
.apply()
|
||||
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteKioskVersion", kioskApkUrl) }
|
||||
applyUpdate(remoteKioskVersion, kioskApkUrl)
|
||||
} catch (e: Exception) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
/** HTTPS with self-signed cert support (LAN servers). */
|
||||
private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection {
|
||||
val conn = URL(urlStr).openConnection()
|
||||
if (conn is javax.net.ssl.HttpsURLConnection) {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : javax.net.ssl.X509TrustManager {
|
||||
override fun checkClientTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||
override fun checkServerTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
conn.sslSocketFactory = sc.socketFactory
|
||||
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
return conn as java.net.HttpURLConnection
|
||||
}
|
||||
|
||||
/**
|
||||
* On resume: if a previous session detected an available update and saved it to prefs,
|
||||
* restore the update banner immediately without a network round-trip.
|
||||
|
||||
@@ -540,6 +540,11 @@ class SetupActivity : AppCompatActivity() {
|
||||
// Cancel auto-discover when leaving server step
|
||||
if (step != 3) discoverCancelled.set(true)
|
||||
|
||||
// Auto-discover when entering server step (empty URL only)
|
||||
if (step == 3 && urlEdit.text.toString().trim().isEmpty()) {
|
||||
autoDiscover()
|
||||
}
|
||||
|
||||
// Scroll to top
|
||||
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
|
||||
}
|
||||
@@ -697,6 +702,58 @@ class SetupActivity : AppCompatActivity() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun normalizeDiscoveredBase(urlStr: String): String {
|
||||
var base = urlStr.substringBefore("/api/")
|
||||
if (base.endsWith(":443")) base = base.removeSuffix(":443")
|
||||
if (base.endsWith(":80")) base = base.removeSuffix(":80")
|
||||
return if (base.endsWith("/")) base else "$base/"
|
||||
}
|
||||
|
||||
private fun probeEverShelfEndpoint(urlStr: String): String? {
|
||||
return try {
|
||||
val conn = openConn(urlStr) ?: return null
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..399) {
|
||||
conn.disconnect()
|
||||
return null
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
if (body.contains("gemini_key_set") || body.contains("\"success\"") || body.contains("\"ok\"")) {
|
||||
normalizeDiscoveredBase(urlStr)
|
||||
} else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeEverShelfHost(ip: String, port: Int): String? {
|
||||
val reachable = try {
|
||||
Socket().use { s -> s.connect(InetSocketAddress(ip, port), 800); true }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!reachable) return null
|
||||
|
||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||
val portInUrl = when {
|
||||
scheme == "https" && port == 443 -> ""
|
||||
scheme == "http" && port == 80 -> ""
|
||||
else -> ":$port"
|
||||
}
|
||||
val paths = listOf(
|
||||
"/dispensa/api/index.php?action=ping",
|
||||
"/api/index.php?action=ping",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/api/index.php?action=get_settings",
|
||||
"/evershelf/api/index.php?action=get_settings",
|
||||
)
|
||||
for (path in paths) {
|
||||
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun openConn(urlStr: String): HttpURLConnection? {
|
||||
return try {
|
||||
val conn = URL(urlStr).openConnection()
|
||||
@@ -772,9 +829,52 @@ class SetupActivity : AppCompatActivity() {
|
||||
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
|
||||
|
||||
val ports = listOf(443, 80, 8080, 8443)
|
||||
|
||||
// ── 1b. Fast path: likely hosts on Wi-Fi subnet (incl. .128) before full sweep ─
|
||||
val priorityIps = linkedSetOf<String>()
|
||||
try {
|
||||
val ifaces = NetworkInterface.getNetworkInterfaces()
|
||||
while (ifaces != null && ifaces.hasMoreElements()) {
|
||||
val intf = ifaces.nextElement()
|
||||
if (!intf.isUp || intf.isLoopback) continue
|
||||
for (addr in intf.interfaceAddresses) {
|
||||
val ip = addr.address
|
||||
if (ip is java.net.Inet4Address && !ip.isLoopbackAddress) {
|
||||
priorityIps.add(ip.hostAddress ?: continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
for (subnet in wifiSubnets.ifEmpty { subnets.take(1) }) {
|
||||
for (last in listOf(1, 128, 100, 10, 50, 254)) {
|
||||
priorityIps.add("$subnet.$last")
|
||||
}
|
||||
}
|
||||
|
||||
runOnUiThread { discoverStatus.text = "🔍 ${getString(R.string.setup_discovering_detail)}" }
|
||||
for (ip in priorityIps) {
|
||||
if (discoverCancelled.get()) break
|
||||
for (port in ports) {
|
||||
val hit = probeEverShelfHost(ip, port)
|
||||
if (hit != null) {
|
||||
runOnUiThread {
|
||||
urlEdit.setText(hit)
|
||||
discoverStatus.text = "✅ ${getString(R.string.setup_server_found)}: $hit"
|
||||
discoverStatus.setTextColor(0xFF34d399.toInt())
|
||||
showUrlStatus("✅ ${getString(R.string.setup_server_found)}", true)
|
||||
btnDiscover.isEnabled = true
|
||||
btnDiscover.text = getString(R.string.setup_discover_btn)
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val paths = listOf(
|
||||
"/api/index.php?action=get_settings",
|
||||
"/dispensa/api/index.php?action=ping",
|
||||
"/api/index.php?action=ping",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/api/index.php?action=get_settings",
|
||||
"/evershelf/api/index.php?action=get_settings",
|
||||
)
|
||||
|
||||
@@ -819,30 +919,24 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
// Full HTTP probe on reachable host
|
||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||
val portInUrl = when {
|
||||
scheme == "https" && port == 443 -> ""
|
||||
scheme == "http" && port == 80 -> ""
|
||||
else -> ":$port"
|
||||
}
|
||||
for (path in paths) {
|
||||
if (discoverCancelled.get() || found.get()) break
|
||||
val urlStr = "$scheme://$ip:$port$path"
|
||||
try {
|
||||
val conn = openConn(urlStr) ?: continue
|
||||
val code = conn.responseCode
|
||||
if (code in 200..399) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
|
||||
return@submit urlStr.substringBefore("/api/") + "/"
|
||||
}
|
||||
} else conn.disconnect()
|
||||
} catch (_: Exception) {}
|
||||
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it }
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Collect results as they complete (not in submission order) ────
|
||||
// ── 3. Collect results until all tasks finish or a server is found ────
|
||||
var result: String? = null
|
||||
var collected = 0
|
||||
while (collected < total && !discoverCancelled.get()) {
|
||||
val future = cs.poll(3, TimeUnit.SECONDS) ?: break
|
||||
while (collected < total && !discoverCancelled.get() && result == null) {
|
||||
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue
|
||||
collected++
|
||||
val r = try { future.get() } catch (_: Exception) { null }
|
||||
if (r != null && found.compareAndSet(false, true)) {
|
||||
@@ -1101,9 +1195,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||
}
|
||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
|
||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
|
||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
|
||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"")
|
||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"")
|
||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"")
|
||||
append("}")
|
||||
}
|
||||
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<string name="install_error_download">Download fehlgeschlagen</string>
|
||||
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
|
||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||
<string name="install_perm_detail">Aktiviere 'Unbekannte Apps installieren' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||
<string name="btn_back">Zurück</string>
|
||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
|
||||
|
||||
Zum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → "API-Schlüssel erhalten"</string>
|
||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.\n\nZum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → \"API-Schlüssel erhalten\"</string>
|
||||
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.
|
||||
|
||||
Bring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
||||
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
||||
<string name="setup_done_title">Alles bereit!</string>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<string name="install_error_download">Descarga fallida</string>
|
||||
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
||||
<string name="install_error_install">Instalación fallida</string>
|
||||
<string name="install_perm_detail">Habilita 'Instalar apps desconocidas' en los ajustes y vuelve aquí.</string>
|
||||
<string name="install_perm_detail">Habilita \'Instalar apps desconocidas\' en los ajustes y vuelve aquí.</string>
|
||||
<string name="install_btn_retry">↩ Reintentar</string>
|
||||
<string name="btn_back">Atrás</string>
|
||||
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
|
||||
|
||||
Para activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → "Obtener clave API"</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.\n\nPara activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → \"Obtener clave API\"</string>
|
||||
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.
|
||||
|
||||
Introduce tus credenciales de Bring! para activar la integración.</string>
|
||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.\n\nIntroduce tus credenciales de Bring! para activar la integración.</string>
|
||||
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
||||
<string name="setup_done_title">¡Todo listo!</string>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
<string name="setup_enter_url">Veuillez d'abord saisir une URL</string>
|
||||
<string name="setup_enter_url">Veuillez d\'abord saisir une URL</string>
|
||||
<string name="setup_testing">Test de connexion…</string>
|
||||
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
||||
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
||||
<string name="setup_unreachable">Impossible d'atteindre le serveur</string>
|
||||
<string name="setup_unreachable">Impossible d\'atteindre le serveur</string>
|
||||
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
||||
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
||||
<string name="setup_discovering">Analyse en cours…</string>
|
||||
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l'URL manuellement.</string>
|
||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l\'URL manuellement.</string>
|
||||
<string name="setup_exit_title">Quitter la configuration ?</string>
|
||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l'app.</string>
|
||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l\'app.</string>
|
||||
<string name="setup_exit_confirm">Quitter</string>
|
||||
<string name="setup_exit_cancel">Continuer</string>
|
||||
<string name="setup_step_back">← Retour</string>
|
||||
@@ -22,20 +22,20 @@
|
||||
<string name="wizard_step3_title">Balance intelligente</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
||||
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
||||
<string name="wizard_step3_yes">✅ Oui, j'ai une balance</string>
|
||||
<string name="wizard_step3_yes">✅ Oui, j\'ai une balance</string>
|
||||
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
||||
<string name="ble_scanning">🔍 Scan en cours…</string>
|
||||
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
||||
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu'elle est allumée et à proximité, puis réessayez.</string>
|
||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu\'elle est allumée et à proximité, puis réessayez.</string>
|
||||
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
||||
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
||||
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
||||
<string name="ble_weight_received">Poids reçu — correspond-il à l'affichage de la balance ?</string>
|
||||
<string name="ble_weight_received">Poids reçu — correspond-il à l\'affichage de la balance ?</string>
|
||||
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
||||
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l'une d'elles pour la sélectionner.</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l\'une d\'elles pour la sélectionner.</string>
|
||||
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
||||
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
||||
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
||||
@@ -43,13 +43,13 @@
|
||||
<string name="install_downloading">Téléchargement en cours…</string>
|
||||
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
||||
<string name="install_installing">Installation en cours…</string>
|
||||
<string name="install_confirm_detail">Confirmez l'installation dans la boîte de dialogue ouverte.</string>
|
||||
<string name="install_confirm_detail">Confirmez l\'installation dans la boîte de dialogue ouverte.</string>
|
||||
<string name="install_success">Installé avec succès !</string>
|
||||
<string name="install_success_detail">L'app a été mise à jour.</string>
|
||||
<string name="install_success_detail">L\'app a été mise à jour.</string>
|
||||
<string name="install_error_download">Téléchargement échoué</string>
|
||||
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
||||
<string name="install_error_install">Installation échouée</string>
|
||||
<string name="install_perm_detail">Activez 'Installer des apps inconnues' dans les paramètres, puis revenez ici.</string>
|
||||
<string name="install_perm_detail">Activez \'Installer des apps inconnues\' dans les paramètres, puis revenez ici.</string>
|
||||
<string name="install_btn_retry">↩ Réessayer</string>
|
||||
<string name="btn_back">Retour</string>
|
||||
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
||||
@@ -58,13 +58,13 @@
|
||||
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
||||
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
||||
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
||||
<string name="wizard_server_ok_detail">Rapport d'erreurs actif — les échecs d'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||
<string name="wizard_server_ok_detail">Rapport d\'erreurs actif — les échecs d\'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Les erreurs n'atteindront pas GitHub Issues. Vérifiez l'URL saisie à l'étape 2.</string>
|
||||
<string name="wizard_server_error_detail">Les erreurs n\'atteindront pas GitHub Issues. Vérifiez l\'URL saisie à l\'étape 2.</string>
|
||||
<string name="setup_features_title">Fonctionnalités</string>
|
||||
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
||||
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d'inactivité.</string>
|
||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d\'inactivité.</string>
|
||||
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
||||
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
||||
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
|
||||
|
||||
Pour l'activer, entrez votre clé API Gemini gratuite.</string>
|
||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → "Obtenir une clé API"</string>
|
||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.\n\nPour l\'activer, entrez votre clé API Gemini gratuite.</string>
|
||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → \"Obtenir une clé API\"</string>
|
||||
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Liste de courses</string>
|
||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l'app Bring!.
|
||||
|
||||
Entrez vos identifiants Bring! pour activer l'intégration.</string>
|
||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l\'app Bring!.\n\nEntrez vos identifiants Bring! pour activer l\'intégration.</string>
|
||||
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
||||
<string name="setup_done_title">Tout est prêt !</string>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
||||
<string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l'URL manualmente.</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l'app.</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||
<string name="setup_exit_confirm">Esci</string>
|
||||
<string name="setup_exit_cancel">Continua</string>
|
||||
<string name="setup_step_back">← Indietro</string>
|
||||
@@ -28,28 +28,28 @@
|
||||
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
||||
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
||||
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall'elenco.</string>
|
||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall\'elenco.</string>
|
||||
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
||||
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
||||
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all'avvio.</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell'elenco per connettersi.</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L'app è stata aggiornata.</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita 'Installa app sconosciute' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
<string name="btn_back">Indietro</string>
|
||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||
@@ -60,11 +60,11 @@
|
||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l'URL inserito al passaggio 2.</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||
<string name="setup_features_title">Funzionalità</string>
|
||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
|
||||
|
||||
Per abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → "Ottieni chiave API"</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.\n\nPer abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → \"Ottieni chiave API\"</string>
|
||||
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l'app Bring!.
|
||||
|
||||
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
|
||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l\'app Bring!.\n\nInserisci le credenziali del tuo account Bring! per abilitare l\'integrazione.</string>
|
||||
<string name="setup_bring_email_hint">Email Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Password Bring!</string>
|
||||
<string name="setup_done_title">Tutto pronto!</string>
|
||||
|
||||
+209
-80
@@ -11,9 +11,23 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260606m">
|
||||
<!-- Core modules (auth, DOM helpers) -->
|
||||
<script src="assets/js/core/dom.js?v=20260603a"></script>
|
||||
<script src="assets/js/core/auth.js?v=20260603b"></script>
|
||||
<!-- ZBar WASM — lazy on kiosk WebView (OOM); eager elsewhere -->
|
||||
<script>
|
||||
(function () {
|
||||
var kioskWv = /; wv\)/.test(navigator.userAgent);
|
||||
if (kioskWv) return;
|
||||
document.write('<script src="assets/vendor/zbar/index.js?v=20260606a"><\/script>');
|
||||
document.write('<script>if(window.zbarWasm&&zbarWasm.setModuleArgs){zbarWasm.setModuleArgs({locateFile:function(f){return"assets/vendor/zbar/"+f}});}<\/script>');
|
||||
document.write('<script src="assets/vendor/zbar/polyfill.js?v=20260606a"><\/script>');
|
||||
})();
|
||||
</script>
|
||||
<!-- QuaggaJS — legacy last-resort only -->
|
||||
<script src="assets/vendor/quagga/quagga.min.js?v=20260603a"></script>
|
||||
<script>if(typeof Quagga==='undefined'){document.write('<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"><\\/script>');}</script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
<script type="module">
|
||||
// Lazy-load the embedding pipeline only when first needed.
|
||||
@@ -25,11 +39,27 @@
|
||||
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
|
||||
window._categoryPipelinePromise = (async () => {
|
||||
try {
|
||||
const { pipeline, env } = await import(
|
||||
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
|
||||
);
|
||||
// Keep WASM/model files in the browser cache; disable remote model check
|
||||
// to avoid CORS issues with the self-hosted instance.
|
||||
const localBase = 'assets/vendor/transformers/';
|
||||
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/';
|
||||
const modelProbe = localBase + 'Xenova/all-MiniLM-L6-v2/tokenizer.json';
|
||||
let pipeline, env;
|
||||
try {
|
||||
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
|
||||
} catch (_) {
|
||||
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
|
||||
}
|
||||
// Use bundled model files when present; otherwise HuggingFace CDN (no 404 spam)
|
||||
let localModels = false;
|
||||
try {
|
||||
const r = await fetch(modelProbe, { method: 'HEAD' });
|
||||
localModels = r.ok;
|
||||
} catch (_) {}
|
||||
if (localModels) {
|
||||
env.localModelPath = localBase;
|
||||
env.allowLocalModels = true;
|
||||
} else {
|
||||
env.allowLocalModels = false;
|
||||
}
|
||||
env.allowRemoteModels = true;
|
||||
env.useBrowserCache = true;
|
||||
const pipe = await pipeline(
|
||||
@@ -64,7 +94,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.42</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +107,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.35</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.42</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -96,6 +126,7 @@
|
||||
<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>
|
||||
</button>
|
||||
<button class="header-btn" onclick="startManualEntry()" title="Ajouter manuellement">✏️</button>
|
||||
<button class="header-btn header-scan-btn" id="btn-header-scan"
|
||||
title="Scansiona prodotto (tieni premuto per modalità spesa)" data-i18n-title="scan.hint">
|
||||
📷
|
||||
@@ -117,21 +148,6 @@
|
||||
<!-- ===== DASHBOARD ===== -->
|
||||
<section class="page active" id="page-dashboard">
|
||||
<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')">
|
||||
<span class="stat-icon">🛒</span>
|
||||
<span class="stat-value" id="stat-spesa">-</span>
|
||||
@@ -260,7 +276,7 @@
|
||||
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||
<div class="scan-ai-overlay-inner">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="scan-ai-overlay-label">Gemini Vision</span>
|
||||
<span class="scan-ai-overlay-label" data-i18n="scan.ai_overlay_label">Gemini Vision</span>
|
||||
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,8 +298,8 @@
|
||||
<!-- Scan errors -->
|
||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||
|
||||
<!-- AI retry button (shown after visual identification fails) -->
|
||||
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
|
||||
<!-- Manual AI identification (only when user taps — never automatic) -->
|
||||
<button class="btn btn-accent scan-ai-manual-btn" id="scan-ai-manual-btn" type="button" style="display:none" onclick="_triggerManualAiScan()" data-i18n="scan.ai_manual_btn">🤖 Identifica con AI</button>
|
||||
|
||||
<!-- Recent scans -->
|
||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||
@@ -374,7 +390,7 @@
|
||||
<form class="form" onsubmit="submitAdd(event)">
|
||||
<div class="form-group">
|
||||
<label data-i18n="add.location_label">📍 Dove lo metti?</label>
|
||||
<div class="location-selector">
|
||||
<div class="location-selector" id="location-selector-add">
|
||||
<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, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
|
||||
@@ -390,6 +406,7 @@
|
||||
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge qty-unit-muted" id="add-quantity-unit" aria-live="polite">pz</span>
|
||||
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
||||
<option value="pz">pz</option>
|
||||
<option value="conf">conf</option>
|
||||
@@ -421,8 +438,7 @@
|
||||
<p class="form-hint" id="add-vacuum-hint" style="display:none" data-i18n="add.vacuum_hint">La scadenza verrà estesa automaticamente</p>
|
||||
</div>
|
||||
<div class="form-group" id="add-expiry-section">
|
||||
<!-- Populated dynamically by showAddForm() -->
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-large btn-success full-width" data-i18n="add.submit">✅ Aggiungi</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -469,11 +485,14 @@
|
||||
</button>
|
||||
<div class="use-partial">
|
||||
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="use-quantity-unit" aria-live="polite">—</span>
|
||||
</div>
|
||||
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
||||
</div>
|
||||
@@ -626,6 +645,12 @@
|
||||
<option value="altro">📦 Altro</option>
|
||||
</select>
|
||||
</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-group flex-1">
|
||||
<label data-i18n="product.unit_label">📏 Unità di misura</label>
|
||||
@@ -694,6 +719,15 @@
|
||||
✨ Genera nuova ricetta
|
||||
</button>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -1195,14 +1229,7 @@
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:14px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-barcode-ai-fallback">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
|
||||
<p class="settings-hint" data-i18n="settings.camera.ai_manual_hint">Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1213,10 +1240,10 @@
|
||||
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.security.token_label">Token di accesso</label>
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
|
||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token API (API_TOKEN nel file .env). Il token viene salvato nel browser.</p>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
||||
@@ -1563,6 +1590,17 @@
|
||||
</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>
|
||||
<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 -->
|
||||
<div id="backup-list-container" style="margin-top:14px">
|
||||
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||
@@ -1669,18 +1707,6 @@
|
||||
</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) -->
|
||||
<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">
|
||||
@@ -1713,30 +1739,129 @@
|
||||
<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>
|
||||
|
||||
<!-- About & Support -->
|
||||
<div class="settings-section" style="margin-top:24px">
|
||||
<h3 class="settings-section-title" data-i18n="about.title">About</h3>
|
||||
<div class="settings-row" style="justify-content:space-between;align-items:center">
|
||||
<span class="settings-label" data-i18n="about.version">Version</span>
|
||||
<span id="about-version-label" class="settings-hint" style="font-family:monospace">—</span>
|
||||
</section>
|
||||
|
||||
<!-- ===== CONFIGURATION PAGE ===== -->
|
||||
<section class="page" id="page-config">
|
||||
<div class="page-header">
|
||||
<h2>🔧 Configuration</h2>
|
||||
</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 style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
|
||||
<button class="btn btn-outline full-width" onclick="reportBugManual()" id="btn-report-bug">
|
||||
🐛 <span data-i18n="about.report_bug">Segnala un problema</span>
|
||||
</button>
|
||||
<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>
|
||||
<div style="display:flex;gap:8px">
|
||||
<a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
|
||||
href="https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md"
|
||||
target="_blank" rel="noopener" data-i18n="about.changelog">Changelog</a>
|
||||
<a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
|
||||
href="https://github.com/dadaloop82/EverShelf"
|
||||
target="_blank" rel="noopener" data-i18n="about.github">GitHub</a>
|
||||
</div>
|
||||
|
||||
<div class="config-tab-content" id="config-tab-categories" style="display:none">
|
||||
<div class="settings-card">
|
||||
<h4>📁 Catégories</h4>
|
||||
<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 id="categories-list-container" style="margin-top:10px">
|
||||
<p class="settings-hint">Chargement…</p>
|
||||
</div>
|
||||
<div class="form-group mt-2">
|
||||
<label>➕ Nouvelle catégorie</label>
|
||||
<div class="barcode-input-row">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ===== GEMINI CHAT ===== -->
|
||||
<section class="page" id="page-chat">
|
||||
<div class="chat-container">
|
||||
@@ -1793,6 +1918,10 @@
|
||||
<span class="nav-icon">📋</span>
|
||||
<span class="nav-label" data-i18n="nav.log">Storico</span>
|
||||
</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">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-label" data-i18n="nav.settings">Config</span>
|
||||
@@ -1852,7 +1981,7 @@
|
||||
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p>
|
||||
<button class="btn btn-large btn-warning full-width" onclick="doRegenerateReplace()" data-i18n="recipes.regen_replace">🔄 Genera un'altra (scarta questa)</button>
|
||||
<button class="btn btn-large btn-success full-width mt-2" onclick="doRegenerateSave()" data-i18n="recipes.regen_save_new">💾 Salva nell'archivio e genera nuova</button>
|
||||
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="action.cancel">Annulla</button>
|
||||
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="confirm.cancel">Annulla</button>
|
||||
</div>
|
||||
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
|
||||
✅ Chiudi
|
||||
@@ -1962,6 +2091,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260518c"></script>
|
||||
</body>
|
||||
<script src="assets/js/app.js?v=20260606z"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.35",
|
||||
"start_url": "/evershelf/",
|
||||
"version": "1.7.42",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
"theme_color": "#2d5016",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "evershelf",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
|
||||
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
|
||||
"build": "npm run build:js && npm run build:css"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"version":"1.7.19","version_code":20}
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Audit: products depleted in last N days vs shopping list / Bring / smart shopping.
|
||||
* Usage: php scripts/audit-finished-shopping.php [days]
|
||||
*/
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/index.php';
|
||||
|
||||
$days = max(1, (int)($argv[1] ?? 30));
|
||||
$db = getDB();
|
||||
|
||||
// Recompute smart shopping fresh
|
||||
ob_start();
|
||||
smartShopping($db);
|
||||
$smartJson = ob_get_clean();
|
||||
$smartData = json_decode($smartJson, true);
|
||||
$smartItems = $smartData['items'] ?? [];
|
||||
$smartByPid = [];
|
||||
$smartByName = [];
|
||||
foreach ($smartItems as $si) {
|
||||
foreach ($si['variants'] ?? [] as $v) {
|
||||
$smartByPid[(int)$v['product_id']] = $si;
|
||||
}
|
||||
$smartByPid[(int)$si['product_id']] = $si;
|
||||
$sn = strtolower(trim($si['shopping_name'] ?? $si['name'] ?? ''));
|
||||
if ($sn !== '') $smartByName[$sn] = $si;
|
||||
}
|
||||
|
||||
// Bring list
|
||||
$bringNames = [];
|
||||
$bringSpecs = [];
|
||||
$auth = bringAuth();
|
||||
if ($auth && !empty($auth['bringListUUID'])) {
|
||||
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
|
||||
if ($listData && isset($listData['purchase'])) {
|
||||
foreach ($listData['purchase'] as $bi) {
|
||||
$k = mb_strtolower($bi['name'] ?? '');
|
||||
$bringNames[$k] = $bi['name'] ?? '';
|
||||
$bringSpecs[$k] = $bi['specification'] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal shopping list
|
||||
$shopNames = [];
|
||||
$shopRows = $db->query("SELECT name, specification FROM shopping_list")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($shopRows as $r) {
|
||||
$shopNames[mb_strtolower($r['name'])] = $r;
|
||||
}
|
||||
|
||||
// Products with zero stock, last activity in window
|
||||
$rows = $db->query("
|
||||
SELECT p.id, p.name, p.brand, p.shopping_name, p.unit,
|
||||
COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) AS stock_qty,
|
||||
(SELECT MAX(t.created_at) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0
|
||||
AND t.type IN ('out','waste','in')
|
||||
AND t.created_at >= datetime('now', '-{$days} days')) AS last_activity,
|
||||
(SELECT MAX(t.created_at) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0
|
||||
AND t.type IN ('out','waste')
|
||||
AND t.created_at >= datetime('now', '-{$days} days')) AS last_out,
|
||||
(SELECT COUNT(*) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')) AS use_count,
|
||||
(SELECT COUNT(*) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0 AND t.type = 'in') AS buy_count
|
||||
FROM products p
|
||||
WHERE COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001
|
||||
AND (SELECT MAX(t.created_at) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0
|
||||
AND t.type IN ('out','waste','in')
|
||||
AND t.created_at >= datetime('now', '-{$days} days')) IS NOT NULL
|
||||
ORDER BY last_activity DESC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$missing = [];
|
||||
$onList = [];
|
||||
$suppressed = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$pid = (int)$r['id'];
|
||||
$generic = trim($r['shopping_name'] ?? '') ?: computeShoppingName($r['name'], '', $r['brand'] ?? '');
|
||||
$bringKey = mb_strtolower(italianToBring($generic));
|
||||
$shopKey = mb_strtolower($generic);
|
||||
|
||||
$smart = $smartByPid[$pid] ?? $smartByName[mb_strtolower($generic)] ?? null;
|
||||
$onBring = isset($bringNames[$bringKey]);
|
||||
$onShop = isset($shopNames[$shopKey]);
|
||||
$inSmart = $smart !== null && ($smart['urgency'] ?? 'none') !== 'none';
|
||||
|
||||
$entry = [
|
||||
'id' => $pid,
|
||||
'name' => $r['name'],
|
||||
'brand' => $r['brand'],
|
||||
'generic' => $generic,
|
||||
'last_activity' => $r['last_activity'],
|
||||
'last_out' => $r['last_out'],
|
||||
'use_count' => (int)$r['use_count'],
|
||||
'buy_count' => (int)$r['buy_count'],
|
||||
'on_bring' => $onBring,
|
||||
'on_shop' => $onShop,
|
||||
'in_smart' => $inSmart,
|
||||
'smart_urgency' => $smart['urgency'] ?? null,
|
||||
'smart_reasons' => $smart['reasons'] ?? [],
|
||||
'bring_spec' => $bringSpecs[$bringKey] ?? '',
|
||||
];
|
||||
|
||||
if (!$onBring && !$onShop && !$inSmart) {
|
||||
$missing[] = $entry;
|
||||
} elseif ($onBring || $onShop) {
|
||||
$onList[] = $entry;
|
||||
} elseif ($inSmart) {
|
||||
$suppressed[] = $entry; // in smart but not synced yet
|
||||
} else {
|
||||
$missing[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== Audit prodotti esauriti (ultimi {$days} giorni) ===\n";
|
||||
echo 'Totale esauriti con attività recente: ' . count($rows) . "\n";
|
||||
echo 'Già in lista/Bring: ' . count($onList) . "\n";
|
||||
echo 'In smart shopping ma non in lista: ' . count($suppressed) . "\n";
|
||||
echo 'MANCANTI (né lista né Bring né smart): ' . count($missing) . "\n\n";
|
||||
|
||||
if ($missing) {
|
||||
echo "--- MANCANTI ---\n";
|
||||
foreach ($missing as $m) {
|
||||
echo sprintf(
|
||||
"- [%d] %s%s → generico: %s | usi:%d acquisti:%d | ultimo:%s\n",
|
||||
$m['id'],
|
||||
$m['name'],
|
||||
$m['brand'] ? " ({$m['brand']})" : '',
|
||||
$m['generic'],
|
||||
$m['use_count'],
|
||||
$m['buy_count'],
|
||||
$m['last_activity']
|
||||
);
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
if ($suppressed) {
|
||||
echo "--- IN SMART MA NON IN LISTA/BRING ---\n";
|
||||
foreach ($suppressed as $m) {
|
||||
echo sprintf(
|
||||
"- [%d] %s → %s | urgenza:%s | %s\n",
|
||||
$m['id'],
|
||||
$m['name'],
|
||||
$m['generic'],
|
||||
$m['smart_urgency'] ?? '?',
|
||||
implode(', ', $m['smart_reasons'] ?? [])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export JSON for fix script
|
||||
file_put_contents(
|
||||
__DIR__ . '/../data/audit_finished_missing.json',
|
||||
json_encode(['days' => $days, 'missing' => $missing, 'suppressed' => $suppressed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
);
|
||||
echo "\nReport salvato in data/audit_finished_missing.json\n";
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Backfill Bring!/shopping list for products depleted in the last N days.
|
||||
* Usage: php scripts/backfill-finished-shopping.php [days]
|
||||
*/
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/index.php';
|
||||
|
||||
$days = max(1, (int)($argv[1] ?? RECENTLY_EXHAUSTED_DAYS));
|
||||
$db = getDB();
|
||||
|
||||
$rows = $db->query("
|
||||
SELECT p.id, p.name, p.shopping_name
|
||||
FROM products p
|
||||
WHERE COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001
|
||||
AND (
|
||||
SELECT MAX(t.created_at) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')
|
||||
) >= datetime('now', '-{$days} days')
|
||||
ORDER BY (
|
||||
SELECT MAX(t.created_at) FROM transactions t
|
||||
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')
|
||||
) DESC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . "] Backfill {$days}d — " . count($rows) . " prodotti esauriti\n";
|
||||
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
foreach ($rows as $r) {
|
||||
$res = bringAddDepletedProduct($db, (int)$r['id']);
|
||||
if (!empty($res['added'])) {
|
||||
$added++;
|
||||
echo " + {$r['name']} → {$res['generic_name']}\n";
|
||||
} elseif (!empty($res['updated'])) {
|
||||
$updated++;
|
||||
echo " ~ {$r['name']} → {$res['generic_name']}\n";
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
ob_start();
|
||||
smartShopping($db);
|
||||
$json = ob_get_clean();
|
||||
$decoded = json_decode($json, true);
|
||||
if ($decoded && !empty($decoded['success'])) {
|
||||
$decoded['cached_at'] = date('c');
|
||||
$decoded['cached_ts'] = time();
|
||||
file_put_contents(
|
||||
__DIR__ . '/../data/smart_shopping_cache.json',
|
||||
json_encode($decoded, JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
bringSyncFull($db, false);
|
||||
$sync = json_decode(ob_get_clean(), true);
|
||||
$auto = $sync['auto_add'] ?? [];
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . "] bringAddDepleted: added={$added} updated={$updated} skipped={$skipped}\n";
|
||||
echo '[' . date('Y-m-d H:i:s') . '] bringSync auto_add: ' . json_encode($auto, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/** Delete all comments on open feature/enhancement backlog issues (English-only tracker policy). */
|
||||
declare(strict_types=1);
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
require_once __DIR__ . '/../api/lib/constants.php';
|
||||
|
||||
$token = _ghToken();
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function ghRequest(string $token, string $method, string $url, ?array $body = null): array {
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: token ' . $token,
|
||||
'Accept: application/vnd.github+json',
|
||||
'X-GitHub-Api-Version: 2022-11-28',
|
||||
'User-Agent: EverShelf-Triage/1.0',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($method === 'DELETE') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
} elseif ($method === 'GET') {
|
||||
// default
|
||||
}
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['code' => $code, 'body' => $raw];
|
||||
}
|
||||
|
||||
$issues = [122, 121, 120, 119, 118, 117, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($issues as $num) {
|
||||
$page = 1;
|
||||
while (true) {
|
||||
$url = 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments?per_page=100&page=$page";
|
||||
$r = ghRequest($token, 'GET', $url);
|
||||
if ($r['code'] !== 200) {
|
||||
fwrite(STDERR, "#$num list comments HTTP {$r['code']}\n");
|
||||
break;
|
||||
}
|
||||
$comments = json_decode($r['body'], true);
|
||||
if (!is_array($comments) || empty($comments)) {
|
||||
break;
|
||||
}
|
||||
foreach ($comments as $c) {
|
||||
$id = (int)($c['id'] ?? 0);
|
||||
if ($id <= 0) continue;
|
||||
$dr = ghRequest($token, 'DELETE', 'https://api.github.com/repos/' . GH_REPO . "/issues/comments/$id");
|
||||
if ($dr['code'] === 204) {
|
||||
$deleted++;
|
||||
echo "deleted comment $id on #$num\n";
|
||||
} else {
|
||||
fwrite(STDERR, "FAIL delete comment $id on #$num HTTP {$dr['code']}\n");
|
||||
}
|
||||
usleep(200000);
|
||||
}
|
||||
if (count($comments) < 100) break;
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done. Deleted $deleted comments.\n";
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
|
||||
*/
|
||||
if ($argc < 3) {
|
||||
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
|
||||
exit(1);
|
||||
}
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Fix ownership and permissions for EverShelf runtime directories.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WEB_USER="${WEB_USER:-www-data}"
|
||||
|
||||
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
|
||||
chmod 750 "${ROOT}/data" "${ROOT}/logs"
|
||||
chmod 640 "${ROOT}/.env" 2>/dev/null || true
|
||||
find "${ROOT}/data" -type f -exec chmod 660 {} \;
|
||||
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
|
||||
echo "Permissions updated for ${WEB_USER}"
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/** Reopen wrongly closed feature issues; close resolved auto-report bugs (English). */
|
||||
declare(strict_types=1);
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
require_once __DIR__ . '/../api/lib/constants.php';
|
||||
|
||||
$token = _ghToken();
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function ghApi(string $token, string $method, string $url, array $payload = []): array {
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: token ' . $token,
|
||||
'Accept: application/vnd.github+json',
|
||||
'X-GitHub-Api-Version: 2022-11-28',
|
||||
'User-Agent: EverShelf-Triage/1.0',
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
if ($method === 'PATCH') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
} elseif ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||
}
|
||||
|
||||
function comment(string $token, int $num, string $body): void {
|
||||
$r = ghApi($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments", ['body' => $body]);
|
||||
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK comment #$num\n" : "FAIL comment #$num\n";
|
||||
}
|
||||
|
||||
function closeIssue(string $token, int $num): void {
|
||||
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'closed']);
|
||||
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK close #$num\n" : "FAIL close #$num\n";
|
||||
}
|
||||
|
||||
function reopenIssue(string $token, int $num): void {
|
||||
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'open']);
|
||||
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK reopen #$num\n" : "FAIL reopen #$num\n";
|
||||
}
|
||||
|
||||
$reopen = [
|
||||
125 => "Reopened: **voice commands in cooking mode** are not implemented yet (only TTS readout exists). This was closed by mistake during bulk triage — the feature backlog should stay open until hands-free step navigation ships.",
|
||||
98 => "Reopened: **pin favourite products to the top of inventory** is not implemented yet (recipe favourites #124 are done, but product pinning is a separate request). Closed by mistake — keeping on the backlog.",
|
||||
];
|
||||
|
||||
foreach ($reopen as $num => $msg) {
|
||||
comment($token, $num, $msg);
|
||||
reopenIssue($token, $num);
|
||||
}
|
||||
|
||||
$bugs = [
|
||||
201 => 'Fixed in latest develop: `inventory_use` and `shopping_add` now retry on `SQLITE_BUSY` via `dbWithRetry()` (same pattern as #198).',
|
||||
202 => 'Fixed: Bring/internal `shopping_add` wrapped in `dbWithRetry()` to survive cron + PWA concurrent writes.',
|
||||
203 => 'Fixed: `smartShopping()` / `smartShoppingCached()` now call `set_time_limit(120)` so large pantries no longer hit the 30s PHP fatal.',
|
||||
204 => 'Fixed: same as #203 — smart shopping timeout caused HTTP 500; extended execution limit resolves the crash.',
|
||||
];
|
||||
|
||||
foreach ($bugs as $num => $msg) {
|
||||
comment($token, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._");
|
||||
closeIssue($token, $num);
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Download @xenova/transformers runtime + all-MiniLM-L6-v2 for offline category classification.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VENDOR="$ROOT/assets/vendor/transformers"
|
||||
MODEL="$VENDOR/Xenova/all-MiniLM-L6-v2"
|
||||
ONNX="$MODEL/onnx"
|
||||
BASE="https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main"
|
||||
|
||||
mkdir -p "$ONNX"
|
||||
|
||||
echo "→ transformers.min.js"
|
||||
curl -fsSL "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js" \
|
||||
-o "$VENDOR/transformers.min.js"
|
||||
|
||||
for f in config.json tokenizer.json tokenizer_config.json; do
|
||||
echo "→ $f"
|
||||
curl -fsSL "$BASE/$f" -o "$MODEL/$f"
|
||||
done
|
||||
|
||||
echo "→ onnx/model_quantized.onnx (~22 MB)"
|
||||
curl -fsSL "$BASE/onnx/model_quantized.onnx" -o "$ONNX/model_quantized.onnx"
|
||||
|
||||
chown -R www-data:www-data "$VENDOR" 2>/dev/null || true
|
||||
echo "Done. Model installed under assets/vendor/transformers/"
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* One-time merge of duplicate product records (same normalized name + compatible brand).
|
||||
* Opened-package splits remain as separate inventory rows on the canonical product.
|
||||
*
|
||||
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
$dbPath = __DIR__ . '/../data/evershelf.db';
|
||||
if (!file_exists($dbPath)) {
|
||||
fwrite(STDERR, "Database not found: $dbPath\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . $dbPath);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
function normName(string $name): string {
|
||||
return mb_strtolower(trim($name));
|
||||
}
|
||||
|
||||
function normBrand(string $brand): string {
|
||||
return mb_strtolower(trim($brand));
|
||||
}
|
||||
|
||||
function brandsCompatible(string $a, string $b): bool {
|
||||
$na = normBrand($a);
|
||||
$nb = normBrand($b);
|
||||
return $na === $nb || $na === '' || $nb === '';
|
||||
}
|
||||
|
||||
function productScore(PDO $db, int $id): float {
|
||||
$tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn();
|
||||
$inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn();
|
||||
return $tx * 10 + $inv;
|
||||
}
|
||||
|
||||
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
|
||||
$db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
|
||||
$db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$byName = [];
|
||||
foreach ($products as $p) {
|
||||
$key = normName($p['name']);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$byName[$key][] = $p;
|
||||
}
|
||||
|
||||
$merged = 0;
|
||||
foreach ($byName as $nameKey => $group) {
|
||||
if (count($group) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into compatible-brand clusters
|
||||
$clusters = [];
|
||||
foreach ($group as $p) {
|
||||
$placed = false;
|
||||
foreach ($clusters as &$cluster) {
|
||||
$ref = $cluster[0];
|
||||
if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) {
|
||||
$cluster[] = $p;
|
||||
$placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($cluster);
|
||||
if (!$placed) {
|
||||
$clusters[] = [$p];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($clusters as $cluster) {
|
||||
if (count($cluster) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id']));
|
||||
$keep = (int)$cluster[0]['id'];
|
||||
$keepName = $cluster[0]['name'];
|
||||
for ($i = 1; $i < count($cluster); $i++) {
|
||||
$drop = (int)$cluster[$i]['id'];
|
||||
echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n";
|
||||
if (!$dryRun) {
|
||||
mergeProducts($db, $keep, $drop);
|
||||
}
|
||||
$merged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo $dryRun
|
||||
? "Dry run: $merged merge(s) would be performed.\n"
|
||||
: "Done: $merged duplicate product(s) merged.\n";
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* One-time security migration: GitHub token → encrypted .env, optional API_TOKEN.
|
||||
*/
|
||||
require_once __DIR__ . '/../api/lib/env.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
|
||||
$envFile = dirname(__DIR__) . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
fwrite(STDERR, ".env not found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
||||
$vars = loadEnv();
|
||||
$changed = false;
|
||||
|
||||
// Migrate legacy XOR token from previous index.php if still in git history
|
||||
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
|
||||
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
|
||||
$legacyKey = 'D1sp3ns4!Ev3r#26';
|
||||
$encBin = hex2bin($legacyEnc);
|
||||
$plain = '';
|
||||
if ($encBin) {
|
||||
for ($i = 0; $i < strlen($encBin); $i++) {
|
||||
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
|
||||
}
|
||||
}
|
||||
if ($plain !== '' && str_starts_with($plain, 'github_')) {
|
||||
$newKey = bin2hex(random_bytes(16));
|
||||
$enc = evershelfEncryptGhToken($plain, $newKey);
|
||||
$lines[] = '';
|
||||
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
|
||||
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
|
||||
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
|
||||
$changed = true;
|
||||
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$lines[] = '';
|
||||
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
|
||||
$lines[] = 'API_TOKEN=' . $token;
|
||||
$changed = true;
|
||||
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
file_put_contents($envFile, implode("\n", $lines) . "\n");
|
||||
chmod($envFile, 0640);
|
||||
echo "Updated .env\n";
|
||||
} else {
|
||||
echo "No migration needed\n";
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Re-apply stock hints and 5% use-all rule to an archived recipe.
|
||||
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
|
||||
*/
|
||||
define('CRON_MODE', true);
|
||||
require __DIR__ . '/../api/index.php';
|
||||
|
||||
$id = (int)($argv[1] ?? 0);
|
||||
if ($id <= 0) {
|
||||
fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php <recipe_id>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
fwrite(STDERR, "Recipe {$id} not found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$recipe = json_decode($row['recipe_json'], true);
|
||||
if (!is_array($recipe)) {
|
||||
fwrite(STDERR, "Invalid recipe JSON for id {$id}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$stmt = $db->query("
|
||||
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
|
||||
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
|
||||
FROM inventory i
|
||||
JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0
|
||||
ORDER BY days_left ASC, p.name ASC
|
||||
");
|
||||
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
|
||||
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
|
||||
$upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
|
||||
|
||||
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
|
||||
foreach ($recipe['ingredients'] ?? [] as $ing) {
|
||||
if (empty($ing['from_pantry'])) {
|
||||
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
|
||||
continue;
|
||||
}
|
||||
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
|
||||
echo sprintf(
|
||||
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
|
||||
$ing['name'] ?? '?',
|
||||
$ing['qty'] ?? '?',
|
||||
$ing['stock_have'] ?? 0,
|
||||
$ing['stock_unit'] ?? '',
|
||||
$ing['stock_remain'] ?? 0,
|
||||
$ing['stock_unit'] ?? '',
|
||||
$useAll
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync translation files: ensure all locales have the same keys as it.json (reference)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent / 'translations'
|
||||
REF = 'it.json'
|
||||
LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json']
|
||||
|
||||
# New keys added across all locales (nested path -> value per locale)
|
||||
NEW_KEYS: dict[str, dict[str, str]] = {
|
||||
'dashboard.banner_prediction_confirmed': {
|
||||
'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni',
|
||||
'en': '✅ Confirmed — forecasts will recalculate from your next entries',
|
||||
'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet',
|
||||
'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements',
|
||||
'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros',
|
||||
},
|
||||
'dashboard.banner_anomaly_explain_fail': {
|
||||
'it': 'Impossibile ottenere spiegazione AI',
|
||||
'en': 'Could not get AI explanation',
|
||||
'de': 'KI-Erklärung konnte nicht abgerufen werden',
|
||||
'fr': 'Impossible d\'obtenir l\'explication IA',
|
||||
'es': 'No se pudo obtener la explicación de IA',
|
||||
},
|
||||
'dashboard.banner_anomaly_dismissed': {
|
||||
'it': 'Anomalia ignorata',
|
||||
'en': 'Anomaly dismissed',
|
||||
'de': 'Anomalie ignoriert',
|
||||
'fr': 'Anomalie ignorée',
|
||||
'es': 'Anomalía descartada',
|
||||
},
|
||||
'error.copy_failed': {
|
||||
'it': 'Copia negli appunti non riuscita',
|
||||
'en': 'Copy to clipboard failed',
|
||||
'de': 'Kopieren in die Zwischenablage fehlgeschlagen',
|
||||
'fr': 'Échec de la copie dans le presse-papiers',
|
||||
'es': 'Error al copiar al portapapeles',
|
||||
},
|
||||
'error.invalid_quantity': {
|
||||
'it': 'Quantità non valida',
|
||||
'en': 'Invalid quantity',
|
||||
'de': 'Ungültige Menge',
|
||||
'fr': 'Quantité invalide',
|
||||
'es': 'Cantidad no válida',
|
||||
},
|
||||
'dashboard.banner_finished_restore_prompt': {
|
||||
'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})',
|
||||
'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})',
|
||||
'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})',
|
||||
'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})',
|
||||
'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})',
|
||||
},
|
||||
'time.just_now': {
|
||||
'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora',
|
||||
},
|
||||
'time.seconds_ago': {
|
||||
'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s',
|
||||
},
|
||||
'time.minutes_ago': {
|
||||
'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min',
|
||||
},
|
||||
'time.hours_ago': {
|
||||
'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h',
|
||||
},
|
||||
'time.days_ago': {
|
||||
'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d',
|
||||
},
|
||||
'use.locations_short': {
|
||||
'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones',
|
||||
},
|
||||
'move.moved_simple': {
|
||||
'it': '📦 Spostato in {location}',
|
||||
'en': '📦 Moved to {location}',
|
||||
'de': '📦 Nach {location} verschoben',
|
||||
'fr': '📦 Déplacé vers {location}',
|
||||
'es': '📦 Movido a {location}',
|
||||
},
|
||||
'product.history_badge': {
|
||||
'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial',
|
||||
},
|
||||
'ai.conservation_hint': {
|
||||
'it': '🤖 AI: conserva in {location}',
|
||||
'en': '🤖 AI: store in {location}',
|
||||
'de': '🤖 KI: lagere in {location}',
|
||||
'fr': '🤖 IA : conserve dans {location}',
|
||||
'es': '🤖 IA: conserva en {location}',
|
||||
},
|
||||
'settings.kiosk_update_required': {
|
||||
'it': '⚠️ Aggiorna il kiosk per usare questa funzione',
|
||||
'en': '⚠️ Update the kiosk app to use this feature',
|
||||
'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen',
|
||||
'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction',
|
||||
'es': '⚠️ Actualiza la app kiosk para usar esta función',
|
||||
},
|
||||
'shopping.bring_names_migrated': {
|
||||
'it': '🔄 {n} nomi generalizzati in Bring!',
|
||||
'en': '🔄 {n} names generalized in Bring!',
|
||||
'de': '🔄 {n} Namen in Bring! verallgemeinert',
|
||||
'fr': '🔄 {n} noms généralisés dans Bring !',
|
||||
'es': '🔄 {n} nombres generalizados en Bring!',
|
||||
},
|
||||
'scan.mode_shopping_activated': {
|
||||
'it': '🛒 Modalità Spesa attivata!',
|
||||
'en': '🛒 Shopping mode activated!',
|
||||
'de': '🛒 Einkaufsmodus aktiviert!',
|
||||
'fr': '🛒 Mode courses activé !',
|
||||
'es': '🛒 ¡Modo compras activado!',
|
||||
},
|
||||
'settings.scale.discover_scanning': {
|
||||
'it': '🔍 Scansione rete locale per gateway bilancia…',
|
||||
'en': '🔍 Scanning local network for scale gateway…',
|
||||
'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…',
|
||||
'fr': '🔍 Recherche du gateway balance sur le réseau local…',
|
||||
'es': '🔍 Buscando pasarela de báscula en la red local…',
|
||||
},
|
||||
'settings.scale.discover_found': {
|
||||
'it': '✅ Gateway trovato: {url}{more}',
|
||||
'en': '✅ Gateway found: {url}{more}',
|
||||
'de': '✅ Gateway gefunden: {url}{more}',
|
||||
'fr': '✅ Gateway trouvé : {url}{more}',
|
||||
'es': '✅ Pasarela encontrada: {url}{more}',
|
||||
},
|
||||
'settings.scale.discover_not_found': {
|
||||
'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.',
|
||||
'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.',
|
||||
'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.',
|
||||
'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.',
|
||||
'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.',
|
||||
},
|
||||
'settings.scale.discover_failed': {
|
||||
'it': '❌ Ricerca fallita: {error}',
|
||||
'en': '❌ Discovery failed: {error}',
|
||||
'de': '❌ Suche fehlgeschlagen: {error}',
|
||||
'fr': '❌ Échec de la recherche : {error}',
|
||||
'es': '❌ Búsqueda fallida: {error}',
|
||||
},
|
||||
'settings.scale.discover_auto': {
|
||||
'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto',
|
||||
},
|
||||
'settings.scale.unknown_device': {
|
||||
'it': 'Dispositivo sconosciuto',
|
||||
'en': 'Unknown device',
|
||||
'de': 'Unbekanntes Gerät',
|
||||
'fr': 'Appareil inconnu',
|
||||
'es': 'Dispositivo desconocido',
|
||||
},
|
||||
'product.from_history': {
|
||||
'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)',
|
||||
},
|
||||
'recipes.ing_stock_line': {
|
||||
'it': 'Hai {have} · restano {remain} dopo l\'uso',
|
||||
'en': 'You have {have} · {remain} left after use',
|
||||
'de': 'Du hast {have} · {remain} bleiben nach Gebrauch',
|
||||
'fr': 'Vous avez {have} · il reste {remain} après usage',
|
||||
'es': 'Tienes {have} · quedan {remain} después del uso',
|
||||
},
|
||||
'recipes.ing_use_all_note': {
|
||||
'it': 'uso totale (<5% della confezione intera)',
|
||||
'en': 'use all (<5% of full package left)',
|
||||
'de': 'alles verwenden (<5% der Vollpackung)',
|
||||
'fr': 'tout utiliser (<5% du conditionnement entier)',
|
||||
'es': 'usar todo (<5% del envase completo)',
|
||||
},
|
||||
}
|
||||
|
||||
# fr/es gaps filled with proper translations (flat key -> value)
|
||||
FR_FILL: dict[str, str] = {
|
||||
'action.related_stock_title': 'Aussi à la maison',
|
||||
'dashboard.banner_expired_action_modify': 'Modifier',
|
||||
'dashboard.banner_expired_action_vacuum': 'Mettre sous vide',
|
||||
'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.',
|
||||
'scan.stock_in_pantry': 'Déjà à la maison :',
|
||||
'scanner.expiry_found': 'Date trouvée',
|
||||
'scanner.expiry_raw_label': 'Lu',
|
||||
'scanner.expiry_read_fail': 'Impossible de lire la date.',
|
||||
'settings.info.act_new_products': 'Nouveaux produits',
|
||||
'settings.info.act_restock': 'Réapprovisionnements',
|
||||
'settings.info.act_title': 'Activité mensuelle',
|
||||
'settings.info.act_tx_month': 'Mouvements',
|
||||
'settings.info.act_tx_year': 'Mouvements annuels',
|
||||
'settings.info.act_use': 'Utilisations',
|
||||
'settings.info.ai_calls': 'Appels',
|
||||
'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.',
|
||||
'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système',
|
||||
'settings.info.ai_title': 'Gemini AI — Utilisation des tokens',
|
||||
'settings.info.bring_days': 'jeton expire dans {n} jours',
|
||||
'settings.info.bring_expired': 'jeton expiré',
|
||||
'settings.info.by_action': 'Répartition par fonction',
|
||||
'settings.info.by_model': 'Répartition par modèle',
|
||||
'settings.info.cache_entries': 'produits',
|
||||
'settings.info.calls_unit': 'appels',
|
||||
'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.',
|
||||
'settings.info.currency_title': 'Devise',
|
||||
'settings.info.db_size': 'Base de données',
|
||||
'settings.info.est_cost': 'Coût est.',
|
||||
'settings.info.input_tok': 'Tokens entrée',
|
||||
'settings.info.inv_active': 'Actifs',
|
||||
'settings.info.inv_expired': 'Expirés',
|
||||
'settings.info.inv_expiring': 'Expirent (7j)',
|
||||
'settings.info.inv_finished': 'Terminés',
|
||||
'settings.info.inv_products': 'Produits totaux',
|
||||
'settings.info.inv_title': 'Inventaire',
|
||||
'settings.info.last_backup': 'Dernière sauvegarde',
|
||||
'settings.info.loading': 'Chargement…',
|
||||
'settings.info.log_level': 'Niveau de log',
|
||||
'settings.info.log_size': 'Logs',
|
||||
'settings.info.output_tok': 'Tokens sortie',
|
||||
'settings.info.price_cache': 'Cache prix',
|
||||
'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
|
||||
'settings.info.system_title': 'Système',
|
||||
'settings.info.tab': 'Info',
|
||||
'settings.info.total_tokens': 'Tokens totaux',
|
||||
'settings.info.year_label': 'Année {year}',
|
||||
'settings.tab_general': 'Général',
|
||||
'settings.tts.test_sound_btn': '🔔 Test sonore',
|
||||
'shopping.pantry_hint': 'Déjà à la maison : {qty}',
|
||||
'startup.check_db_legacy': 'Ancienne BD (dispensa.db)',
|
||||
'startup.check_scale': 'Passerelle balance',
|
||||
'startup.check_tts': 'URL synthèse vocale',
|
||||
'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :',
|
||||
'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.',
|
||||
'toast.vacuum_sealed': '{name} enregistré sous vide',
|
||||
}
|
||||
|
||||
ES_FILL = {
|
||||
'action.related_stock_title': 'También en casa',
|
||||
'dashboard.banner_expired_action_modify': 'Editar',
|
||||
'dashboard.banner_expired_action_vacuum': 'Poner al vacío',
|
||||
'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.',
|
||||
'scan.stock_in_pantry': 'Ya en despensa:',
|
||||
'scanner.expiry_found': 'Fecha encontrada',
|
||||
'scanner.expiry_raw_label': 'Leído',
|
||||
'scanner.expiry_read_fail': 'No se puede leer la fecha.',
|
||||
'settings.info.act_new_products': 'Productos nuevos',
|
||||
'settings.info.act_restock': 'Reabastecimientos',
|
||||
'settings.info.act_title': 'Actividad mensual',
|
||||
'settings.info.act_tx_month': 'Movimientos',
|
||||
'settings.info.act_tx_year': 'Movimientos anuales',
|
||||
'settings.info.act_use': 'Usos',
|
||||
'settings.info.ai_calls': 'Llamadas',
|
||||
'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.',
|
||||
'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema',
|
||||
'settings.info.ai_title': 'Gemini AI — Uso de tokens',
|
||||
'settings.info.bring_days': 'token expira en {n} días',
|
||||
'settings.info.bring_expired': 'token expirado',
|
||||
'settings.info.by_action': 'Desglose por función',
|
||||
'settings.info.by_model': 'Desglose por modelo',
|
||||
'settings.info.cache_entries': 'productos',
|
||||
'settings.info.calls_unit': 'llamadas',
|
||||
'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.',
|
||||
'settings.info.currency_title': 'Moneda',
|
||||
'settings.info.db_size': 'Base de datos',
|
||||
'settings.info.est_cost': 'Coste est.',
|
||||
'settings.info.input_tok': 'Tokens de entrada',
|
||||
'settings.info.inv_active': 'Activos',
|
||||
'settings.info.inv_expired': 'Caducados',
|
||||
'settings.info.inv_expiring': 'Caducan (7d)',
|
||||
'settings.info.inv_finished': 'Agotados',
|
||||
'settings.info.inv_products': 'Productos totales',
|
||||
'settings.info.inv_title': 'Inventario',
|
||||
'settings.info.last_backup': 'Última copia',
|
||||
'settings.info.loading': 'Cargando…',
|
||||
'settings.info.log_level': 'Nivel de log',
|
||||
'settings.info.log_size': 'Logs',
|
||||
'settings.info.output_tok': 'Tokens de salida',
|
||||
'settings.info.price_cache': 'Caché de precios',
|
||||
'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
|
||||
'settings.info.system_title': 'Sistema',
|
||||
'settings.info.tab': 'Info',
|
||||
'settings.info.total_tokens': 'Tokens totales',
|
||||
'settings.info.year_label': 'Año {year}',
|
||||
'settings.tab_general': 'General',
|
||||
'settings.tts.test_sound_btn': '🔔 Prueba de sonido',
|
||||
'shopping.pantry_hint': 'Ya en casa: {qty}',
|
||||
'startup.check_db_legacy': 'BD antigua (dispensa.db)',
|
||||
'startup.check_scale': 'Pasarela báscula',
|
||||
'startup.check_tts': 'URL texto a voz',
|
||||
'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:',
|
||||
'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.',
|
||||
'toast.vacuum_sealed': '{name} guardado al vacío',
|
||||
}
|
||||
|
||||
|
||||
def flatten(obj: dict, prefix: str = '') -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for k, v in obj.items():
|
||||
key = f'{prefix}.{k}' if prefix else k
|
||||
if isinstance(v, dict):
|
||||
out.update(flatten(v, key))
|
||||
else:
|
||||
out[key] = v
|
||||
return out
|
||||
|
||||
|
||||
def set_nested(root: dict, dotted: str, value: str) -> None:
|
||||
parts = dotted.split('.')
|
||||
d = root
|
||||
for p in parts[:-1]:
|
||||
d = d.setdefault(p, {})
|
||||
d[parts[-1]] = value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ref = json.loads((ROOT / REF).read_text(encoding='utf-8'))
|
||||
ref_flat = flatten(ref)
|
||||
en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8')))
|
||||
|
||||
for fname in LOCALES:
|
||||
lang = fname.replace('.json', '')
|
||||
path = ROOT / fname
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
flat = flatten(data)
|
||||
|
||||
# Fill missing keys from reference (Italian text as last resort via en)
|
||||
for key, ref_val in ref_flat.items():
|
||||
if key not in flat:
|
||||
if lang == 'fr' and key in FR_FILL:
|
||||
val = FR_FILL[key]
|
||||
elif lang == 'es' and key in ES_FILL:
|
||||
val = ES_FILL[key]
|
||||
elif lang == 'en':
|
||||
val = en_flat.get(key, ref_val)
|
||||
else:
|
||||
val = en_flat.get(key, ref_val)
|
||||
set_nested(data, key, val)
|
||||
flat[key] = val
|
||||
|
||||
# Inject new keys
|
||||
for key, per_lang in NEW_KEYS.items():
|
||||
set_nested(data, key, per_lang[lang if lang in per_lang else 'en'])
|
||||
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(f'Updated {fname}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Full Bring! sync: recompute smart shopping, migrate names, dedupe generics,
|
||||
* fix specs, remove obsolete items, add missing critical/high.
|
||||
*
|
||||
* Usage: php scripts/sync-shopping-bring.php
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/index.php';
|
||||
|
||||
$db = getDB();
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . "] Starting full Bring! sync…\n";
|
||||
|
||||
ob_start();
|
||||
bringSyncFull($db, true);
|
||||
$json = ob_get_clean();
|
||||
$result = json_decode($json, true);
|
||||
|
||||
if (!$result || empty($result['success'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . ($result['error'] ?? $json) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Smart items: ' . ($result['smart_items'] ?? '?') . "\n";
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Migrate: ' . json_encode($result['migrate'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Dedupe: ' . json_encode($result['dedupe'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Specs: ' . json_encode($result['specs'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Cleanup: ' . json_encode($result['cleanup'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Auto-add: ' . json_encode($result['auto_add'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
if (!empty($result['dedupe_final'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Dedupe (final): ' . json_encode($result['dedupe_final'], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
}
|
||||
if (!empty($result['cache_restored'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Cache restored: ' . $result['cache_restored'] . " items\n";
|
||||
}
|
||||
echo '[' . date('Y-m-d H:i:s') . "] Done.\n";
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Triage resolved auto-report bugs only (English comments).
|
||||
* Feature/enhancement backlog issues are never bulk-closed here.
|
||||
* Usage: php scripts/triage-open-issues.php [--dry-run]
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
require_once __DIR__ . '/../api/lib/constants.php';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv ?? [], true);
|
||||
$repo = GH_REPO;
|
||||
$token = _ghToken();
|
||||
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function ghApi(string $token, string $method, string $url, array $payload = []): array {
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: token ' . $token,
|
||||
'Accept: application/vnd.github+json',
|
||||
'X-GitHub-Api-Version: 2022-11-28',
|
||||
'User-Agent: EverShelf-Triage/1.0',
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
if ($method === 'PATCH') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
} elseif ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||
}
|
||||
|
||||
function commentIssue(string $token, string $repo, int $num, string $body, bool $dryRun): bool {
|
||||
if ($dryRun) {
|
||||
echo "[dry-run] comment #$num\n";
|
||||
return true;
|
||||
}
|
||||
$r = ghApi($token, 'POST', "https://api.github.com/repos/$repo/issues/$num/comments", ['body' => $body]);
|
||||
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||
echo "OK comment #$num\n";
|
||||
return true;
|
||||
}
|
||||
fwrite(STDERR, "FAIL comment #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
function closeIssue(string $token, string $repo, int $num, bool $dryRun): bool {
|
||||
if ($dryRun) {
|
||||
echo "[dry-run] close #$num\n";
|
||||
return true;
|
||||
}
|
||||
$r = ghApi($token, 'PATCH', "https://api.github.com/repos/$repo/issues/$num", ['state' => 'closed']);
|
||||
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||
echo "OK close #$num\n";
|
||||
return true;
|
||||
}
|
||||
fwrite(STDERR, "FAIL close #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
$bugs = [
|
||||
198 => 'Fixed in develop: `PRAGMA busy_timeout` raised to 10s and `dbWithRetry()` on `updateInventory` retries SQLITE_BUSY when cron and PWA write in parallel.',
|
||||
199 => 'Duplicate of #198 — same event (`inventory_update` → database locked). Fix: retry + longer busy_timeout.',
|
||||
196 => 'Fixed in v1.7.38+: `saveProduct` handles duplicate barcodes (merge or 409 JSON) instead of HTTP 500.',
|
||||
197 => 'PWA side-effect of PHP crash #196 — fixed with duplicate barcode handling in `saveProduct`.',
|
||||
195 => 'Fixed: `EverLog::request()` always receives strings — `(string)($_SERVER[\'REQUEST_METHOD\'] ?? \'GET\')`.',
|
||||
193 => 'Same root cause as #195 (TypeError when method was null from CLI).',
|
||||
194 => 'Fixed: `_applySpesaScanUI` referenced `currentPage` → corrected to `_currentPageId`.',
|
||||
192 => 'Fixed: TDZ on `enriched` in `renderShoppingItems`.',
|
||||
191 => 'Fixed: TDZ on `setProgress` / `barEl` in `_runStartupCheck`.',
|
||||
134 => 'Auto-report for non-writable Docker volume. Mitigations: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile chown.',
|
||||
184 => 'Related to #134: SQLite readonly when `data/` is not writable.',
|
||||
];
|
||||
|
||||
foreach ($bugs as $num => $msg) {
|
||||
commentIssue($token, $repo, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._", $dryRun);
|
||||
closeIssue($token, $repo, $num, $dryRun);
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
+113
-19
@@ -32,6 +32,7 @@
|
||||
"reset_default": "↺ Standard wiederherstellen",
|
||||
"save_info": "💾 Info speichern",
|
||||
"retry": "🔄 Erneut versuchen",
|
||||
"next": "Weiter →",
|
||||
"yes_short": "Ja",
|
||||
"no_short": "Nein"
|
||||
},
|
||||
@@ -143,8 +144,10 @@
|
||||
"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_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_check": "Kannst du nachschauen?",
|
||||
"banner_finished_action_restore": "{qty} {unit} wiederherstellen",
|
||||
"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_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
||||
@@ -164,7 +167,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Gemini um eine Erklärung bitten",
|
||||
"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": {
|
||||
"title": "Vorrat",
|
||||
@@ -193,6 +200,7 @@
|
||||
"mode_shopping": "🛒 Einkaufsmodus",
|
||||
"mode_shopping_end": "✅ Einkauf beenden",
|
||||
"spesa_btn": "🛒 Einkauf",
|
||||
"spesa_camera_hint": "Barcode mit der Kamera erfassen. Kein Barcode? Tippe unten auf «Mit KI erkennen».",
|
||||
"zoom": "Zoom",
|
||||
"tab_barcode": "Barcode",
|
||||
"tab_name": "Name",
|
||||
@@ -228,22 +236,38 @@
|
||||
"status_confirmed": "Bestätigt!",
|
||||
"status_parallel": "Kombinierter Scan aktiv...",
|
||||
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
|
||||
"status_digit_ocr": "Lese Ziffern unter dem Barcode...",
|
||||
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"method_local_ocr": "Ziffern-OCR",
|
||||
"method_zbar": "ZBar",
|
||||
"local_ocr_found": "Code aus Ziffern: {code}",
|
||||
"ai_fallback_searching": "KI identifiziert Produkt...",
|
||||
"ai_fallback_found": "Produkt von KI erkannt",
|
||||
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
|
||||
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
|
||||
"ai_overlay_label": "Gemini Vision",
|
||||
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
|
||||
"ai_retry_btn": "Mit KI erneut versuchen",
|
||||
"ai_manual_btn": "🤖 Mit KI erkennen",
|
||||
"ai_not_recognized": "KI: Produkt nicht erkannt. Erneut versuchen oder manuell eingeben.",
|
||||
"ai_match_title": "Produkt von KI erkannt",
|
||||
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
|
||||
"ai_match_existing": "Mogliche Treffer in der Vorratskammer",
|
||||
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
|
||||
"ai_match_use_btn": "Dieses nutzen",
|
||||
"ai_match_add_btn": "\"{name}\" hinzufugen",
|
||||
"ai_detected_label": "KI erkannt"
|
||||
"ai_match_existing": "Aktuell in der Vorratskammer",
|
||||
"ai_match_finished": "Aufgebraucht / leer",
|
||||
"ai_match_catalog": "Im Katalog (ohne Bestand)",
|
||||
"ai_match_finished_badge": "aufgebraucht",
|
||||
"ai_match_finished_hint": "Produkt aufgebraucht — Menge auffüllen",
|
||||
"ai_match_merged_existing": "Mit vorhandenem Katalogprodukt verknüpft",
|
||||
"ai_match_none": "Keine ähnlichen Produkte — du kannst ein neues anlegen.",
|
||||
"ai_match_use_btn": "Nutzen",
|
||||
"ai_match_create_btn": "➕ Neu anlegen: {name}",
|
||||
"ai_match_add_btn": "{name} hinzufugen",
|
||||
"ai_match_action_hint": "Tippe auf den gruenen Button, um dieses Produkt hinzuzufuegen",
|
||||
"ai_match_or_similar": "Oder waehle ein aehnliches Produkt:",
|
||||
"ai_detected_label": "KI erkannt",
|
||||
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
@@ -282,8 +306,9 @@
|
||||
"hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
|
||||
"scan_expiry_title": "📷 Ablaufdatum scannen",
|
||||
"product_added": "✅ {name} hinzugefügt!{qty}",
|
||||
"duplicate_recent_confirm": "Du hast «{name}» gerade hinzugefuegt ({when}).\n\nDie Menge ist bereits {total}.\n\nUm {qty} erhoehen?",
|
||||
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
|
||||
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
|
||||
"history_badge_tip": "Durchschnitt der letzten {n} Einträge — wird bei jedem Kauf aktualisiert",
|
||||
"vacuum_question": "Vakuumiert?",
|
||||
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
|
||||
},
|
||||
@@ -316,14 +341,29 @@
|
||||
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
||||
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
|
||||
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
|
||||
"disambiguation_one_conf": "<strong>1 Packung</strong> aufgebraucht ({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!",
|
||||
"use_all_confirm_title": "✅ Alles aufbrauchen",
|
||||
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
|
||||
"use_all_confirm_btn": "✅ Ja, aufgebraucht",
|
||||
"throw_all_confirm_title": "🗑️ Alles 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": {
|
||||
"title_new": "Neues Produkt",
|
||||
@@ -333,6 +373,8 @@
|
||||
"name_label": "🏷️ Produktname *",
|
||||
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
|
||||
"brand_label": "🏢 Marke",
|
||||
"allergens_label": "Allergene:",
|
||||
"ingredients_summary": "📋 Zutaten",
|
||||
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
|
||||
"category_label": "📂 Kategorie",
|
||||
"unit_label": "📏 Maßeinheit",
|
||||
@@ -363,7 +405,9 @@
|
||||
"weight_label": "Gewicht",
|
||||
"origin_label": "Herkunft",
|
||||
"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": {
|
||||
"title": "📦 Alle Produkte",
|
||||
@@ -416,7 +460,18 @@
|
||||
"load_error": "Fehler beim Laden",
|
||||
"favorite": "Zu Favoriten hinzufügen",
|
||||
"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": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
@@ -476,6 +531,18 @@
|
||||
"item_removed": "✅ {name} von der Liste entfernt!",
|
||||
"urgency_spec_critical": "⚡ Dringend",
|
||||
"urgency_spec_high": "🟠 Bald",
|
||||
"urgency_spec_medium": "🟡 Bald",
|
||||
"urgency_spec_low": "🔵 Prognose",
|
||||
"family_sibling_title": "Ähnlich in {location}",
|
||||
"family_sibling_check": "Prüfen: {name}",
|
||||
"family_sibling_stock": "Du solltest haben: {qty}",
|
||||
"family_sibling_location": "Standort: {location}",
|
||||
"family_sibling_qty": "Menge: {qty}",
|
||||
"family_sibling_purchased": "Gekauft am {date}",
|
||||
"family_sibling_question": "Ist die Menge noch korrekt?",
|
||||
"family_sibling_prompt": "Du hast auch {name}: {qty} auf Lager. Menge bestätigen?",
|
||||
"family_sibling_yes": "Ja, passt",
|
||||
"family_sibling_no": "Nein, aktualisieren",
|
||||
"bring_add_n": "{n} zu Bring! hinzufügen",
|
||||
"bring_add_selected": "Ausgewählte zu Bring! hinzufügen",
|
||||
"bring_adding": "Wird hinzugefügt...",
|
||||
@@ -503,6 +570,7 @@
|
||||
"remove_error": "Fehler beim Entfernen",
|
||||
"btn_fetch_prices": "Preise suchen",
|
||||
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
||||
"price_total_short": "geschätzte Ausgaben",
|
||||
"price_loading": "Preise werden gesucht…",
|
||||
"price_not_found": "Preis n/v",
|
||||
"suggest_loading": "Analyse läuft...",
|
||||
@@ -512,7 +580,8 @@
|
||||
"priority_low": "Niedrig",
|
||||
"smart_last_update": "Aktualisiert {time}",
|
||||
"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": {
|
||||
"title": "🤖 KI-Identifikation",
|
||||
@@ -523,7 +592,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "📒 Verlauf",
|
||||
@@ -689,7 +759,8 @@
|
||||
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
||||
"detect_btn": "🔄 Kameras erkennen",
|
||||
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
|
||||
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini."
|
||||
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini.",
|
||||
"ai_manual_hint": "Wenn der Barcode nicht lesbar ist, nutze den Button «Mit KI erkennen» unter der Kamera. Erfordert konfiguriertes Gemini."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 HTTPS-Zertifikat",
|
||||
@@ -778,7 +849,13 @@
|
||||
"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_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) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — 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) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — 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": {
|
||||
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
||||
@@ -965,7 +1042,8 @@
|
||||
"sensor_copied": "YAML in die Zwischenablage kopiert!",
|
||||
"save_btn": "HA-Einstellungen speichern",
|
||||
"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": {
|
||||
"today": "HEUTE",
|
||||
@@ -1039,6 +1117,7 @@
|
||||
"finished_all": "📤 {name} aufgebraucht!",
|
||||
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
|
||||
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
|
||||
"ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt",
|
||||
"appliance_added": "Gerät hinzugefügt",
|
||||
"item_added": "{name} hinzugefügt"
|
||||
},
|
||||
@@ -1110,7 +1189,9 @@
|
||||
"offline_ops_pending": "{n} Aktionen ausstehend",
|
||||
"offline_synced": "{n} Aktionen synchronisiert",
|
||||
"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": {
|
||||
@@ -1232,7 +1313,8 @@
|
||||
"stay_btn": "Nein, bleibt in {location}",
|
||||
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
||||
"vacuum_restore": "Vakuum wiederherstellen",
|
||||
"vacuum_seal_rest": "Rest vakuumieren"
|
||||
"vacuum_seal_rest": "Rest vakuumieren",
|
||||
"moved_simple": "📦 Nach {location} verschoben"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unverarbeitet",
|
||||
@@ -1467,7 +1549,12 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Monatsstatistik",
|
||||
@@ -1480,5 +1567,12 @@
|
||||
"top_used": "meistbenutzt",
|
||||
"top_cats": "Hauptkategorien",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-19
@@ -32,6 +32,7 @@
|
||||
"reset_default": "↺ Reset to default",
|
||||
"save_info": "💾 Save information",
|
||||
"retry": "🔄 Retry",
|
||||
"next": "Next →",
|
||||
"yes_short": "Yes",
|
||||
"no_short": "No"
|
||||
},
|
||||
@@ -143,8 +144,10 @@
|
||||
"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_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_check": "Can you check?",
|
||||
"banner_finished_action_restore": "Restore {qty} {unit}",
|
||||
"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_untracked_title": "stock not recorded as an entry",
|
||||
@@ -164,7 +167,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Ask Gemini for an explanation",
|
||||
"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": {
|
||||
"title": "Pantry",
|
||||
@@ -193,6 +200,7 @@
|
||||
"mode_shopping": "🛒 Shopping Mode",
|
||||
"mode_shopping_end": "✅ End shopping",
|
||||
"spesa_btn": "🛒 Shopping",
|
||||
"spesa_camera_hint": "Point the camera at the barcode. No barcode? Tap «Identify with AI» below.",
|
||||
"zoom": "Zoom",
|
||||
"tab_barcode": "Barcode",
|
||||
"tab_name": "Name",
|
||||
@@ -228,22 +236,38 @@
|
||||
"status_confirmed": "Confirmed!",
|
||||
"status_parallel": "Using combined scan methods...",
|
||||
"status_ocr_searching": "Reading the barcode digits...",
|
||||
"status_digit_ocr": "Reading numbers below the barcode...",
|
||||
"status_ai_visual_searching": "Now trying to recognize the product...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"method_local_ocr": "Digit OCR",
|
||||
"method_zbar": "ZBar",
|
||||
"local_ocr_found": "Code from digits: {code}",
|
||||
"ai_fallback_searching": "AI identifying product...",
|
||||
"ai_fallback_found": "Product identified by AI",
|
||||
"ai_fallback_not_found": "AI: product not recognized",
|
||||
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
|
||||
"ai_overlay_label": "Gemini Vision",
|
||||
"ai_overlay_msg": "Gemini Vision is analyzing the product...",
|
||||
"ai_retry_btn": "Retry with AI",
|
||||
"ai_manual_btn": "🤖 Identify with AI",
|
||||
"ai_not_recognized": "AI could not recognize the product. Try again or add manually.",
|
||||
"ai_match_title": "Product recognized by AI",
|
||||
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
|
||||
"ai_match_existing": "Possible pantry matches",
|
||||
"ai_match_none": "No similar pantry products found.",
|
||||
"ai_match_use_btn": "Use this",
|
||||
"ai_match_add_btn": "Add \"{name}\"",
|
||||
"ai_detected_label": "AI detected"
|
||||
"ai_match_existing": "Currently in pantry",
|
||||
"ai_match_finished": "Finished / depleted",
|
||||
"ai_match_catalog": "In catalog (no stock)",
|
||||
"ai_match_finished_badge": "depleted",
|
||||
"ai_match_finished_hint": "Finished product — restock the quantity",
|
||||
"ai_match_merged_existing": "Linked to an existing catalog product",
|
||||
"ai_match_none": "No similar products found — you can create a new one.",
|
||||
"ai_match_use_btn": "Use",
|
||||
"ai_match_create_btn": "➕ Create new: {name}",
|
||||
"ai_match_add_btn": "Add {name}",
|
||||
"ai_match_action_hint": "Tap the green button to add this product",
|
||||
"ai_match_or_similar": "Or pick a similar product:",
|
||||
"ai_detected_label": "AI detected",
|
||||
"mode_shopping_activated": "🛒 Shopping mode activated!"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
@@ -282,8 +306,9 @@
|
||||
"hint_modify": "📝 You can change the date or scan it with the camera",
|
||||
"scan_expiry_title": "📷 Scan Expiry Date",
|
||||
"product_added": "✅ {name} added!{qty}",
|
||||
"duplicate_recent_confirm": "You just added \"{name}\" ({when}).\n\nQuantity is already {total}.\n\nIncrease it by {qty}?",
|
||||
"suffix_freezer_vacuum": "(freezer + vacuum sealed)",
|
||||
"history_badge_tip": "Average from {n} previous entries",
|
||||
"history_badge_tip": "Average of the last {n} entries — updates with each new purchase",
|
||||
"vacuum_question": "Vacuum sealed?",
|
||||
"vacuum_saved": "🔒 Vacuum sealed!"
|
||||
},
|
||||
@@ -316,14 +341,29 @@
|
||||
"toast_bring": "🛒 Product finished → added to Bring!",
|
||||
"toast_opened_finished": "🔓 Opened package of {name} finished!",
|
||||
"disambiguation_hint": "What do you mean by \"all done\"?",
|
||||
"disambiguation_one_conf": "Finished <strong>1 package</strong> ({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!",
|
||||
"use_all_confirm_title": "✅ Finish everything",
|
||||
"use_all_confirm_msg": "Confirm that you have finished the product:",
|
||||
"use_all_confirm_btn": "✅ Yes, finished",
|
||||
"throw_all_confirm_title": "🗑️ Discard everything",
|
||||
"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": {
|
||||
"title_new": "New Product",
|
||||
@@ -333,6 +373,8 @@
|
||||
"name_label": "🏷️ Product Name *",
|
||||
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
|
||||
"brand_label": "🏢 Brand",
|
||||
"allergens_label": "Allergens:",
|
||||
"ingredients_summary": "📋 Ingredients",
|
||||
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
|
||||
"category_label": "📂 Category",
|
||||
"unit_label": "📏 Unit of measure",
|
||||
@@ -363,7 +405,9 @@
|
||||
"weight_label": "Weight",
|
||||
"origin_label": "Origin",
|
||||
"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": {
|
||||
"title": "📦 All Products",
|
||||
@@ -395,6 +439,11 @@
|
||||
"regen_save_new": "💾 Save to archive & generate a new one",
|
||||
"close_btn": "✅ Close",
|
||||
"ingredients_title": "🧾 Ingredients",
|
||||
"shopping_suggestions_intro": "For an alternative version you'd need (not in pantry — optional):",
|
||||
"shopping_suggestions_add": "Add to shopping list",
|
||||
"shopping_suggestions_added": "Added to shopping list",
|
||||
"unit_for_input": "Unit of measure",
|
||||
"enter_in": "Enter value in",
|
||||
"tools_title": "Equipment needed",
|
||||
"steps_title": "👨🍳 Steps",
|
||||
"no_steps": "No steps available",
|
||||
@@ -416,7 +465,18 @@
|
||||
"load_error": "Loading error",
|
||||
"favorite": "Add to 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": {
|
||||
"title": "🛒 Shopping List",
|
||||
@@ -476,6 +536,18 @@
|
||||
"item_removed": "✅ {name} removed from list!",
|
||||
"urgency_spec_critical": "⚡ Urgent",
|
||||
"urgency_spec_high": "🟠 Soon",
|
||||
"urgency_spec_medium": "🟡 Soon",
|
||||
"urgency_spec_low": "🔵 Forecast",
|
||||
"family_sibling_title": "Similar in {location}",
|
||||
"family_sibling_check": "Check: {name}",
|
||||
"family_sibling_stock": "You should have: {qty}",
|
||||
"family_sibling_location": "Location: {location}",
|
||||
"family_sibling_qty": "Quantity: {qty}",
|
||||
"family_sibling_purchased": "Purchased on {date}",
|
||||
"family_sibling_question": "Is the quantity still correct?",
|
||||
"family_sibling_prompt": "You also have {name}: {qty} in stock. Confirm the quantity?",
|
||||
"family_sibling_yes": "Yes, all good",
|
||||
"family_sibling_no": "No, update",
|
||||
"bring_add_n": "Add {n} to Bring!",
|
||||
"bring_add_selected": "Add selected to Bring!",
|
||||
"bring_adding": "Adding...",
|
||||
@@ -503,6 +575,7 @@
|
||||
"remove_error": "Removal error",
|
||||
"btn_fetch_prices": "Find prices",
|
||||
"price_total_label": "💰 Estimated total:",
|
||||
"price_total_short": "estimated total",
|
||||
"price_loading": "Looking up prices…",
|
||||
"price_not_found": "price n/a",
|
||||
"suggest_loading": "Analyzing...",
|
||||
@@ -512,7 +585,8 @@
|
||||
"priority_low": "Low",
|
||||
"smart_last_update": "Updated {time}",
|
||||
"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": {
|
||||
"title": "🤖 AI Identification",
|
||||
@@ -523,7 +597,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "📒 Operations Log",
|
||||
@@ -689,7 +764,8 @@
|
||||
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
||||
"detect_btn": "🔄 Detect cameras",
|
||||
"ai_fallback_label": "AI visual identification (5s fallback)",
|
||||
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured."
|
||||
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured.",
|
||||
"ai_manual_hint": "If the barcode cannot be read, use the «Identify with AI» button below the camera. Requires Gemini configured."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 HTTPS Certificate",
|
||||
@@ -778,7 +854,13 @@
|
||||
"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_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) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — 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) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — 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": {
|
||||
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
||||
@@ -965,7 +1047,8 @@
|
||||
"sensor_copied": "YAML copied to clipboard!",
|
||||
"save_btn": "Save HA settings",
|
||||
"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": {
|
||||
"today": "TODAY",
|
||||
@@ -1039,6 +1122,7 @@
|
||||
"finished_all": "📤 {name} finished!",
|
||||
"vacuum_sealed": "{name} saved as vacuum sealed",
|
||||
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
||||
"ghost_restored": "✅ {name}: restored {qty} {unit} to inventory",
|
||||
"appliance_added": "Appliance added",
|
||||
"item_added": "{name} added"
|
||||
},
|
||||
@@ -1110,7 +1194,9 @@
|
||||
"offline_ops_pending": "{n} operations pending",
|
||||
"offline_synced": "{n} operations synced",
|
||||
"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": {
|
||||
@@ -1232,7 +1318,8 @@
|
||||
"stay_btn": "No, stay in {location}",
|
||||
"moved_toast": "📦 Opened package moved to {location}",
|
||||
"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": {
|
||||
"1": "Unprocessed",
|
||||
@@ -1467,7 +1554,12 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Monthly Stats",
|
||||
@@ -1480,5 +1572,12 @@
|
||||
"top_used": "top used",
|
||||
"top_cats": "Top categories",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+174
-23
@@ -32,6 +32,7 @@
|
||||
"reset_default": "↺ Restablecer valores por defecto",
|
||||
"save_info": "💾 Guardar información",
|
||||
"retry": "🔄 Reintentar",
|
||||
"next": "Siguiente →",
|
||||
"yes_short": "Sí",
|
||||
"no_short": "No"
|
||||
},
|
||||
@@ -141,8 +142,10 @@
|
||||
"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_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_check": "¿Puedes comprobarlo?",
|
||||
"banner_finished_action_restore": "Restaurar {qty} {unit}",
|
||||
"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_untracked_title": "stock no registrado como entrada",
|
||||
@@ -162,7 +165,13 @@
|
||||
"banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Pedir explicación a Gemini",
|
||||
"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": {
|
||||
"title": "Despensa",
|
||||
@@ -191,6 +200,7 @@
|
||||
"mode_shopping": "🛒 Modo compras",
|
||||
"mode_shopping_end": "✅ Finalizar compras",
|
||||
"spesa_btn": "🛒 Compras",
|
||||
"spesa_camera_hint": "Enfoca el código con la cámara. ¿Sin código? Pulsa «Identificar con IA» abajo.",
|
||||
"zoom": "Zoom",
|
||||
"tab_barcode": "Código de barras",
|
||||
"tab_name": "Nombre",
|
||||
@@ -225,22 +235,39 @@
|
||||
"status_confirmed": "Confirmado!",
|
||||
"status_parallel": "Escaneo combinado activo...",
|
||||
"status_ocr_searching": "Estoy leyendo los números del código de barras...",
|
||||
"status_digit_ocr": "Leyendo los números bajo el código...",
|
||||
"status_ai_visual_searching": "Ahora intento reconocer el producto...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"method_local_ocr": "OCR números",
|
||||
"method_zbar": "ZBar",
|
||||
"local_ocr_found": "Código desde los números: {code}",
|
||||
"ai_fallback_searching": "Identificación de IA en curso...",
|
||||
"ai_fallback_found": "Producto identificado por IA",
|
||||
"ai_fallback_not_found": "IA: producto no reconocido",
|
||||
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código",
|
||||
"ai_overlay_label": "Gemini Vision",
|
||||
"ai_overlay_msg": "Gemini Vision está analizando el producto...",
|
||||
"ai_retry_btn": "Reintentar con IA",
|
||||
"ai_manual_btn": "🤖 Identificar con IA",
|
||||
"ai_not_recognized": "IA: producto no reconocido. Reintenta o añádelo manualmente.",
|
||||
"ai_match_title": "Producto reconocido por IA",
|
||||
"ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.",
|
||||
"ai_match_existing": "Posibles coincidencias en despensa",
|
||||
"ai_match_none": "No se encontraron productos similares en despensa.",
|
||||
"ai_match_use_btn": "Usar este",
|
||||
"ai_match_add_btn": "Agregar \"{name}\"",
|
||||
"ai_detected_label": "IA detecto"
|
||||
"ai_match_existing": "Actualmente en despensa",
|
||||
"ai_match_finished": "Agotados / terminados",
|
||||
"ai_match_catalog": "En catálogo (sin stock)",
|
||||
"ai_match_finished_badge": "agotado",
|
||||
"ai_match_finished_hint": "Producto agotado — repón la cantidad",
|
||||
"ai_match_merged_existing": "Vinculado a un producto existente del catálogo",
|
||||
"ai_match_none": "Sin productos similares — puedes crear uno nuevo.",
|
||||
"ai_match_use_btn": "Usar",
|
||||
"ai_match_create_btn": "➕ Crear nuevo: {name}",
|
||||
"ai_match_add_btn": "Agregar {name}",
|
||||
"ai_match_action_hint": "Toca el boton verde para agregar este producto",
|
||||
"ai_match_or_similar": "O elige un producto similar:",
|
||||
"ai_detected_label": "IA detecto",
|
||||
"stock_in_pantry": "Ya en despensa:",
|
||||
"mode_shopping_activated": "🛒 ¡Modo compras activado!"
|
||||
},
|
||||
"action": {
|
||||
"title": "¿Qué quieres hacer?",
|
||||
@@ -254,7 +281,8 @@
|
||||
"throw_btn": "🗑️ DESECHAR",
|
||||
"throw_sub": "tirar",
|
||||
"edit_sub": "caducidad, ubicación…",
|
||||
"create_recipe_btn": "Receta"
|
||||
"create_recipe_btn": "Receta",
|
||||
"related_stock_title": "También en casa"
|
||||
},
|
||||
"add": {
|
||||
"title": "Añadir a la despensa",
|
||||
@@ -278,8 +306,9 @@
|
||||
"hint_modify": "📝 Puedes cambiar la fecha o escanearla con la cámara",
|
||||
"scan_expiry_title": "📷 Escanear fecha de caducidad",
|
||||
"product_added": "✅ ¡{name} añadido!{qty}",
|
||||
"duplicate_recent_confirm": "Acabas de añadir «{name}» ({when}).\n\nLa cantidad ya es {total}.\n\n¿Aumentarla en {qty}?",
|
||||
"suffix_freezer_vacuum": "(congelador + al vacío)",
|
||||
"history_badge_tip": "Media de {n} entradas anteriores",
|
||||
"history_badge_tip": "Media de los últimos {n} registros — se actualiza con cada compra",
|
||||
"vacuum_question": "¿Al vacío?",
|
||||
"vacuum_saved": "🔒 ¡Al vacío!"
|
||||
},
|
||||
@@ -312,14 +341,29 @@
|
||||
"toast_bring": "🛒 Producto terminado → añadido a Bring!",
|
||||
"toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!",
|
||||
"disambiguation_hint": "¿Qué quieres decir con «todo terminado»?",
|
||||
"disambiguation_one_conf": "Terminado <strong>1 envase</strong> ({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!",
|
||||
"use_all_confirm_title": "✅ Terminar todo",
|
||||
"use_all_confirm_msg": "Confirma que has terminado el producto:",
|
||||
"use_all_confirm_btn": "✅ Sí, terminado",
|
||||
"throw_all_confirm_title": "🗑️ Desechar todo",
|
||||
"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": {
|
||||
"title_new": "Nuevo producto",
|
||||
@@ -329,6 +373,8 @@
|
||||
"name_label": "🏷️ Nombre del producto *",
|
||||
"name_placeholder": "Ej.: Leche entera, Pasta penne...",
|
||||
"brand_label": "🏢 Marca",
|
||||
"allergens_label": "Alérgenos:",
|
||||
"ingredients_summary": "📋 Ingredientes",
|
||||
"brand_placeholder": "Ej.: Barilla, Danone, Heinz...",
|
||||
"category_label": "📂 Categoría",
|
||||
"unit_label": "📏 Unidad de medida",
|
||||
@@ -359,7 +405,9 @@
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origen",
|
||||
"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": {
|
||||
"title": "📦 Todos los productos",
|
||||
@@ -411,7 +459,19 @@
|
||||
"load_error": "Error de carga",
|
||||
"favorite": "Añadir a 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": {
|
||||
"title": "🛒 Lista de la compra",
|
||||
@@ -471,6 +531,18 @@
|
||||
"item_removed": "✅ ¡{name} eliminado de la lista!",
|
||||
"urgency_spec_critical": "⚡ Urgente",
|
||||
"urgency_spec_high": "🟠 Pronto",
|
||||
"urgency_spec_medium": "🟡 Pronto",
|
||||
"urgency_spec_low": "🔵 Previsión",
|
||||
"family_sibling_title": "Similar en {location}",
|
||||
"family_sibling_check": "Comprueba: {name}",
|
||||
"family_sibling_stock": "Deberías tener: {qty}",
|
||||
"family_sibling_location": "Ubicación: {location}",
|
||||
"family_sibling_qty": "Cantidad: {qty}",
|
||||
"family_sibling_purchased": "Comprado el {date}",
|
||||
"family_sibling_question": "¿La cantidad sigue siendo correcta?",
|
||||
"family_sibling_prompt": "También tienes {name}: {qty} en stock. ¿Confirmas la cantidad?",
|
||||
"family_sibling_yes": "Sí, correcto",
|
||||
"family_sibling_no": "No, actualizar",
|
||||
"bring_add_n": "Añadir {n} a Bring!",
|
||||
"bring_add_selected": "Añadir selección a Bring!",
|
||||
"bring_adding": "Añadiendo...",
|
||||
@@ -498,6 +570,7 @@
|
||||
"remove_error": "Error al eliminar",
|
||||
"btn_fetch_prices": "Buscar precios",
|
||||
"price_total_label": "💰 Total estimado:",
|
||||
"price_total_short": "total estimado",
|
||||
"price_loading": "Buscando precios…",
|
||||
"price_not_found": "precio n/d",
|
||||
"suggest_loading": "Analizando...",
|
||||
@@ -506,7 +579,9 @@
|
||||
"priority_medium": "Media",
|
||||
"priority_low": "Baja",
|
||||
"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": {
|
||||
"title": "🤖 Identificación IA",
|
||||
@@ -517,7 +592,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "📒 Registro de operaciones",
|
||||
@@ -683,7 +759,8 @@
|
||||
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
|
||||
"detect_btn": "🔄 Detectar cámaras",
|
||||
"ai_fallback_label": "Identificación visual IA (repuesto 5s)",
|
||||
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado."
|
||||
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado.",
|
||||
"ai_manual_hint": "Si el código no se lee, usa el botón «Identificar con IA» debajo de la cámara. Requiere Gemini configurado."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificado HTTPS",
|
||||
@@ -733,7 +810,8 @@
|
||||
"heard_yes": "Sí, la escuché",
|
||||
"heard_no": "No, no escuché nada",
|
||||
"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": {
|
||||
"title": "🌐 Idioma",
|
||||
@@ -771,7 +849,13 @@
|
||||
"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_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) — peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico — 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) — peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico — 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": {
|
||||
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
|
||||
@@ -917,7 +1001,49 @@
|
||||
"sensor_copied": "¡YAML copiado al portapapeles!",
|
||||
"save_btn": "Guardar ajustes HA",
|
||||
"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": {
|
||||
"today": "HOY",
|
||||
@@ -990,8 +1116,10 @@
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)",
|
||||
"finished_all": "📤 ¡{name} terminado!",
|
||||
"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",
|
||||
"item_added": "{name} añadido"
|
||||
"item_added": "{name} añadido",
|
||||
"vacuum_sealed": "{name} guardado al vacío"
|
||||
},
|
||||
"antiwaste": {
|
||||
"title": "🌱 Informe anti-desperdicio",
|
||||
@@ -1061,7 +1189,9 @@
|
||||
"offline_ops_pending": "{n} operaciones pendientes",
|
||||
"offline_synced": "{n} operaciones sincronizadas",
|
||||
"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": {
|
||||
@@ -1162,7 +1292,10 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "⚠️ ¡Stock bajo!",
|
||||
@@ -1180,7 +1313,8 @@
|
||||
"stay_btn": "No, quedarse en {location}",
|
||||
"moved_toast": "📦 Paquete abierto movido a {location}",
|
||||
"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": {
|
||||
"1": "Sin procesar",
|
||||
@@ -1410,7 +1544,17 @@
|
||||
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
|
||||
"retry": "Reintentar",
|
||||
"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": {
|
||||
"title": "Estadísticas Mensuales",
|
||||
@@ -1423,5 +1567,12 @@
|
||||
"top_used": "más usado",
|
||||
"top_cats": "Categorías principales",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+180
-23
@@ -32,6 +32,7 @@
|
||||
"reset_default": "↺ Rétablir les valeurs par défaut",
|
||||
"save_info": "💾 Enregistrer les informations",
|
||||
"retry": "🔄 Réessayer",
|
||||
"next": "Suivant →",
|
||||
"yes_short": "Oui",
|
||||
"no_short": "Non"
|
||||
},
|
||||
@@ -141,8 +142,10 @@
|
||||
"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_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_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_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",
|
||||
@@ -162,7 +165,13 @@
|
||||
"banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Demander une explication à Gemini",
|
||||
"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": {
|
||||
"title": "Garde-manger",
|
||||
@@ -191,6 +200,7 @@
|
||||
"mode_shopping": "🛒 Mode courses",
|
||||
"mode_shopping_end": "✅ Terminer les courses",
|
||||
"spesa_btn": "🛒 Courses",
|
||||
"spesa_camera_hint": "Visez le code-barres avec la caméra. Pas de code ? Appuyez sur « Identifier avec l'IA » ci-dessous.",
|
||||
"zoom": "Zoom",
|
||||
"tab_barcode": "Code-barres",
|
||||
"tab_name": "Nom",
|
||||
@@ -225,22 +235,39 @@
|
||||
"status_confirmed": "Confirmé !",
|
||||
"status_parallel": "Scan combiné actif...",
|
||||
"status_ocr_searching": "Je lis les chiffres du code-barres...",
|
||||
"status_digit_ocr": "Lecture des chiffres sous le code-barres...",
|
||||
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"method_local_ocr": "OCR chiffres",
|
||||
"method_zbar": "ZBar",
|
||||
"local_ocr_found": "Code depuis les chiffres : {code}",
|
||||
"ai_fallback_searching": "Identification IA en cours...",
|
||||
"ai_fallback_found": "Produit identifié par l'IA",
|
||||
"ai_fallback_not_found": "IA : produit non reconnu",
|
||||
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres",
|
||||
"ai_overlay_label": "Gemini Vision",
|
||||
"ai_overlay_msg": "Gemini Vision analyse le produit...",
|
||||
"ai_retry_btn": "Reessayer avec l'IA",
|
||||
"ai_manual_btn": "🤖 Identifier avec l'IA",
|
||||
"ai_not_recognized": "IA : produit non reconnu. Réessayez ou saisissez-le manuellement.",
|
||||
"ai_match_title": "Produit reconnu par l'IA",
|
||||
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
|
||||
"ai_match_existing": "Correspondances possibles dans le stock",
|
||||
"ai_match_none": "Aucun produit similaire trouve dans le stock.",
|
||||
"ai_match_use_btn": "Utiliser celui-ci",
|
||||
"ai_match_add_btn": "Ajouter \"{name}\"",
|
||||
"ai_detected_label": "IA a detecte"
|
||||
"ai_match_existing": "Actuellement en stock",
|
||||
"ai_match_finished": "Terminés / épuisés",
|
||||
"ai_match_catalog": "Au catalogue (sans stock)",
|
||||
"ai_match_finished_badge": "épuisé",
|
||||
"ai_match_finished_hint": "Produit terminé — réapprovisionner la quantité",
|
||||
"ai_match_merged_existing": "Lié à un produit déjà au catalogue",
|
||||
"ai_match_none": "Aucun produit similaire — vous pouvez en créer un nouveau.",
|
||||
"ai_match_use_btn": "Utiliser",
|
||||
"ai_match_create_btn": "➕ Créer nouveau : {name}",
|
||||
"ai_match_add_btn": "Ajouter {name}",
|
||||
"ai_match_action_hint": "Appuyez sur le bouton vert pour ajouter ce produit",
|
||||
"ai_match_or_similar": "Ou choisissez un produit similaire :",
|
||||
"ai_detected_label": "IA a detecte",
|
||||
"stock_in_pantry": "Déjà à la maison :",
|
||||
"mode_shopping_activated": "🛒 Mode courses activé !"
|
||||
},
|
||||
"action": {
|
||||
"title": "Que voulez-vous faire ?",
|
||||
@@ -254,7 +281,8 @@
|
||||
"throw_btn": "🗑️ JETER",
|
||||
"throw_sub": "jeter",
|
||||
"edit_sub": "péremption, emplacement…",
|
||||
"create_recipe_btn": "Recette"
|
||||
"create_recipe_btn": "Recette",
|
||||
"related_stock_title": "Aussi à la maison"
|
||||
},
|
||||
"add": {
|
||||
"title": "Ajouter au garde-manger",
|
||||
@@ -278,8 +306,9 @@
|
||||
"hint_modify": "📝 Vous pouvez modifier la date ou la scanner avec la caméra",
|
||||
"scan_expiry_title": "📷 Scanner la date de péremption",
|
||||
"product_added": "✅ {name} ajouté !{qty}",
|
||||
"duplicate_recent_confirm": "Vous venez d'ajouter « {name} » ({when}).\n\nLa quantité est déjà {total}.\n\nL'augmenter de {qty} ?",
|
||||
"suffix_freezer_vacuum": "(congélateur + sous vide)",
|
||||
"history_badge_tip": "Moyenne de {n} entrées précédentes",
|
||||
"history_badge_tip": "Moyenne des {n} derniers ajouts — mise à jour à chaque achat",
|
||||
"vacuum_question": "Sous vide ?",
|
||||
"vacuum_saved": "🔒 Sous vide !"
|
||||
},
|
||||
@@ -312,14 +341,29 @@
|
||||
"toast_bring": "🛒 Produit terminé → ajouté à Bring !",
|
||||
"toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !",
|
||||
"disambiguation_hint": "Que voulez-vous dire par « tout fini » ?",
|
||||
"disambiguation_one_conf": "Terminer <strong>1 emballage</strong> ({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 !",
|
||||
"use_all_confirm_title": "✅ Tout terminer",
|
||||
"use_all_confirm_msg": "Confirmez que vous avez terminé le produit :",
|
||||
"use_all_confirm_btn": "✅ Oui, terminé",
|
||||
"throw_all_confirm_title": "🗑️ Tout jeter",
|
||||
"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": {
|
||||
"title_new": "Nouveau produit",
|
||||
@@ -329,6 +373,8 @@
|
||||
"name_label": "🏷️ Nom du produit *",
|
||||
"name_placeholder": "Ex. : Lait entier, Pâtes penne...",
|
||||
"brand_label": "🏢 Marque",
|
||||
"allergens_label": "Allergènes :",
|
||||
"ingredients_summary": "📋 Ingrédients",
|
||||
"brand_placeholder": "Ex. : Barilla, Danone, Heinz...",
|
||||
"category_label": "📂 Catégorie",
|
||||
"unit_label": "📏 Unité de mesure",
|
||||
@@ -359,7 +405,9 @@
|
||||
"weight_label": "Poids",
|
||||
"origin_label": "Origine",
|
||||
"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": {
|
||||
"title": "📦 Tous les produits",
|
||||
@@ -411,7 +459,25 @@
|
||||
"load_error": "Erreur de chargement",
|
||||
"favorite": "Ajouter aux 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": {
|
||||
"title": "🛒 Liste de courses",
|
||||
@@ -471,6 +537,18 @@
|
||||
"item_removed": "✅ {name} retiré de la liste !",
|
||||
"urgency_spec_critical": "⚡ Urgent",
|
||||
"urgency_spec_high": "🟠 Bientôt",
|
||||
"urgency_spec_medium": "🟡 Bientôt",
|
||||
"urgency_spec_low": "🔵 Prévision",
|
||||
"family_sibling_title": "Similaire dans {location}",
|
||||
"family_sibling_check": "Vérifier : {name}",
|
||||
"family_sibling_stock": "Vous devriez avoir : {qty}",
|
||||
"family_sibling_location": "Emplacement : {location}",
|
||||
"family_sibling_qty": "Quantité : {qty}",
|
||||
"family_sibling_purchased": "Acheté le {date}",
|
||||
"family_sibling_question": "La quantité est-elle encore correcte ?",
|
||||
"family_sibling_prompt": "Tu as aussi {name} : {qty} en stock. Confirmer la quantité ?",
|
||||
"family_sibling_yes": "Oui, c'est bon",
|
||||
"family_sibling_no": "Non, mettre à jour",
|
||||
"bring_add_n": "Ajouter {n} à Bring !",
|
||||
"bring_add_selected": "Ajouter la sélection à Bring !",
|
||||
"bring_adding": "Ajout en cours...",
|
||||
@@ -498,6 +576,7 @@
|
||||
"remove_error": "Erreur de suppression",
|
||||
"btn_fetch_prices": "Trouver les prix",
|
||||
"price_total_label": "💰 Total estimé :",
|
||||
"price_total_short": "total estimé",
|
||||
"price_loading": "Recherche des prix…",
|
||||
"price_not_found": "prix n/d",
|
||||
"suggest_loading": "Analyse en cours...",
|
||||
@@ -506,7 +585,9 @@
|
||||
"priority_medium": "Moyenne",
|
||||
"priority_low": "Faible",
|
||||
"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": {
|
||||
"title": "🤖 Identification IA",
|
||||
@@ -517,7 +598,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "📒 Journal des opérations",
|
||||
@@ -683,7 +765,8 @@
|
||||
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
|
||||
"detect_btn": "🔄 Détecter les caméras",
|
||||
"ai_fallback_label": "Identification visuelle IA (repli 5s)",
|
||||
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré."
|
||||
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré.",
|
||||
"ai_manual_hint": "Si le code-barres ne se lit pas, utilisez le bouton « Identifier avec l'IA » sous la caméra. Nécessite Gemini configuré."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificat HTTPS",
|
||||
@@ -733,7 +816,8 @@
|
||||
"heard_yes": "Oui, je l'ai entendu",
|
||||
"heard_no": "Non, je n'ai rien entendu",
|
||||
"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": {
|
||||
"title": "🌐 Langue",
|
||||
@@ -771,7 +855,13 @@
|
||||
"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_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) — poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique — 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) — poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique — 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": {
|
||||
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.",
|
||||
@@ -917,7 +1007,49 @@
|
||||
"sensor_copied": "YAML copié dans le presse-papiers !",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"today": "AUJOURD'HUI",
|
||||
@@ -990,8 +1122,10 @@
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)",
|
||||
"finished_all": "📤 {name} terminé !",
|
||||
"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é",
|
||||
"item_added": "{name} ajouté"
|
||||
"item_added": "{name} ajouté",
|
||||
"vacuum_sealed": "{name} enregistré sous vide"
|
||||
},
|
||||
"antiwaste": {
|
||||
"title": "🌱 Rapport anti-gaspi",
|
||||
@@ -1061,7 +1195,9 @@
|
||||
"offline_ops_pending": "{n} opérations en attente",
|
||||
"offline_synced": "{n} opérations synchronisées",
|
||||
"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": {
|
||||
@@ -1162,7 +1298,10 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "⚠️ Stock faible !",
|
||||
@@ -1180,7 +1319,8 @@
|
||||
"stay_btn": "Non, rester dans {location}",
|
||||
"moved_toast": "📦 Emballage ouvert déplacé vers {location}",
|
||||
"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": {
|
||||
"1": "Non transformé",
|
||||
@@ -1410,7 +1550,17 @@
|
||||
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
|
||||
"retry": "Réessayer",
|
||||
"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": {
|
||||
"title": "Statistiques Mensuelles",
|
||||
@@ -1423,5 +1573,12 @@
|
||||
"top_used": "le plus utilisé",
|
||||
"top_cats": "Catégories principales",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
-20
@@ -32,6 +32,7 @@
|
||||
"reset_default": "↺ Ripristina default",
|
||||
"save_info": "💾 Salva informazioni",
|
||||
"retry": "🔄 Riprova",
|
||||
"next": "Avanti →",
|
||||
"yes_short": "Sì",
|
||||
"no_short": "No"
|
||||
},
|
||||
@@ -143,8 +144,10 @@
|
||||
"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_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_check": "Puoi controllare?",
|
||||
"banner_finished_action_restore": "Ripristina {qty} {unit}",
|
||||
"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_untracked_title": "scorte non registrate come entrata",
|
||||
@@ -164,7 +167,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Chiedi a Gemini una spiegazione",
|
||||
"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": {
|
||||
"title": "Dispensa",
|
||||
@@ -193,6 +200,7 @@
|
||||
"mode_shopping": "🛒 Modalità Spesa",
|
||||
"mode_shopping_end": "✅ Fine spesa",
|
||||
"spesa_btn": "🛒 Spesa",
|
||||
"spesa_camera_hint": "Inquadra il codice con la telecamera. Senza barcode? Premi «Identifica con AI» sotto.",
|
||||
"zoom": "Zoom",
|
||||
"tab_barcode": "Barcode",
|
||||
"tab_name": "Nome",
|
||||
@@ -228,22 +236,38 @@
|
||||
"status_confirmed": "Confermato!",
|
||||
"status_parallel": "Doppia scansione attiva...",
|
||||
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
|
||||
"status_digit_ocr": "Leggo i numeri sotto il codice...",
|
||||
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"method_local_ocr": "OCR numeri",
|
||||
"method_zbar": "ZBar",
|
||||
"local_ocr_found": "Codice dai numeri: {code}",
|
||||
"ai_fallback_searching": "Identificazione AI in corso...",
|
||||
"ai_fallback_found": "Prodotto identificato dall'AI",
|
||||
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
|
||||
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
|
||||
"ai_overlay_label": "Gemini Vision",
|
||||
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
|
||||
"ai_retry_btn": "Riprova con AI",
|
||||
"ai_manual_btn": "🤖 Identifica con AI",
|
||||
"ai_not_recognized": "AI: prodotto non riconosciuto. Riprova o inserisci manualmente.",
|
||||
"ai_match_title": "Prodotto riconosciuto con AI",
|
||||
"ai_match_subtitle": "Scegli se usare un prodotto gia presente oppure aggiungere quello rilevato.",
|
||||
"ai_match_existing": "Possibili corrispondenze in dispensa",
|
||||
"ai_match_none": "Nessun prodotto simile trovato in dispensa.",
|
||||
"ai_match_use_btn": "Usa questo",
|
||||
"ai_match_add_btn": "Aggiungi \"{name}\"",
|
||||
"ai_detected_label": "AI ha trovato"
|
||||
"ai_match_subtitle": "Scegli un prodotto esistente o creane uno nuovo con il nome rilevato.",
|
||||
"ai_match_existing": "In dispensa adesso",
|
||||
"ai_match_finished": "Finiti / esauriti",
|
||||
"ai_match_catalog": "Nel catalogo (senza scorte)",
|
||||
"ai_match_finished_badge": "esaurito",
|
||||
"ai_match_finished_hint": "Prodotto finito — reintegra la quantità",
|
||||
"ai_match_merged_existing": "Collegato a un prodotto già presente nel catalogo",
|
||||
"ai_match_none": "Nessun prodotto simile trovato — puoi crearne uno nuovo.",
|
||||
"ai_match_use_btn": "Usa",
|
||||
"ai_match_create_btn": "➕ Crea nuovo: {name}",
|
||||
"ai_match_add_btn": "Aggiungi {name}",
|
||||
"ai_match_action_hint": "Tocca il pulsante verde per aggiungere questo prodotto",
|
||||
"ai_match_or_similar": "Oppure scegli un prodotto simile:",
|
||||
"ai_detected_label": "AI ha trovato",
|
||||
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
@@ -282,8 +306,9 @@
|
||||
"hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera",
|
||||
"scan_expiry_title": "📷 Scansiona Data Scadenza",
|
||||
"product_added": "✅ {name} aggiunto!{qty}",
|
||||
"duplicate_recent_confirm": "Hai appena aggiunto «{name}» ({when}).\n\nLa quantità è già {total}.\n\nVuoi aumentarla di {qty}?",
|
||||
"suffix_freezer_vacuum": "(freezer + sotto vuoto)",
|
||||
"history_badge_tip": "Media da {n} inserimenti precedenti",
|
||||
"history_badge_tip": "Media degli ultimi {n} inserimenti — si aggiorna ad ogni nuovo acquisto",
|
||||
"vacuum_question": "Messo sotto vuoto?",
|
||||
"vacuum_saved": "🔒 Sotto vuoto registrato"
|
||||
},
|
||||
@@ -316,14 +341,29 @@
|
||||
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
|
||||
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
|
||||
"disambiguation_one_conf": "Finita <strong>1 confezione</strong> ({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!",
|
||||
"use_all_confirm_title": "✅ Finisci tutto",
|
||||
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
|
||||
"use_all_confirm_btn": "✅ Sì, finito",
|
||||
"throw_all_confirm_title": "🗑️ Butta tutto",
|
||||
"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": {
|
||||
"title_new": "Nuovo Prodotto",
|
||||
@@ -333,6 +373,8 @@
|
||||
"name_label": "🏷️ Nome Prodotto *",
|
||||
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
|
||||
"brand_label": "🏢 Marca",
|
||||
"allergens_label": "Allergeni:",
|
||||
"ingredients_summary": "📋 Ingredienti",
|
||||
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
|
||||
"category_label": "📂 Categoria",
|
||||
"unit_label": "📏 Unità di misura",
|
||||
@@ -363,7 +405,9 @@
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origine",
|
||||
"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": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
@@ -395,6 +439,12 @@
|
||||
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
|
||||
"close_btn": "✅ Chiudi",
|
||||
"ingredients_title": "🧾 Ingredienti",
|
||||
"shopping_suggestions_intro": "Per una variante servirebbe (non in dispensa — opzionale):",
|
||||
"shopping_suggestions_add": "Aggiungi alla lista spesa",
|
||||
"shopping_suggestions_added": "Aggiunto alla lista spesa",
|
||||
"frozen_badge": "surgelato — dal freezer",
|
||||
"unit_for_input": "Unità di misura",
|
||||
"enter_in": "Inserimento in",
|
||||
"tools_title": "Strumenti necessari",
|
||||
"steps_title": "👨🍳 Procedimento",
|
||||
"no_steps": "Nessun procedimento disponibile",
|
||||
@@ -416,7 +466,18 @@
|
||||
"load_error": "Errore nel caricamento",
|
||||
"favorite": "Aggiungi ai 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": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
@@ -476,6 +537,18 @@
|
||||
"item_removed": "✅ {name} rimosso dalla lista!",
|
||||
"urgency_spec_critical": "⚡ Urgente",
|
||||
"urgency_spec_high": "🟠 Presto",
|
||||
"urgency_spec_medium": "🟡 A breve",
|
||||
"urgency_spec_low": "🔵 Previsione",
|
||||
"family_sibling_title": "Simile in {location}",
|
||||
"family_sibling_check": "Controlla: {name}",
|
||||
"family_sibling_stock": "Dovresti avere: {qty}",
|
||||
"family_sibling_location": "Si trova in: {location}",
|
||||
"family_sibling_qty": "Quantità: {qty}",
|
||||
"family_sibling_purchased": "Acquistato il {date}",
|
||||
"family_sibling_question": "La quantità è ancora corretta?",
|
||||
"family_sibling_prompt": "Hai anche {name}: ne hai {qty} in dispensa. Confermi la quantità?",
|
||||
"family_sibling_yes": "Sì, tutto ok",
|
||||
"family_sibling_no": "No, aggiorna",
|
||||
"bring_add_n": "Aggiungi {n} a Bring!",
|
||||
"bring_add_selected": "Aggiungi selezionati a Bring!",
|
||||
"bring_adding": "Aggiunta in corso...",
|
||||
@@ -503,6 +576,7 @@
|
||||
"remove_error": "Errore nella rimozione",
|
||||
"btn_fetch_prices": "Cerca i prezzi",
|
||||
"price_total_label": "💰 Spesa stimata:",
|
||||
"price_total_short": "spesa stimata",
|
||||
"price_loading": "Ricerca prezzi…",
|
||||
"price_not_found": "prezzo n/d",
|
||||
"suggest_loading": "Analisi in corso...",
|
||||
@@ -512,7 +586,8 @@
|
||||
"priority_low": "Bassa",
|
||||
"smart_last_update": "Aggiornato {time}",
|
||||
"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": {
|
||||
"title": "🤖 Identificazione AI",
|
||||
@@ -523,7 +598,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "📒 Storico",
|
||||
@@ -689,7 +765,8 @@
|
||||
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
||||
"detect_btn": "🔄 Rileva fotocamere",
|
||||
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
|
||||
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato."
|
||||
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato.",
|
||||
"ai_manual_hint": "Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificato HTTPS",
|
||||
@@ -778,7 +855,13 @@
|
||||
"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_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) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — 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) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — 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": {
|
||||
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
||||
@@ -965,7 +1048,8 @@
|
||||
"sensor_copied": "YAML copiato negli appunti!",
|
||||
"save_btn": "Salva impostazioni HA",
|
||||
"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": {
|
||||
"today": "OGGI",
|
||||
@@ -1039,6 +1123,7 @@
|
||||
"finished_all": "📤 {name} terminato!",
|
||||
"vacuum_sealed": "{name} salvato come sottovuoto",
|
||||
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
||||
"ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario",
|
||||
"appliance_added": "Elettrodomestico aggiunto",
|
||||
"item_added": "{name} aggiunto"
|
||||
},
|
||||
@@ -1110,7 +1195,9 @@
|
||||
"offline_ops_pending": "{n} operazioni in attesa",
|
||||
"offline_synced": "{n} operazioni sincronizzate",
|
||||
"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": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
@@ -1231,7 +1318,8 @@
|
||||
"stay_btn": "No, resta in {location}",
|
||||
"moved_toast": "📦 Confezione aperta spostata in {location}",
|
||||
"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": {
|
||||
"1": "Non trasformato",
|
||||
@@ -1466,7 +1554,12 @@
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Statistiche Mensili",
|
||||
@@ -1479,5 +1572,12 @@
|
||||
"top_used": "più usato",
|
||||
"top_cats": "Categorie principali",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user