Compare commits

...

158 Commits

Author SHA1 Message Date
github-actions[bot] e72e57edf6 chore: auto-merge develop → main
Triggered by: b63deca Release v1.7.37: strict recipe pantry matching and renderRecipe fix.
2026-06-04 17:39:57 +00:00
dadaloop82 b63deca795 Release v1.7.37: strict recipe pantry matching and renderRecipe fix.
Prevent false  pantry links via strict name matching and full inventory prompts; fix qtyNum crash when reopening archived recipes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:38:12 +00:00
github-actions[bot] 217626ca2a chore: auto-merge develop → main
Triggered by: cf65e79 Release v1.7.36: recipe stock hints, ghost products, and shopping total fix.
2026-06-04 17:24:45 +00:00
dadaloop82 cf65e79010 Release v1.7.36: recipe stock hints, ghost products, and shopping total fix.
Adds pantry stock/remainder lines on recipe ingredients with zero-waste use-all on sealed package leftovers, ghost product restore in the dashboard, unified shopping totals, i18n sync, and maintenance scripts.

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

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

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

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

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

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

Feature is disabled by default (BARCODE_AI_FALLBACK=false).
2026-05-29 17:37:37 +00:00
dadaloop82 7947f47e6d release: v1.7.33 2026-05-29 11:06:28 +00:00
dadaloop82 758eb93e20 fix: ha_sensor shopping_total null + wrong shopping_list columns
- Extended shopping_total cache TTL from 1h to 24h
- Added inline price fallback: when cache is empty/stale, computes total
  from shopping_price_cache.json (no AI calls); joins shopping_list with
  products to get canonical shopping_name; tries both v3 and legacy v0
  key formats to maximise cache hit rate; works in both internal and
  Bring shopping modes (removed isShoppingBringMode guard — table is
  always populated by sync)
- Fixed haInventorySensor + haRefreshPrices: shopping_list has no
  quantity/unit/checked columns; changed to SELECT name with
  COALESCE(p.shopping_name, sl.name) join, defaults qty=1/unit=pz
2026-05-29 11:06:19 +00:00
dadaloop82 ff1175451a release: v1.7.32 2026-05-29 06:54:42 +00:00
dadaloop82 42630c3e3e feat: smarter expiry-to-shopping-list logic
- Extend isExpiringSoon threshold: 3d -> 7d
- Expired items: add isRegular/buyCount>=2 guard so one-off
  expired products don't appear in shopping list (expiry
  banner already covers them)
- Expiring-soon block: require isRegular for 7-day window;
  add 'willExpireBeforeUsed' check (daysLeft > daysToExpiry);
  new reason string 'Scade in Ngg — ricompra' when stock is
  adequate but won't be consumed in time
2026-05-29 06:54:40 +00:00
dadaloop82 637eaa20d6 docs: version badge 1.7.31 2026-05-29 06:48:52 +00:00
dadaloop82 5e307f79b8 docs: update version badge to v1.7.31 2026-05-29 06:48:50 +00:00
dadaloop82 a6478b20e1 release: v1.7.31 2026-05-29 06:46:40 +00:00
dadaloop82 223457bbdf fix: addToInventory creates new row when all existing rows are opened
When adding a new pack of a product that already has an opened row
in inventory (opened_at IS NOT NULL), the previous code merged the
new stock into the opened row, corrupting opened_at tracking and
hiding the second pack from the anomaly model.

Now: search only for sealed rows (opened_at IS NULL) to merge into.
If only opened rows exist, INSERT a new sealed row instead.
2026-05-29 06:46:37 +00:00
dadaloop82 12c6a8977a release: v1.7.30 2026-05-29 06:37:52 +00:00
dadaloop82 c7a69d8379 fix: consumption anomaly ignores sealed packs in other rows
getConsumptionPredictions now aggregates total qty across all
inventory rows for the same product_id before flagging.
If totalQtyAllRows >= expectedQty, the anomaly is suppressed
(stock is healthy, just split across opened+sealed rows).
Also uses aggregated total as the displayed actual_qty.
2026-05-29 06:37:50 +00:00
dadaloop82 c7f3c95d75 release: v1.7.29 2026-05-29 06:34:50 +00:00
dadaloop82 a6f90a07e5 feat: buy-cycle consumption prediction for untracked products
Products like salt/spices that are never marked per-use now get
consumption rate estimated from the average time between restocks:
  avgCycleDays = (lastIn - firstIn) / (buyCount - 1)
  estimatedDaysLeft = avgCycleDays - daysSinceLastBuy

Requirements: buyCount >= 3, dailyRate == 0, avgCycle >= 7 days.
Appears in smart shopping list with reason 'Finisce tra ~Ngg (ciclo medio Mgg)'.
Also marks buy-cycle products as isRegular so stock checks apply.
2026-05-29 06:34:40 +00:00
dadaloop82 2d07001c5b release: v1.7.28 2026-05-29 06:02:53 +00:00
dadaloop82 faa55eda93 chore: CHANGELOG v1.7.28 2026-05-29 06:02:51 +00:00
dadaloop82 0b902d7c19 fix: issue reporter — local FP cache prevents duplicate issues
- Add _getFpCachePath(), _loadFpCache(), _saveFpCache() helpers
- Check data/reported_issue_fps.json before GitHub Search API
  (falls back to /tmp/ when data/ is not writable)
- Save new issue number to cache immediately after creation
- Apply 30-minute comment throttle per fingerprint
- Fall back to GitHub Search on first run / cache miss

Fixes root cause of ~50 duplicate issues (#134 duplicates #135-#183)
caused by GitHub Search API indexing delay.
2026-05-29 06:02:27 +00:00
dadaloop82 d80199e4f1 release: v1.7.27 2026-05-29 05:43:47 +00:00
dadaloop82 1637cc1020 feat: HA sensor enrichment, inventory edit guard, recipe ingredient fix, bread shelf-life
- HA sensor: expiring_list now includes full product details (location, brand,
  category, days_remaining, opened_at, vacuum_sealed, default_quantity, etc.)
- HA sensor: new expired_list attribute with full product details per expired item
- HA sensor: new low_stock_list attribute (items with quantity ≤ 1, full details)
- HA sensor: new sensor=product endpoint (?action=ha_sensor&sensor=product)
  with optional filters: &id=, &name=, &location=
- HA cron webhook: expiry alert items now carry full product details
- Inventory edit: confirm dialog when quantity exceeds unit-specific threshold
  (prevents data loss from unit-confusion typos, e.g. 183 conf instead of 0.183)
- Recipe AI: explicit rule against ingredient form substitution
  (fresh tomatoes ≠ passata, fresh milk ≠ UHT ≠ cream, etc.)
- Shelf-life: opened bread rules (piadina 2d, bauletto/pancarrè 4d, pane 3d)
- docs/wiki: HA page updated with new schema, examples, product endpoint

Closes #125
2026-05-29 05:40:25 +00:00
github-actions[bot] 904a398009 chore: auto-merge develop → main
Triggered by: bc39361 feat: barcode scan visual feedback + EAN checksum validation
2026-05-27 05:28:31 +00:00
dadaloop82 bc39361246 feat: barcode scan visual feedback + EAN checksum validation
- Add scan-status-bar overlay inside scanner viewport showing:
  - Active scan method (Native API / Quagga / Native + Quagga)
  - Scanning state: ready, scanning, partial read, invalid, confirmed
- Validate EAN-8/EAN-13/UPC checksums in Quagga path before confirming
  (native BarcodeDetector validates internally; Quagga can return false positives)
- Show 'invalid barcode, retrying' message with invalid code highlighted
- Reset invalid barcode confidence counter on invalid read so scanner retries
- Spawn parallel Quagga scan with 'combined scan active' status message
- Add 6 translation keys (scan.status_*) in all 5 language files
2026-05-27 05:26:47 +00:00
github-actions[bot] 7f173770fc chore: auto-merge develop → main
Triggered by: b83db76 fix: remove background/border from persons +/- buttons
2026-05-26 18:23:27 +00:00
dadaloop82 b83db76a8d fix: remove background/border from persons +/- buttons 2026-05-26 18:21:53 +00:00
github-actions[bot] cfd089a0a3 chore: auto-merge develop → main
Triggered by: ade121f fix: align recipe persons-ctrl inline, enlarge fav star
2026-05-26 18:20:23 +00:00
dadaloop82 ade121f43f fix: align recipe persons-ctrl inline, enlarge fav star 2026-05-26 18:18:42 +00:00
github-actions[bot] 2f665f777b chore: auto-merge develop → main
Triggered by: f46b12e feat: recipe favorites (#124), portion rescaler (#123), financial waste report (#117), macronutrient panel (#118)
2026-05-26 18:11:28 +00:00
dadaloop82 f46b12e3ad feat: recipe favorites (#124), portion rescaler (#123), financial waste report (#117), macronutrient panel (#118)
- #124: star toggle on recipe view + favorites shown first in archive with gold border
- #123: +/- persons buttons on recipe to scale ingredient quantities
- #117: wasted value in EUR displayed in monthly stats section
- #118: macronutrient breakdown panel (P/C/F/fiber bars) with 4th insight rotation phase
- DB: is_favorite column on recipes, nutriments_json on products (auto-migrated)
- OFF API: nutriments fields fetched and stored per product
- Translations: it/en/de/fr/es updated with new keys
2026-05-26 18:09:32 +00:00
github-actions[bot] a932d3de11 chore: auto-merge develop → main
Triggered by: 6120fad chore: update CHANGELOG for v1.7.26
2026-05-26 17:31:48 +00:00
dadaloop82 6120fad40b chore: update CHANGELOG for v1.7.26 2026-05-26 17:30:17 +00:00
github-actions[bot] 8ac6fec5a2 chore: auto-merge develop → main
Triggered by: 4f68925 fix: insight banner rotation 30s → 60s (1 minute per panel)
2026-05-26 17:29:50 +00:00
github-actions[bot] fe7587e9e4 chore: auto-merge develop → main
Triggered by: f4ea9e7 fix: banner rotation (30s interval, skip empty phases) + normalize OFF categories
2026-05-26 17:28:44 +00:00
dadaloop82 4f68925a7c fix: insight banner rotation 30s → 60s (1 minute per panel) 2026-05-26 17:28:22 +00:00
dadaloop82 f4ea9e74e6 fix: banner rotation (30s interval, skip empty phases) + normalize OFF categories
- _startInsightAlternation: 3_600_000ms → 30_000ms so rotation is actually
  visible without waiting an hour
- _applyInsightPhase: smart phase-skip instead of always falling back to waste;
  if current phase has no content, advance to the next non-empty phase
- PHP: add _normalizeCat() mapping OpenFoodFacts slug format ('en:dairies',
  'en:plant-based-foods-and-beverages', …) to Italian app categories; also
  re-aggregates counts after normalization so 'en:dairies' + 'en:milk' both
  count toward 'latticini' correctly
- JS _renderMonthlyStatsSection: guard t() fallback — if t() returns the key
  itself (not found), display a cleaned-up slug instead of the raw key
2026-05-26 17:26:57 +00:00
github-actions[bot] 8f217fd166 chore: auto-merge develop → main
Triggered by: b985247 feat: monthly stats panel in rotating insight banner (closes #100)
2026-05-26 17:21:45 +00:00
dadaloop82 b985247b95 feat: monthly stats panel in rotating insight banner (closes #100)
- Add PHP action 'monthly_stats' + getMonthlyStats() function:
  items consumed/added/wasted this month, trend vs prev month,
  top 5 categories and top 3 products by transaction count
- Expand insight rotation from 2 → 3 phases (waste → nutrition → monthly)
- Add _renderMonthlyStatsSection() following nutrition section styling:
  large indigo number, trend arrow with %, horizontal category bars
  animated on show, badges for added/wasted/top-product
- Add ms-* CSS classes (bar chart layout matching nutr-* design)
- Add stats_monthly translations in it/en/de/fr/es (10 keys each)
2026-05-26 17:19:54 +00:00
github-actions[bot] efbed479df chore: auto-merge develop → main
Triggered by: 695c23f feat: smart shopping extended horizon for staple items (closes #98)
2026-05-26 17:10:43 +00:00
dadaloop82 695c23fc21 feat: smart shopping extended horizon for staple items (closes #98)
Products used ≥ 4x/month (daily staples: latte, pane, uova) now appear in
the smart shopping list when stock will deplete within 28 days (instead of 14).
Products used ≥ 2x/month (weekly staples: yogurt, frutta, carne) appear
within 21 days. The existing ≥1.5x/month rule at 14 days is unchanged.

No manual pin or UI needed: frequency is detected automatically from
transaction history. justRestocked guard prevents noise for freshly
bought items.
2026-05-26 17:09:04 +00:00
github-actions[bot] 7a34406b07 chore: auto-merge develop → main
Triggered by: 50660f6 fix: TTS test ask user if heard + PHP 8 end() ref bug + DB migration guard for fresh volumes
2026-05-26 15:01:40 +00:00
dadaloop82 50660f634f fix: TTS test ask user if heard + PHP 8 end() ref bug + DB migration guard for fresh volumes
- app.js: TTS kiosk timeout 4s → 10s; fires interactive 'Hai sentito?' YES/NO
  prompt instead of showing error (TTS can take 6-8s; UtteranceProgressListener
  may not fire on all firmware); YES → success, NO → troubleshooting steps
- translations: add heard_question/heard_yes/heard_no/test_ok_kiosk/test_fail_steps
  to all 5 languages (it/en/de/fr/es) under settings.tts
- api/index.php: fix end() PHP 8.0+ reference error in _offFetchProduct()
  (categories_hierarchy stored in temp var before calling end())  (fixes #130)
- api/database.php: migrateDB() now checks sqlite_master for 'products' table;
  if missing, calls initializeDB() and returns — no ALTER on nonexistent table
  (fixes #133, covers #131)
- api/index.php: health_check db_row_count query guarded against missing
  inventory table (fixes #131)
2026-05-26 14:59:17 +00:00
github-actions[bot] fb06b42107 chore: auto-merge develop → main
Triggered by: c16067d fix(kiosk): scale retry restores nav buttons + settings link to webapp
2026-05-26 14:44:57 +00:00
dadaloop82 c16067d9e5 fix(kiosk): scale retry restores nav buttons + settings link to webapp
SetupActivity:
- btnTestRetry click handler: add step3NextButtons.visibility = VISIBLE
  so the Indietro/Avanti buttons reappear after pressing No-retry
  (previously they stayed hidden → user was stuck with no way to go back)
- onDisconnected(): always re-enable btnTestRetry so user is never stuck
  when scale drops unexpectedly before a weight reading arrives

SettingsActivity / activity_settings.xml:
- Add 'IMPOSTAZIONI AVANZATE' section explaining that HA, Gemini AI,
- Add '← Torna all'app per le impostazioni avanzate' button (finish())
2026-05-26 14:42:27 +00:00
github-actions[bot] 605d8590f6 chore: auto-merge develop → main
Triggered by: 149cff3 fix(kiosk): force STREAM_MUSIC for TTS + JS feedback callbacks
2026-05-26 14:04:23 +00:00
dadaloop82 149cff3ca5 fix(kiosk): force STREAM_MUSIC for TTS + JS feedback callbacks
Android TTS was silently failing because engine.speak() used the default
audio stream, which may be muted while the media stream is not.

Changes:
- KioskActivity: speak() now passes Bundle with KEY_PARAM_STREAM=STREAM_MUSIC
  so TTS plays on the same channel as beep/media audio
- KioskActivity: add UtteranceProgressListener that calls window._kioskTtsDone()
  or window._kioskTtsError(uid, code) back into the WebView on completion/failure
- app.js testTTS(): kiosk path now shows real result from Android callbacks
  (success/error) with a 4s fallback timeout showing actionable troubleshooting hints
2026-05-26 14:02:31 +00:00
github-actions[bot] ec7d172ed9 chore: auto-merge develop → main
Triggered by: 0479e34 ci: retry after GitHub CDN outage
2026-05-26 13:53:56 +00:00
dadaloop82 0479e34c7f ci: retry after GitHub CDN outage 2026-05-26 13:52:15 +00:00
dadaloop82 730efe4d87 ci: force Node.js 24 for JS actions (deadline June 2, 2026)
Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true env var to ci.yml,
security.yml and build-kiosk.yml to prevent action failures when
GitHub removes Node 20 runner support on 2026-06-02.

Also re-triggers CI to bypass transient 403 infrastructure failure
that hit the previous run (GitHub CDN outage).
2026-05-26 12:31:37 +00:00
dadaloop82 be3dceeebb feat: add separate sound beep test button + always show move modal on open
- Add testSound() function: plays AudioContext beep independently of TTS
- Add '🔔 Esegui Test Suono' button in TTS settings (above voice test btn)
- Add translations: settings.tts.test_sound_btn (it/en/de)
- Remove hasOtherLocs guard in showMoveAfterUseModal: always show
  location-choice modal when opening a package, regardless of whether
  the product already exists in other locations (e.g. peas only in pantry
  → user can now choose to move opened portion to fridge)
2026-05-26 12:17:18 +00:00
dadaloop82 875250626d fix: prefer offline voices in TTS; add beep + real diagnostics to test
_speakBrowser: voice fallback now prefers localService=true (offline)
voices over cloud voices. On Android Chrome, cloud voices fail silently
when no internet. New priority: local Italian → any Italian → local
any-lang → first available → lang-only.

testTTS (browser engine):
- Plays _playCookingTimerSound('done') beep FIRST (AudioContext, always
  works) so user can confirm audio hardware/volume is working
- Checks if only cloud voices exist → shows actionable error
- Builds utterance with onerror → shows error code on failure
- Adds onstart → shows actual voice name/lang/online status on success
- 2s watchdog: if onstart never fires → 'nessuna risposta' warning
- Shows '🔊 Beep + TTS in corso...' during the test
2026-05-26 12:11:52 +00:00
dadaloop82 245d007e29 fix: Chrome speechSynthesis paused-state bug + better TTS diagnostics
_speakBrowser: Chrome sets speechSynthesis.paused=true after tab is
backgrounded/minimized. Calling cancel() does NOT clear this state.
Adding resume() before speak() inside the setTimeout fixes silent
failure when user returns to the tab (e.g. switch app → back to browser).

testTTS: add diagnostic messages for:
- Android kiosk: check isTtsReady() and show actionable error if
  the native TTS engine hasn't initialized yet
- Web browser: check getVoices().length before speaking; if empty,
  show error asking user to install a voice pack in OS settings
  (instead of always showing ' Riproduzione in corso' even when silent)
2026-05-26 12:04:31 +00:00
github-actions[bot] 63a9f70f86 chore: auto-merge develop → main
Triggered by: 1a6e0c8 fix: robust browser TTS voice selection and Chrome cancel+speak workaround
2026-05-25 17:56:33 +00:00
dadaloop82 1a6e0c87ce fix: robust browser TTS voice selection and Chrome cancel+speak workaround
_speakBrowser() was silently failing in two scenarios:

1. Chrome cancel+speak bug: calling speechSynthesis.speak() immediately
   after cancel() is silently dropped in Chrome. Fixed by adding a 50ms
   setTimeout between cancel() and speak().

2. No voice fallback: when tts_voice preference is empty (never saved)
   and the browser has no default 'it-IT' voice, speech fails silently.
   Now tries: preferred by name → first Italian voice → first any voice
   → lang-only as last resort.

3. Async voice loading: Chrome/Android load voices asynchronously.
   When getVoices() returns empty, now waits for onvoiceschanged (with
   a 500ms safety timeout) before speaking.
2026-05-25 17:55:04 +00:00
github-actions[bot] 73f43cb296 chore: auto-merge develop → main
Triggered by: baed815 fix: translate CATEGORY_LABELS and #pf-category after i18n load
2026-05-25 17:43:55 +00:00
dadaloop82 baed815a48 fix: translate CATEGORY_LABELS and #pf-category after i18n load
CATEGORY_LABELS was built at parse time before translations were
available, causing raw keys like 'categories.verdura' to appear
in all category labels, dropdowns and inventory filters.

_applyI18nToLabels() now also:
- Rebuilds each CATEGORY_LABELS entry using the loaded i18n strings
  and the corresponding CATEGORY_ICONS icon prefix
- Regenerates the static #pf-category <select> options so the
  product-add form also shows translated category names
2026-05-25 17:42:10 +00:00
github-actions[bot] 8aa934f5ca chore: auto-merge develop → main
Triggered by: 83b5eb3 fix: browser TTS ignored when HA TTS configured; wrong ha_hint i18n key
2026-05-25 10:38:53 +00:00
dadaloop82 83b5eb3063 fix: browser TTS ignored when HA TTS configured; wrong ha_hint i18n key
- speakCookingStep: respect tts_engine='browser' explicitly; if user
  chose browser engine, always use _speakBrowser regardless of HA.
  Also add per-engine try/catch so HA or server TTS failures fall back
  to browser TTS instead of silently doing nothing.
- index.html: fix data-i18n='settings.tts.ha_hint' → 'settings.ha.ha_hint'
  (key lived in settings.ha not settings.tts)
2026-05-25 10:37:17 +00:00
github-actions[bot] 59c6f9d76c chore: auto-merge develop → main
Triggered by: bac9485 fix: hide ingredients panel in cooking mode
2026-05-25 10:21:51 +00:00
dadaloop82 bac9485e4e fix: hide ingredients panel in cooking mode
Ingredients are already shown (with use buttons) in the recipe
result screen. In cooking mode only steps should appear.
2026-05-25 10:20:17 +00:00
github-actions[bot] 11178af001 chore: auto-merge develop → main
Triggered by: 4e4a736 feat: ask replace vs save-to-archive on regenerate recipe
2026-05-25 10:13:43 +00:00
dadaloop82 4e4a736dba feat: ask replace vs save-to-archive on regenerate recipe
When user clicks 'Generate another', show a choice:
- Replace (discard current, generate new) — former behavior
- Save to archive & generate new — saves current recipe first

All 5 languages (it/en/de/fr/es) with regen_choice_title,
regen_replace, regen_save_new keys.
2026-05-25 10:11:56 +00:00
dadaloop82 52afdd6bfa fix(recipes): steps shown as raw JSON when AI uses instruction/appliance_function objects
- _stepStr: parse JSON-string steps; handle s.instruction key (backward-compat with already-saved recipes)
- _stepAppliance: new helper to extract appliance_function hint; returns null for 'Nessuno'/'None'
- renderRecipe steps list: shows appliance badge inline after step text when present
- CSS: .recipe-step-appliance badge (green chip, dark-mode variant)
- Prompt (both generateRecipe + generateRecipeStream): rule 9/10 explicitly forbids step objects;
  appliance info must be embedded in the step text string directly
2026-05-25 10:01:10 +00:00
github-actions[bot] 3d27433eb3 chore: auto-merge develop → main
Triggered by: eddb622 feat(offline): full offline mode — cache sync, write queue, startup recovery
2026-05-25 09:27:09 +00:00
dadaloop82 eddb622c85 feat(offline): full offline mode — cache sync, write queue, startup recovery
- Full-screen network error overlay (z-index 300000, above screensaver)
- 'Continue offline' button after 3s, auto-enter after 8s
- Inventory + settings synced to localStorage at startup (during health check)
- inventory_summary and stats computed from local cache while offline
- Write queue (add/use/update/delete): optimistic UI + sync on reconnect
- Pending ops survive page refresh — detected and re-synced at next startup
- Buffered remoteLog/reportError flushed to server (GitHub issues) on restore
- AI/network sections hidden in offline mode (CSS body.offline-mode)
- Banner: pulsing dot while loading cache, item count when ready
- Broken external images replaced with grey SVG placeholder
- Fix: opened items marked is_edible:true offline (was flooding banner)
- Fix: _semverGt() prevents update badge for older GitHub releases
- Bump version to v1.7.25
2026-05-25 09:05:19 +00:00
github-actions[bot] 95c20adbbd chore: auto-merge develop → main
Triggered by: 6fa2e4d docs: HA integration section first in README, with HACS buttons and full feature table
2026-05-23 20:43:02 +00:00
dadaloop82 6fa2e4d830 docs: HA integration section first in README, with HACS buttons and full feature table
- Replaces the old 'New' callout with a proper dedicated section at the top of Features
- Shows all 16 sensors, 6 binary sensors, 5 buttons, todo, calendar, text, 6 services
- HACS and config_flow_start my.home-assistant.io badge buttons

Also: fix dark mode not persisting — _setThemeMode now saves immediately to server .env
2026-05-23 20:41:29 +00:00
github-actions[bot] 6ff1dfe0cc chore: auto-merge develop → main
Triggered by: 43e0ac9 feat(ha): v1.1.0 backend — haCalendar, haSuggestRecipe, haRefreshPrices, haClearExpired + enriched haInventorySensor
2026-05-23 20:24:46 +00:00
dadaloop82 43e0ac9da3 feat(ha): v1.1.0 backend — haCalendar, haSuggestRecipe, haRefreshPrices, haClearExpired + enriched haInventorySensor
New endpoints:
- ha_calendar: returns all expiry dates as calendar events
- ha_suggest_recipe: AI recipe suggestion from expiring items (Gemini)
- ha_refresh_prices: recompute shopping total from price cache only
- ha_clear_expired: delete zero-stock expired rows

haInventorySensor now returns:
- items_dispensa, items_frigo, items_freezer, items_other
- low_stock_items, zero_stock_items
- ai_calls_month, last_backup_at
- days_to_next_expiry, next_expiry_name, next_expiry_date
- bring_connected, shopping_total, price_tracking_enabled, price_currency
2026-05-23 20:22:51 +00:00
github-actions[bot] 1ce32cb5f0 chore: auto-merge develop → main
Triggered by: d75cde7 feat(ha): add expiring_today, next_expiry_name/date, expires_today flag
2026-05-23 20:09:32 +00:00
dadaloop82 d75cde7eb6 feat(ha): add expiring_today, next_expiry_name/date, expires_today flag 2026-05-23 20:07:54 +00:00
github-actions[bot] 43fe1c7bb5 chore: auto-merge develop → main
Triggered by: b2c87ae feat(ha): enrich ha_sensor with opened_items, shopping_total, price_tracking, expiring_3d
2026-05-23 20:00:36 +00:00
dadaloop82 b2c87ae343 feat(ha): enrich ha_sensor with opened_items, shopping_total, price_tracking, expiring_3d 2026-05-23 19:58:49 +00:00
github-actions[bot] fbdae35516 chore: auto-merge develop → main
Triggered by: d9ebc51 fix: ha_sensor JOIN products for name/unit columns (was HTTP 500)
2026-05-23 14:39:37 +00:00
dadaloop82 d9ebc51e71 fix: ha_sensor JOIN products for name/unit columns (was HTTP 500) 2026-05-23 14:38:01 +00:00
github-actions[bot] 56ca58bc18 chore: auto-merge develop → main
Triggered by: b2e0f6d feat: add ha_info, ha_shopping_items endpoints and avahi mDNS service file for HACS integration
2026-05-23 13:25:19 +00:00
dadaloop82 b2e0f6d683 feat: add ha_info, ha_shopping_items endpoints and avahi mDNS service file for HACS integration
- api/index.php: new haGetInfo() endpoint (unique_id, version, instance, items count)
- api/index.php: new haGetShoppingItems() endpoint (Bring! + internal shopping list)
- api/index.php: haInventorySensor() now accepts ?expiry_days=N query param
- api/cron_smart_shopping.php: auto-register avahi mDNS service if avahi-daemon present
- docker/avahi-evershelf.xml: Zeroconf _evershelf._tcp service declaration
- .env.example: add INSTANCE_NAME variable (used by HA integration for device label)
2026-05-23 13:23:28 +00:00
github-actions[bot] ddb9bd9f75 chore: auto-merge develop → main
Triggered by: 965a672 feat: full Home Assistant integration
2026-05-23 12:30:02 +00:00
dadaloop82 965a672abe feat: full Home Assistant integration
- PHP: _fireHaWebhook(), _sendHaNotify(), haInventorySensor(), haTestConnection()
- PHP: ha_sensor + ha_test routing actions
- PHP: getServerSettings() exposes ha_token (consistent with tts_token)
- PHP: saveSettings() handles all HA_* env keys (url, token, tts_entity, webhook_id, events, notify_service, expiry_days)
- PHP: bringAddItems(), shoppingAdd(), updateInventory() fire shopping_add / stock_update webhooks
- Cron: daily HA expiry/expired webhook + push notify with flag-file guard
- HTML: 🏠 Settings tab button + full HA panel (connection, TTS, webhook, notify, sensor cards)
- JS: serverKeys + loadSettingsUI extended with HA fields
- JS: _applyHaSettingsUI(), _loadHaTab(), _renderHaSensorYaml()
- JS: onHaEnabledChange(), testHaConnection(), applyHaTtsPreset()
- JS: saveHaSettings(), copyHaSensorYaml(), showHaWebhookHelp()
- JS: _buildHaTtsRequest() for HA media_player TTS
- JS: speakCookingStep() now supports HA TTS as first-priority path
- JS: onTtsEngineChange() fixed to show server section for both 'server' and 'custom'
- Translations: settings.ha.* (52 keys) in all 5 languages (it/en/de/fr/es)
- .env.example: HA_ENABLED/URL/TOKEN/TTS_ENTITY/WEBHOOK_ID/EVENTS/NOTIFY_SERVICE/EXPIRY_DAYS
- docs/wiki/Home-Assistant.md: new wiki page (REST sensors, webhooks, TTS, push notify, troubleshooting)
- README: HA integration highlighted as first feature block
2026-05-23 12:28:09 +00:00
github-actions[bot] 7249daa8eb chore: auto-merge develop → main
Triggered by: ec53f75 docs: update README
2026-05-23 12:11:11 +00:00
dadaloop82 ec53f7529c docs: update README 2026-05-23 12:09:37 +00:00
github-actions[bot] 1074dff87d chore: auto-merge develop → main
Triggered by: 3989d11 fix: step.replace is not a function when Gemini returns steps as objects
2026-05-23 11:57:33 +00:00
dadaloop82 3989d11094 fix: step.replace is not a function when Gemini returns steps as objects
- PHP generateRecipeStream: normalize recipe.steps to plain strings after
  parsing Gemini JSON (handles [{text:'...'}, ...] objects gracefully)
- JS: add _stepStr(s) helper near cooking mode — safely extracts text from
  a step regardless of type (string or object {text/description/step key})
  and strips leading 'Passo N:' prefix in one place
- JS: replace all 7 manual step.replace(/^Passo.../) calls with _stepStr()
  across renderRecipe, renderCookingStep, startCookingMode, replayCookingTTS,
  toggleCookingTTS, navigateCookingStep — no more crash if Gemini schema drifts
2026-05-23 11:55:55 +00:00
github-actions[bot] b010ced1a6 chore: auto-merge develop → main
Triggered by: cc0fa09 fix: recipe errors now show specific cause instead of generic 'connection error'
2026-05-23 11:47:00 +00:00
dadaloop82 cc0fa09219 fix: recipe errors now show specific cause instead of generic 'connection error'
- PHP generateRecipeStream: wrap entire body in try/catch(\Throwable) to catch
  any PHP fatal/exception mid-stream and send it as a proper SSE error event
- PHP: curl timeout raised 60s→90s; capture curl errno/errmsg on failure
- PHP: HTTP error messages now include a human-readable status label
  (e.g. 'Quota API esaurita (429)', 'Nessuna risposta da Gemini (cURL: ...)')
- JS catch block: show err.message alongside error.connection so the actual
  JS network error (NetworkError, AbortError, etc.) is visible
- JS no-recipe+no-error path: show recipes.stream_interrupted instead of
  generic error.connection
- Translation: added recipes.stream_interrupted in it/en/de
2026-05-23 11:45:26 +00:00
github-actions[bot] c0a076749e chore: auto-merge develop → main
Triggered by: 6a41b53 feat: shopping list pantry hints, barcode multi-API fallback (OPF/beauty/Gemini), README disclaimer
2026-05-23 09:54:56 +00:00
dadaloop82 6a41b53174 feat: shopping list pantry hints, barcode multi-API fallback (OPF/beauty/Gemini), README disclaimer
- Shopping list: each item now shows 'Hai già Xg in dispensa' for same-family inventory stock
  - Lazy-loads inventory once per shopping page visit (_getShoppingInventoryCache)
  - Matches by first significant token (same logic as related-stock on action page)
  - Green hint below item badges, dark-mode aware (.shopping-pantry-hint)
- Barcode lookup: added Open Products Facts + Open Beauty Facts as step 3;
  Gemini AI (_barcodeLookupGemini) as final step 4 fallback
- Added stockForName PHP endpoint (stock_for_name action) for future use
- Restored missing function signatures for _offFetchProduct() and saveProduct()
  that were accidentally lost when stockForName was added in a previous session
- Translation: added shopping.pantry_hint in it/en/de
2026-05-23 09:53:17 +00:00
github-actions[bot] 1d04236bc0 chore: auto-merge develop → main
Triggered by: 561c6e9 ci: fix auto-merge — clear checkout extraheader so WORKFLOW_PAT actually reaches git push
2026-05-23 09:37:26 +00:00
dadaloop82 561c6e9809 ci: fix auto-merge — clear checkout extraheader so WORKFLOW_PAT actually reaches git push 2026-05-23 09:35:53 +00:00
dadaloop82 6857c20893 ci: fix checkout to use github.token, WORKFLOW_PAT only for push 2026-05-23 09:23:48 +00:00
dadaloop82 964de98203 ci: test auto-merge with WORKFLOW_PAT secret 2026-05-23 09:18:03 +00:00
dadaloop82 e28a6e4e39 ci: use WORKFLOW_PAT for auto-merge to allow pushing workflow file changes 2026-05-23 08:37:30 +00:00
dadaloop82 fd9e2471e0 fix: remove invalid 'workflows' permission from ci.yml 2026-05-23 08:30:57 +00:00
dadaloop82 3c8a9693b2 fix: isSuspiciousDefaultQty — use package_unit threshold for pz+g/ml products 2026-05-23 08:28:58 +00:00
dadaloop82 b38bdc45f5 fix: add workflows:write permission to auto-merge job 2026-05-23 08:27:04 +00:00
dadaloop82 83a0df272a fix: CI auto-merge push auth — set remote URL with GITHUB_TOKEN before push 2026-05-23 08:21:26 +00:00
dadaloop82 6320b575e0 v1.7.25 — partial throw from banner, barcode fallback, related stock, Bring! re-add fix
- Fix: Bring! items re-appearing after manual removal (missing _markBringPurchased call in removeBringItem / confirmShoppingItemFound; autoAddCriticalItems now respects blocklist for qty=0 items)
- Fix: barcode false 'not found' — new _offFetchProduct() helper tries UPC-A↔EAN-13 candidates × 2 locales with auto-retry; UPCItemDB fallback also iterates candidates
- Fix: bannerThrowAway() now opens partial-throw modal (location + qty input + throw-all button) instead of immediately discarding everything
- Add: related stock card on action page — shows same-family inventory items when scanning a branded product
2026-05-23 08:17:20 +00:00
github-actions[bot] 8ccd218c5a chore: auto-merge develop → main
Triggered by: 5c1afaa feat: redesign action-page hero card (match use-page style)
2026-05-22 21:25:12 +00:00
dadaloop82 5c1afaaaf5 feat: redesign action-page hero card (match use-page style) 2026-05-22 21:23:18 +00:00
github-actions[bot] 6245b15420 chore: auto-merge develop → main
Triggered by: 02f673a fix: include dark_mode in save_settings payload (was never written to .env)
2026-05-22 21:15:51 +00:00
dadaloop82 02f673a164 fix: include dark_mode in save_settings payload (was never written to .env) 2026-05-22 21:14:16 +00:00
github-actions[bot] 61bb1b5552 chore: auto-merge develop → main
Triggered by: cbf4bd5 feat: show expiry + qty pills on use-page product hero card; redesign card with accent border
2026-05-22 21:11:53 +00:00
dadaloop82 cbf4bd54da feat: show expiry + qty pills on use-page product hero card; redesign card with accent border 2026-05-22 21:10:19 +00:00
github-actions[bot] 1cdbdb3b25 chore: auto-merge develop → main
Triggered by: 837d62c fix: add missing _ensureAudioUnlocked() definition; use shared AudioContext in _playCookingTimerSound
2026-05-22 13:15:59 +00:00
dadaloop82 837d62c335 fix: add missing _ensureAudioUnlocked() definition; use shared AudioContext in _playCookingTimerSound 2026-05-22 13:14:21 +00:00
github-actions[bot] fa36ba83bf chore: auto-merge develop → main
Triggered by: 1efeaf9 fix: skip use-all confirm when only one item row and no open packages
2026-05-22 04:51:02 +00:00
dadaloop82 1efeaf9236 fix: skip use-all confirm when only one item row and no open packages 2026-05-22 04:49:27 +00:00
dadaloop82 573bcd1102 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-21 18:45:46 +00:00
dadaloop82 426cc9df7e release: merge develop → main for v1.7.24 2026-05-21 18:45:35 +00:00
dadaloop82 6f2d6d9944 release: v1.7.24 — changelog, readme badge, manifest version 2026-05-21 18:45:17 +00:00
github-actions[bot] d3eb82eee2 chore: auto-merge develop → main
Triggered by: 98426bf fix: dark_mode persisted in server .env (not localStorage) — add to saveSettings, getServerSettings, applySyncedSettings
2026-05-21 18:42:49 +00:00
dadaloop82 98426bf861 fix: dark_mode persisted in server .env (not localStorage) — add to saveSettings, getServerSettings, applySyncedSettings 2026-05-21 18:41:12 +00:00
github-actions[bot] 264b1f648e chore: auto-merge develop → main
Triggered by: b89df96 fix: dark mode resets to auto on reload — bootstrap dark_mode from localStorage in getSettings()
2026-05-21 18:35:19 +00:00
dadaloop82 b89df961a6 fix: dark mode resets to auto on reload — bootstrap dark_mode from localStorage in getSettings() 2026-05-21 18:33:33 +00:00
github-actions[bot] 5e34bc90b3 chore: auto-merge develop → main
Triggered by: 3b100df fix: cooking timer sound/TTS — shared pre-unlocked AudioContext; always speak on alarm regardless of TTS toggle; kiosk bridge TTS check
2026-05-21 18:20:24 +00:00
dadaloop82 3b100df26c fix: cooking timer sound/TTS — shared pre-unlocked AudioContext; always speak on alarm regardless of TTS toggle; kiosk bridge TTS check 2026-05-21 18:18:45 +00:00
github-actions[bot] 2ecb3cbac6 chore: auto-merge develop → main
Triggered by: c2004fd fix: scale use — auto-switch conf→sub (g/ml) when scale is active; show scale btn for conf+weight products
2026-05-21 06:02:55 +00:00
dadaloop82 c2004fd0f8 fix: scale use — auto-switch conf→sub (g/ml) when scale is active; show scale btn for conf+weight products 2026-05-21 06:01:04 +00:00
github-actions[bot] fba0947945 chore: auto-merge develop → main
Triggered by: 3a1f6cf fix: cooking timer — AudioContext.resume() on mobile; always play beep; show done card 3s before dismiss
2026-05-20 18:32:10 +00:00
dadaloop82 3a1f6cfd1e fix: cooking timer — AudioContext.resume() on mobile; always play beep; show done card 3s before dismiss 2026-05-20 18:30:25 +00:00
github-actions[bot] 37fb522e8b chore: auto-merge develop → main
Triggered by: 66f5a03 fix: wrap updateInventory DB writes in a transaction to prevent concurrent lock errors (#109 #110)
2026-05-20 15:40:15 +00:00
dadaloop82 66f5a03503 fix: wrap updateInventory DB writes in a transaction to prevent concurrent lock errors (#109 #110) 2026-05-20 15:38:34 +00:00
dadaloop82 a37d97dfcd fix: kiosk — permanently hide native settings btn; add web ⚙️ in overlay; remove scattered show/hide calls 2026-05-20 15:37:57 +00:00
github-actions[bot] 47197d0d66 chore: auto-merge develop → main
Triggered by: 1496216 fix: depleted items urgency based on usesPerMonth only (not recency)
2026-05-20 13:47:41 +00:00
dadaloop82 149621651d fix: depleted items urgency based on usesPerMonth only (not recency) 2026-05-20 13:46:00 +00:00
github-actions[bot] b5a6daa557 chore: auto-merge develop → main
Triggered by: ccc2f89 fix: depleted items urgency — use buyCount/useCount from internal history to assign medium/low
2026-05-20 13:42:45 +00:00
dadaloop82 ccc2f8907d fix: depleted items urgency — use buyCount/useCount from internal history to assign medium/low 2026-05-20 13:41:04 +00:00
github-actions[bot] 9e80915a61 chore: auto-merge develop → main
Triggered by: 7b60f1d fix: 0.5 conf use page (default conf mode + fraction btns); depleted items always in shopping; conf decimals in history log
2026-05-20 13:37:35 +00:00
dadaloop82 7b60f1dbe3 fix: 0.5 conf use page (default conf mode + fraction btns); depleted items always in shopping; conf decimals in history log 2026-05-20 13:35:27 +00:00
github-actions[bot] 7019160704 chore: auto-merge develop → main
Triggered by: ac8b5ac fix: restore Bring! health check; token warning only when truly invalid
2026-05-19 17:27:50 +00:00
dadaloop82 ac8b5acc0c fix: restore Bring! health check; token warning only when truly invalid
- Restore the if($bringEnabled) block that was accidentally removed in fa0442e
- Check is skipped entirely when SHOPPING_MODE != bring or credentials not set
- Missing token file = first launch, auto-created on next shopping open → ok:true
- Warning shown only if token file exists but access_token field is missing (corrupt)
- Expired tokens are OK (refreshed automatically)

Fixes spurious 'Token Bring!' warning on installs without Bring! configured
2026-05-19 17:26:06 +00:00
github-actions[bot] 34df755ba3 chore: auto-merge develop → main
Triggered by: 87eac17 fix: recipe quantities for conf+weight; move modal remembers location
2026-05-19 16:53:15 +00:00
dadaloop82 87eac171bf fix: recipe quantities for conf+weight; move modal remembers location
- PHP (all 3 recipe endpoints): conf products with weight/volume package_unit
  (e.g., 300g/conf) now keep qty_number in sub-units (grams/ml) instead of
  converting to fractional conf. Post-normalisation block converts any AI-
  returned fractional conf values to grams automatically.
- JS submitRecipeUse: vacuum state now read from actual inventory item at the
  used location (_recipeUseContext.items), not from recipe ingredient data.
- JS showRecipeMoveModal: now uses _prefMoveLocCache preference system —
  after 2 consistent choices the modal is skipped automatically. 'Stay' button
  records the choice. Added _recipeMoveCancelStay() helper.
- JS confirmRecipeMove: records move choice via _recordMoveLocChoice();
  accepts optional forcedVacuum param for preference-triggered auto-moves.

Closes #108 (duplicate of #107 — data dir permissions)
2026-05-19 16:51:37 +00:00
github-actions[bot] ef15f3536c chore: auto-merge develop → main
Triggered by: f77b325 refactor: remove localStorage for settings — all settings server-centralised
2026-05-19 16:42:55 +00:00
dadaloop82 f77b3259ad refactor: remove localStorage for settings — all settings server-centralised
- getSettings() no longer reads from localStorage; uses _settingsCache only
- saveSettingsToStorage() no longer writes to localStorage
- _applySyncedSettings() no longer writes to localStorage
- syncSettingsFromDB() meal_plan/tts_voice blocks no longer write to localStorage
- loadSettingsUI() server-merge block no longer writes to localStorage
- flipCamera() saves camera_facing directly to server via _saveSettingToServer()
- New helper _saveSettingToServer(data): calls save_settings API for partial updates
- onShoppingEnabledChange() / onShoppingModeChange() now immediately persist to
  server .env via _saveSettingToServer() — no wait for Save button
- Early-theme IIFE: reads dedicated evershelf_dark_mode key (falls back to old
  evershelf_settings for backward compat) — only dark_mode kept in localStorage
  as a technical necessity for pre-render theme application
2026-05-19 16:41:07 +00:00
github-actions[bot] 5ad24ed73b chore: auto-merge develop → main
Triggered by: 84934c1 fix: sync shopping settings across clients (serverKeys was missing shopping_* keys)
2026-05-19 16:37:41 +00:00
dadaloop82 84934c1908 fix: sync shopping settings across clients (serverKeys was missing shopping_* keys) 2026-05-19 16:35:54 +00:00
github-actions[bot] dd0625b253 chore: auto-merge develop → main
Triggered by: fa0442e feat: native shopping list — decouple from Bring! (#105)
2026-05-19 16:07:33 +00:00
dadaloop82 fa0442e2f6 feat: native shopping list — decouple from Bring! (#105)
- New shopping_list SQLite table (migration in migrateDB)
- shoppingGetList/Add/Remove — delegates to Bring! or internal DB
  based on SHOPPING_MODE env var (default: internal)
- isShoppingBringMode() guard: requires mode=bring + BRING credentials
- bringQuickSyncProduct updated to support both modes
- All bring_* JS calls replaced with shopping_* (bring_migrate_names kept)
- New settings tab 'Lista spesa' (tab-bring) with:
  - Enable/disable shopping list toggle
  - Provider radio: internal vs Bring!
  - Bring! sub-section (shown only when mode=bring)
  - AI smart suggestions toggle
  - Forecast toggle
  - Auto-add threshold (qty slider)
  - Price estimation section
- _applyShoppingSettingsUI, onShoppingEnabledChange, onShoppingModeChange
- SHOPPING_* env vars documented in .env.example
- cron_smart_shopping respects SHOPPING_MODE and SHOPPING_SMART_SUGGESTIONS
- Translations: 12 new keys in all 5 languages (it/en/de/fr/es)
- DB busy_timeout=5000ms + WAL pragma in getDB() (fixes #95)
2026-05-19 16:05:49 +00:00
github-actions[bot] a85414d790 chore: auto-merge develop → main
Triggered by: c07439f docs: add Contributing + Community section to README
2026-05-18 19:12:04 +00:00
dadaloop82 c07439fea4 docs: add Contributing + Community section to README
- New '🤝 Contributing' section with translation table, skill matrix,
  and links to good first issues and help wanted labels
- New '💬 Community' section linking to GitHub Discussions
- Visible call-to-action for translators pointing to issue #93
2026-05-18 19:10:00 +00:00
github-actions[bot] 8f6934485a chore: auto-merge develop → main
Triggered by: d7aadff fix(kiosk): target SDK 35 + setInstallReason for Android 16 compatibility
2026-05-18 19:06:15 +00:00
dadaloop82 d7aadff598 fix(kiosk): target SDK 35 + setInstallReason for Android 16 compatibility
- compileSdk/targetSdk 34 → 35 (Android 15 stable)
- versionCode 17 → 18, versionName 1.7.16 → 1.7.17
- PackageInstaller.SessionParams: add setInstallReason(INSTALL_REASON_USER)
  on API 26+ — required on Android 14+ to avoid STATUS_FAILURE=1 on self-update
- DownloadManager failure: report dm_status + dm_reason in error payload
  so future issues include the HTTP error code (e.g. 404 vs network error)

Fixes #91 #92
2026-05-18 19:04:32 +00:00
github-actions[bot] d8aff8ac04 chore: auto-merge develop → main
Triggered by: 7364e75 feat: Google Drive OAuth via http://localhost redirect (no public domain required)
2026-05-18 18:44:00 +00:00
dadaloop82 7364e75881 feat: Google Drive OAuth via http://localhost redirect (no public domain required)
- Switch redirect URI from server IP to http://localhost (works everywhere)
- Add manual code exchange flow: user copies URL from browser, pastes in app
- New PHP action gdrive_oauth_exchange to exchange auth code for refresh token
- Fix  null bug in gdrive_oauth_exchange (was read before initialization)
- Add #gdrive-code-section UI with input + submit button in index.html
- Update _gdriveAuthorize() to show code section and store redirect_uri
- Add _gdriveSubmitCode() JS function for manual code submission
- Update setup wizard and backup tab to show http://localhost as redirect URI
- Add 5 new translation keys (gdrive_redirect_uri_hint, gdrive_code_title,
  gdrive_code_hint, gdrive_code_submit, gdrive_code_empty) in all 5 languages
- Update gdrive_oauth_steps in all translations to reflect new flow
- Document Google Drive OAuth setup in README.md
- Dark mode: comprehensive fix for 30+ components with hardcoded light colors
2026-05-18 18:41:56 +00:00
github-actions[bot] ff25307662 chore: auto-merge develop → main
Triggered by: 4515ff7 i18n: replace all hardcoded Italian strings with English
2026-05-18 07:34:28 +00:00
dadaloop82 4515ff7246 i18n: replace all hardcoded Italian strings with English
- api/index.php: health check hints (disk space, DB, Gemini, TTS, scale,
  internet) translated to English; Bring! error strings (credentials,
  list not found, fetch error, missing params) translated; Gemini chat
  and identify_product error strings translated; transaction note
  [Correzione manuale] -> [Manual correction]
- assets/js/app.js: expiry scanner result strings now use t() keys
  (scanner.expiry_found, scanner.expiry_read_fail, scanner.expiry_raw_label);
  removed Italian fallback from kiosk native_update_hint toast
- translations/{it,en,de}.json: added scanner.expiry_found,
  scanner.expiry_read_fail, scanner.expiry_raw_label keys
- README.md: 'Generali' tab label -> 'General' (2 occurrences)
2026-05-18 07:32:41 +00:00
50 changed files with 11947 additions and 1606 deletions
+80 -4
View File
@@ -89,14 +89,90 @@ PRICE_CURRENCY=EUR
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
PRICE_UPDATE_MONTHS=3
# ── Cleanup / retention ──────────────────────────────────────────────────────
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
RECIPE_RETENTION_DAYS=7
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
# Smart Shopping uses this history to compute purchase frequencies.
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
# Minimum enforced at runtime: 30 days.
TRANSACTION_RETENTION_DAYS=90
# ── Local Backup ─────────────────────────────────────────────────────────────
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
BACKUP_ENABLED=true
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
BACKUP_RETENTION_DAYS=3
# ── Google Drive Backup ───────────────────────────────────────────────────────
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
GDRIVE_ENABLED=false
#
# Setup steps:
# 1. Create a Google Cloud project and enable the Drive API
# 2. Create a Service Account and download the JSON key
# 3. Create a Drive folder and share it with the service account email
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
#
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
GDRIVE_SERVICE_ACCOUNT_JSON=
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
GDRIVE_SERVICE_ACCOUNT_FILE=
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
GDRIVE_FOLDER_ID=
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
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.
INSTANCE_NAME=
# ── Home Assistant Integration ────────────────────────────────────────────────
# All HA settings can also be configured from the Settings → 🏠 tab.
#
# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors)
HA_ENABLED=false
# HA_URL: base URL of your HA instance — no trailing slash
# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123
HA_URL=
# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens)
HA_TOKEN=
# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room)
HA_TTS_ENTITY=
# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger
HA_WEBHOOK_ID=
# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for
# Available: expiry, shopping_add, stock_update, barcode_scan
HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update
# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone)
HA_NOTIFY_SERVICE=
# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3)
HA_EXPIRY_DAYS=3
# ── Developer / demo ─────────────────────────────────────────────────────────
# 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
+3
View File
@@ -1,5 +1,8 @@
name: Build & Release Kiosk APK
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
push:
branches: [main]
+35 -1
View File
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lint-php:
name: PHP Syntax Check
@@ -102,7 +105,9 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
# WORKFLOW_PAT is only needed for the push step below.
token: ${{ github.token }}
- name: Configure git bot identity
run: |
@@ -111,6 +116,15 @@ jobs:
- name: Merge develop → main
run: |
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
# that silently overrides any credentials embedded in git remote URLs.
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
# changes are rejected.
# ────────────────────────────────────────────────────────────────────
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
LAST=$(git log --oneline -1 origin/develop)
git checkout main
git pull --ff-only origin main
@@ -118,6 +132,26 @@ jobs:
-m "chore: auto-merge develop → main
Triggered by: $LAST"
# ── PUSH STRATEGY ───────────────────────────────────────────────────
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
# → can push workflow file changes; set as a repo secret.
# Priority 2: GITHUB_TOKEN fallback
# → cannot push workflow files; strip them from the merge commit.
# ────────────────────────────────────────────────────────────────────
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
if [ -z "$PUSH_TOKEN" ]; then
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
if [ -n "$WF" ]; then
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
echo "$WF"
git checkout origin/main -- .github/workflows/
git diff --cached --quiet || git commit --amend --no-edit
fi
PUSH_TOKEN="${{ github.token }}"
fi
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
git push origin main
# ── Auto-create GitHub Release on main ───────────────────────────────────
+3
View File
@@ -1,5 +1,8 @@
name: Security Scan (Trivy)
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
push:
branches: [main, develop]
+14
View File
@@ -1,5 +1,19 @@
RewriteEngine On
# 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
RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+144
View File
@@ -11,6 +11,150 @@ 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.37] - 2026-06-04
### Fixed
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
### Changed
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
## [1.7.36] - 2026-06-04
### Added
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
### Fixed
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
## [1.7.35] - 2026-06-02
### Fixed
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
- **Recipe persons +/ buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
## [1.7.34] - 2026-05-30
### Added
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
## [1.7.33] - 2026-05-29
### Fixed
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
## [1.7.32] - 2026-05-29
### Changed
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
## [1.7.31] - 2026-05-29
### Fixed
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
## [1.7.30] - 2026-05-29
### Fixed
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
## [1.7.29] - 2026-05-29
### Added
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
## [1.7.28] - 2026-05-30
### Fixed
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135#183).
## [1.7.27] - 2026-05-29
### Added
- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`.
- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count).
- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info.
- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`.
- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos.
- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days).
### Fixed
- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal).
- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date.
### Docs
- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`.
## [1.7.26] - 2026-05-26
### Added
- **Monthly stats panel** — Third rotating card in the insight banner (anti-waste → nutrition → monthly stats, 1 minute each). Shows products consumed this month with a trend vs. the previous calendar month (↑/↓/→ with % delta), animated horizontal category bars, and badges for items added, wasted, and top-used product. Falls back gracefully when the current month has no transactions. Closes [#100](https://github.com/dadaloop82/EverShelf/issues/100).
- **Extended smart-shopping horizon for staples** — Items consumed ≥ 4 times/month now get a 28-day look-ahead window; ≥ 2 times/month get 21 days. Frequently used staples no longer disappear from the smart list between restocks. Closes [#98](https://github.com/dadaloop82/EverShelf/issues/98).
### Fixed
- **TTS test interactive confirmation** — Test timeout raised from 4 s to 10 s; instead of an error, the UI shows a YES/NO prompt ("Did you hear it?") so users can confirm or report failure explicitly.
- **`end()` PHP 8 reference error** — `_offFetchProduct()` passed the result of `??` directly to `end()`, which requires a variable. Fixed with a temporary variable.
- **Database migration crash on fresh installs** — `migrateDB()` tried to rename the `transactions` table before it existed. A `sqlite_master` guard now calls `initializeDB()` and returns early when the schema is absent. Closes [#131](https://github.com/dadaloop82/EverShelf/issues/131), [#133](https://github.com/dadaloop82/EverShelf/issues/133).
- **Health-check crash on empty database** — `db_row_count` query was executed even when the `inventory` table was missing, causing a fatal PDO error. The query is now skipped until the schema is fully initialised. Closes [#132](https://github.com/dadaloop82/EverShelf/issues/132).
- **Insight banner stuck on one panel** — Rotation interval was 1 hour (effectively invisible); now 60 seconds. `_applyInsightPhase` also now skips empty panels instead of always falling back to the anti-waste card, so the rotation works correctly even when a panel has no data.
- **Untranslated OpenFoodFacts category labels** — Categories stored as OFF slugs (`en:plant-based-foods-and-beverages`, `en:dairies`, …) were shown raw. A new `_normalizeCat()` PHP function maps ~60 OFF slugs to Italian app categories; counts are re-aggregated after normalisation so `en:dairies` + `en:milk` both contribute to `latticini`.
## [1.7.25] - 2026-05-25
### Added
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
### Fixed
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
## [1.7.24] - 2026-05-21
### Fixed
- **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE.
- **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView.
- **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit.
- **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page.
- **SQLite database locked during inventory update** — `updateInventory()` made 34 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110).
- **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent.
- **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log.
- **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection.
- **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit.
- **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load.
### Added
- **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105).
- **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107).
### Changed
- **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically.
## [1.7.23] - 2026-05-18
### Added
+115 -13
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.19-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.37-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
@@ -36,13 +36,39 @@
---
> **⚠️ 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.
---
## ✨ Features
> ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry**
> A new **Generali** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place.
> Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small.
> Vacuum-sealed products get an extended grace period (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days) before being flagged as expired.
> Auto theme now follows **time of day** (dark 20:0007:00) instead of the OS setting, making it server-friendly.
### 🏠 NEW — Home Assistant Integration
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.
[![Install via HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
&nbsp;
[![Add Integration](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
**What you get:**
| | |
|---|---|
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
| **5 languages** | English, Italian, German, French, Spanish |
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
---
### 📦 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
@@ -60,6 +86,7 @@
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
- **Recipe stock hints** — Each pantry ingredient shows how much you have and what remains after use; when the leftover would be less than 5% of the full sealed package (10% for an already-opened partial pack), the recipe automatically uses everything on hand to avoid waste
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
@@ -100,7 +127,7 @@
### 🌙 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 **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
- **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
@@ -111,7 +138,16 @@
- **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
@@ -201,7 +237,7 @@ TTS_ENABLED=true
# Optional: DB retention and cleanup (applied automatically each cron cycle)
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
TRANSACTION_RETENTION_DAYS=90 # delete stock transactions older than N days (min 30 enforced)
# Optional: Vacuum-sealed expiry grace period
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
@@ -212,8 +248,11 @@ GEMINI_COST_25F_OUT=0.60
GEMINI_COST_20F_IN=0.10
GEMINI_COST_20F_OUT=0.40
# Optional: Security — protect the save_settings endpoint
# Set a strong random string; the Settings UI will ask for it before saving
# Optional: Security — protect all API endpoints
# Set a strong random string; clients send it as X-API-Token header (or ?api_token= for HA)
API_TOKEN=
# Optional: Legacy alias for API_TOKEN (settings save only)
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
@@ -296,6 +335,24 @@ The included `backup.sh` creates local daily backups of your database:
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`.
---
## 🏗️ Architecture
@@ -363,8 +420,11 @@ evershelf-kiosk/ # 📺 Android kiosk app (add-on)
- **Credentials** are stored in `.env` (server-side, never committed to Git)
- **Database** stays local — never pushed to remote repositories
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access
- **API token** — set `API_TOKEN` in `.env` to require `X-API-Token` on all API calls (Home Assistant: `?api_token=`)
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `ha_token_set`, …)
- **GitHub Issues token** — stored encrypted as `GH_ISSUE_TOKEN_ENC` + `GH_ISSUE_TOKEN_KEY` (see `scripts/encrypt-gh-token.php`)
- **Settings write protection** — `save_settings` requires the same API token when configured; validated with `hash_equals`
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
@@ -419,6 +479,48 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
### 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
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* EverShelf API bootstrap — shared by HTTP router and cron.
*/
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';
+156 -3
View File
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
exit('Forbidden');
}
// Define CRON_MODE before loading index.php so the router is skipped
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
define('CRON_MODE', true);
// Load all API functions without running the HTTP router
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
try {
$db = getDB();
@@ -87,11 +89,45 @@ try {
ob_end_clean();
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','7') . 'd' . ")\n";
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
} catch (Throwable $ce) {
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
}
// ── Daily incremental backup ──────────────────────────────────────────
// Create a local backup at most once every 23 h; also push to Google Drive
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
// though the cron runs every 5 minutes.
if (env('BACKUP_ENABLED', 'true') === 'true') {
try {
$lastBackupTs = 0;
if (file_exists(BACKUP_LAST_TS_PATH)) {
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
$lastBackupTs = (int)($lastData['ts'] ?? 0);
}
if (time() - $lastBackupTs >= 82800) { // 23 h
$backupResult = createLocalBackup($db);
if ($backupResult['success']) {
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
if (env('GDRIVE_ENABLED', 'false') === 'true') {
$gResult = backupToGDrive($db);
if ($gResult['success']) {
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
}
}
} else {
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
}
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
}
}
} catch (Throwable $e) {
$msg = $e->getMessage();
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
@@ -99,3 +135,120 @@ try {
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
exit(1);
}
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
try {
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
if (!file_exists($haFlagFile)) {
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
$expiringItems = $db->query(
"SELECT p.id AS product_id, i.id AS inventory_id,
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
ORDER BY i.expiry_date ASC LIMIT 20"
)->fetchAll(PDO::FETCH_ASSOC);
$expiredItems = $db->query(
"SELECT p.id AS product_id, i.id AS inventory_id,
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
AND i.expiry_date < date('now')
ORDER BY i.expiry_date ASC LIMIT 10"
)->fetchAll(PDO::FETCH_ASSOC);
// Normalise rows to full product format
if (!function_exists('_haFormatProduct')) {
function _haFormatProduct(array $row): array {
$daysRemaining = null;
if (!empty($row['expiry_date'])) {
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
$daysRemaining = (int)$diff->format('%r%a');
}
return [
'product_id' => (int)($row['product_id'] ?? 0),
'inventory_id' => (int)($row['inventory_id'] ?? 0),
'name' => $row['name'],
'brand' => $row['brand'] ?? null,
'category' => $row['category'] ?? null,
'quantity' => (float)($row['quantity'] ?? 0),
'unit' => $row['unit'] ?? '',
'default_quantity' => (float)($row['default_quantity'] ?? 0),
'package_unit' => $row['package_unit'] ?? null,
'location' => $row['location'] ?? null,
'expiry_date' => $row['expiry_date'] ?? null,
'days_remaining' => $daysRemaining,
'opened_at' => $row['opened_at'] ?? null,
'vacuum_sealed' => !empty($row['vacuum_sealed']),
];
}
}
$expiringItems = array_map('_haFormatProduct', $expiringItems);
$expiredItems = array_map('_haFormatProduct', $expiredItems);
if (!empty($expiringItems)) {
$names = implode(', ', array_column($expiringItems, 'name'));
_fireHaWebhook('expiry_alert', [
'count' => count($expiringItems),
'items' => $expiringItems,
'type' => 'expiring_soon',
'days' => $expiryDays,
'summary' => $names,
]);
// Also send HA notification if service configured
if (env('HA_NOTIFY_SERVICE', '') !== '') {
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
}
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
}
if (!empty($expiredItems)) {
$expNames = implode(', ', array_column($expiredItems, 'name'));
_fireHaWebhook('expiry_alert', [
'count' => count($expiredItems),
'items' => $expiredItems,
'type' => 'expired',
'summary' => $expNames,
]);
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
}
// Mark as done for today
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
// Clean up old flag files (keep last 7 days)
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
}
}
} catch (Throwable $haE) {
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
}
}
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
// If avahi-daemon is running on this host, register the _evershelf._tcp service
// so that Home Assistant can auto-discover this instance via Zeroconf.
if (function_exists('shell_exec')) {
try {
$avahiService = '/etc/avahi/services/evershelf.xml';
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
if (file_exists($template)) {
$xml = file_get_contents($template);
@file_put_contents($avahiService, $xml);
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
}
}
} catch (Throwable $avahiE) {
// Non-fatal: avahi not available
}
}
+55
View File
@@ -48,6 +48,13 @@ function getDB(): PDO {
? new LoggingPDO('sqlite:' . DB_PATH)
: new PDO('sqlite:' . DB_PATH);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
$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->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec("PRAGMA journal_mode=WAL");
$db->exec("PRAGMA foreign_keys=ON");
@@ -119,6 +126,16 @@ function initializeDB(PDO $db): void {
}
function migrateDB(PDO $db): void {
// Guard: if core tables don't exist yet (e.g. DB file present but empty / partial init),
// run initializeDB first so all tables are created, then return — no ALTER TABLE needed.
$productsExists = $db->query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='products'"
)->fetchColumn();
if (!$productsExists) {
initializeDB($db);
return;
}
// Add package_unit column if missing
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
$colNames = array_column($cols, 'name');
@@ -244,6 +261,36 @@ function migrateDB(PDO $db): void {
// Ensure composite indexes exist (added in v1.7.5 for performance)
$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)");
// 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)) {
$db->exec("
CREATE TABLE shopping_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
raw_name TEXT NOT NULL DEFAULT '',
specification TEXT NOT NULL DEFAULT '',
added_at INTEGER DEFAULT (strftime('%s','now')),
sort_order INTEGER DEFAULT 0
)
");
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
}
// Add is_favorite column to recipes if missing (#124)
$recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name');
if (!in_array('is_favorite', $recCols)) {
try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
// Add nutriments_json column to products if missing (#118)
$prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
if (!in_array('nutriments_json', $prodCols2)) {
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; }
}
}
/**
@@ -417,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
if (preg_match('/\baglio\b/', $n)) return 14;
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
// Packaged sliced bread — preservatives help a bit
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
// Generic bread / sandwich bread in fridge
if (preg_match('/\bpane\b/', $cat)) return 3;
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
if (preg_match('/\bketchup\b/', $n)) return 90;
+3482 -897
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* EverShelf — shared path constants.
*/
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Rotate data/cron.log — keep last N MB / lines.
*/
require_once __DIR__ . '/constants.php';
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
$path = CRON_LOG_PATH;
if (!file_exists($path)) {
return;
}
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
$size = filesize($path);
if ($size === false || $size <= $maxBytes) {
return;
}
for ($i = $keepRotated; $i >= 1; $i--) {
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
$to = $path . '.' . $i;
if ($i === $keepRotated && file_exists($to)) {
@unlink($to);
}
if (file_exists($from)) {
@rename($from, $to);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* EverShelf — environment variable loader (.env).
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$envFile = dirname(__DIR__, 2) . '/.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$key, $val] = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
/** Push a single key into the in-memory env cache (after .env write). */
function envCacheSet(string $key, string $value): void {
loadEnv();
// Force reload on next call — callers should use loadEnv() return for batch updates
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
*
* Configure ONE of:
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
*
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
*/
require_once __DIR__ . '/env.php';
function evershelfDecryptGhToken(string $encB64, string $key): string {
$raw = base64_decode($encB64, true);
if ($raw === false || strlen($raw) < 28) {
return '';
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt(
$cipher,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return ($plain !== false) ? $plain : '';
}
function evershelfEncryptGhToken(string $plain, string $key): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(
$plain,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return base64_encode($iv . $tag . $cipher);
}
/** Decode GitHub Issues token at runtime — never stored in source code. */
function _ghToken(): string {
static $token = null;
if ($token !== null) {
return $token;
}
$plain = env('GH_ISSUE_TOKEN');
if ($plain !== '') {
$token = $plain;
return $token;
}
$enc = env('GH_ISSUE_TOKEN_ENC');
$key = env('GH_ISSUE_TOKEN_KEY');
if ($enc !== '' && $key !== '') {
$token = evershelfDecryptGhToken($enc, $key);
return $token;
}
$token = '';
return $token;
}
+293
View File
@@ -0,0 +1,293 @@
<?php
/**
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
*/
require_once __DIR__ . '/env.php';
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
function evershelfEffectiveApiToken(): string {
$api = env('API_TOKEN');
if ($api !== '') {
return $api;
}
return env('SETTINGS_TOKEN', '');
}
function evershelfApiTokenRequired(): bool {
return evershelfEffectiveApiToken() !== '';
}
function evershelfGetProvidedApiToken(): string {
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
return (string)$_SERVER['HTTP_X_API_TOKEN'];
}
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
}
if (isset($_GET['api_token'])) {
return (string)$_GET['api_token'];
}
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
return $m[1];
}
return evershelfGetProvidedApiTokenFromHeaders();
}
function evershelfApiTokenValid(): bool {
$required = evershelfEffectiveApiToken();
if ($required === '') {
return true;
}
$provided = evershelfGetProvidedApiToken();
return $provided !== '' && hash_equals($required, $provided);
}
function evershelfGetProvidedApiTokenFromHeaders(): string {
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
}
/** Actions reachable without API token (telemetry + public probes). */
function evershelfPublicActions(): array {
return [
'ping',
'app_bootstrap',
'check_update',
'report_error',
'report_bug',
'client_log',
'gdrive_oauth_callback',
];
}
/** GET actions that mutate state — require auth when token is configured. */
function evershelfMutatingGetActions(): array {
return ['db_cleanup', 'export_inventory'];
}
function evershelfDestructiveActions(): array {
return [
'save_settings', 'db_cleanup',
'backup_now', 'backup_delete', 'backup_restore',
'gdrive_push', 'gdrive_oauth_exchange',
'migrate_units',
];
}
function evershelfActionNeedsAuth(string $action, string $method): bool {
if (!evershelfApiTokenRequired()) {
return false;
}
if (in_array($action, evershelfPublicActions(), true)) {
return false;
}
if ($method === 'POST') {
return true;
}
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
return true;
}
if (in_array($action, evershelfDestructiveActions(), true)) {
return true;
}
// Protect all data reads when API token is set
return true;
}
function evershelfRequireApiAuth(string $action, string $method): void {
if (!evershelfActionNeedsAuth($action, $method)) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => 'unauthorized',
'api_token_required' => true,
]);
exit;
}
function evershelfRequireAuthForSensitive(string $action): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
function evershelfSendCorsHeaders(): void {
$configured = env('CORS_ORIGIN', '');
if ($configured === '') {
// Same-origin SPA — do not emit wildcard CORS
return;
}
if ($configured === '*') {
header('Access-Control-Allow-Origin: *');
} else {
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_filter(array_map('trim', explode(',', $configured)));
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $reqOrigin);
header('Vary: Origin');
}
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
}
/** Read-only actions allowed in DEMO_MODE. */
function evershelfDemoReadOnlyActions(): array {
return [
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
'search_barcode', 'lookup_barcode', 'stock_for_name',
'product_get', 'products_list', 'products_search', 'inventory_search',
'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;
}
+56 -51
View File
@@ -1,57 +1,53 @@
<?php
/**
* EverShelf Scale Gateway — Auto-discovery
*
* Scans the server's local /24 subnet for any host responding on the gateway
* port (default 8765) and confirms it with a WebSocket handshake.
*
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
evershelfSendCorsHeaders();
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) $port = 8765;
// ── Determine server LAN IP ────────────────────────────────────────────────
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
function localLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
$ifaces = @net_get_interfaces();
if ($ifaces) {
foreach ($ifaces as $name => $info) {
if ($name === 'lo') continue;
foreach ($info['unicast'] ?? [] as $u) {
$ip = $u['address'] ?? '';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
}
}
return '';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$serverIp = localLanIp();
// Simple rate limit: max 6 scans per minute per IP
$rlDir = dirname(__DIR__) . '/data/rate_limits';
if (!is_dir($rlDir)) {
@mkdir($rlDir, 0755, true);
}
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
$now = time();
$hits = [];
if (file_exists($rlFile)) {
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
}
if (count($hits) >= 6) {
http_response_code(429);
echo json_encode(['error' => 'Too many discovery scans']);
exit;
}
$hits[] = $now;
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) {
$port = 8765;
}
$serverIp = evershelfLocalLanIp();
$parts = explode('.', $serverIp);
if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
exit;
}
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
// Total scan budget: 1.5 seconds.
$candidates = [];
for ($i = 1; $i <= 254; $i++) {
$ip = $subnet . $i;
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
$read = null;
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
$n = @stream_select($read, $write, $except, 0, $usec);
if ($n === false || $n === 0) break;
if ($n === false || $n === 0) {
break;
}
// Sockets in $except = connection refused/error
$failed = [];
foreach ($except as $s) {
$ip = array_search($s, $candidates, true);
if ($ip !== false) $failed[$ip] = true;
if ($ip !== false) {
$failed[$ip] = true;
}
}
// Sockets in $write = connection complete (may overlap with $except on error)
foreach ($write as $s) {
$ip = array_search($s, $candidates, true);
if ($ip === false) continue;
if ($ip === false) {
continue;
}
if (!isset($failed[$ip])) {
$found_tcp[] = $ip;
}
@fclose($s);
unset($candidates[$ip]);
}
// Close failed sockets too
foreach ($failed as $ip => $_) {
if (isset($candidates[$ip])) {
@fclose($candidates[$ip]);
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
}
}
}
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
foreach ($candidates as $s) {
@fclose($s);
}
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
$gateways = [];
foreach ($found_tcp as $ip) {
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
if (!$sock) continue;
if (!$sock) {
continue;
}
stream_set_timeout($sock, 2);
$key = base64_encode(random_bytes(16));
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
$dl = microtime(true) + 2;
while (microtime(true) < $dl && !feof($sock)) {
$line = fgets($sock, 256);
if ($line === false) break;
if ($line === false) {
break;
}
$resp .= $line;
if ($line === "\r\n") break;
if ($line === "\r\n") {
break;
}
}
fclose($sock);
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
echo json_encode([
'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]);
+16 -7
View File
@@ -1,16 +1,20 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
$rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$host = $parsed['host'] ?? '';
$host = strtolower($parsed['host'] ?? '');
$port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/';
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($host)) {
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
exit;
}
// Try to open a TCP connection with a 5-second timeout
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
if (!$sock) {
+17 -1
View File
@@ -8,6 +8,16 @@
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
// ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? '';
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$wsHost = $parsed['host'] ?? '';
$wsHost = strtolower($parsed['host'] ?? '');
$wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/';
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
exit;
}
if (!evershelfScaleHostAllowed($wsHost)) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
exit;
}
// ── SSE headers ───────────────────────────────────────────────────────────────
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-store, must-revalidate');
+953 -5
View File
File diff suppressed because it is too large Load Diff
+3386 -475
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
/**
* EverShelf core API token storage and auth headers.
*/
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
function getApiToken() {
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
}
function setApiToken(token) {
const t = (token || '').trim();
if (t) {
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
} else {
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
}
}
function apiAuthHeaders() {
const fromStorage = getApiToken();
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
const token = fromSettingsField || fromStorage;
if (!token) return {};
return { 'X-API-Token': token };
}
/** Fetch API token from server when loading the UI from the same origin. */
async function ensureApiToken() {
if (getApiToken()) return true;
try {
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
if (!res.ok) return false;
const data = await res.json();
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token) {
setApiToken(data.api_token);
return true;
}
} catch (_) { /* offline / network */ }
return !!getApiToken();
}
function _promptApiTokenIfNeeded() {
if (!window._apiTokenRequired) return;
if (getApiToken()) return;
const existing = document.getElementById('api-token-overlay');
if (existing) return;
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
const overlay = document.createElement('div');
overlay.id = 'api-token-overlay';
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal-content" style="max-width:420px;padding:20px">
<h3>${title}</h3>
<p class="settings-hint">${hint}</p>
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('api-token-save').onclick = () => {
const v = document.getElementById('api-token-input').value.trim();
if (v) {
setApiToken(v);
overlay.remove();
location.reload();
}
};
}
window.getApiToken = getApiToken;
window.setApiToken = setApiToken;
window.apiAuthHeaders = apiAuthHeaders;
window.ensureApiToken = ensureApiToken;
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
+11
View File
@@ -0,0 +1,11 @@
/**
* EverShelf core safe HTML escaping (loaded before app.js).
*/
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
File diff suppressed because one or more lines are too long
+14 -8
View File
@@ -1,13 +1,19 @@
#!/bin/bash
# Daily backup of EverShelf database (local only)
# The database is NOT pushed to remote repositories.
# Runs via cron: creates a local timestamped backup copy
#
# Example crontab entry:
# 0 3 * * * /var/www/html/evershelf/backup.sh
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
set -euo pipefail
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKUP_DIR="${INSTALL_DIR}/data/backups"
ENV_FILE="${INSTALL_DIR}/.env"
RETENTION=3
if [ -f "$ENV_FILE" ]; then
val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2)
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
RETENTION="$val"
fi
fi
mkdir -p "$BACKUP_DIR"
@@ -19,5 +25,5 @@ fi
DATE=$(date '+%Y-%m-%d_%H%M')
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
# Keep only the last 7 backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
# Keep only the newest N backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
+2
View File
@@ -0,0 +1,2 @@
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
Require all denied
+1
View File
@@ -0,0 +1 @@
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
<service>
<type>_evershelf._tcp</type>
<port>80</port>
<txt-record>path=/api/</txt-record>
<txt-record>version=1.0</txt-record>
<txt-record>app=evershelf</txt-record>
</service>
</service-group>
+38
View File
@@ -0,0 +1,38 @@
# EverShelf — Architecture (modular layout)
```
dispensa/
├── api/
│ ├── bootstrap.php # Shared init: env, security, DB, logger
│ ├── index.php # HTTP handlers + router (split planned per domain)
│ ├── database.php # SQLite schema & migrations
│ ├── logger.php # Rotating file logger (logs/)
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
│ ├── lib/
│ │ ├── env.php # .env loader
│ │ ├── constants.php # Paths & pricing constants
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
│ │ ├── github.php # Encrypted GitHub Issues token
│ │ └── cron_log.php # data/cron.log rotation
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
├── assets/
│ ├── js/
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
│ │ └── app.js # SPA logic (domain modules: future split)
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
├── data/ # Runtime data (.htaccess: deny all)
├── logs/ # Application logs (.htaccess: deny all)
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
```
## Security model
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
## Planned refactors
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
+308
View File
@@ -0,0 +1,308 @@
# Home Assistant Integration
EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations.
**Capabilities:**
- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items)
- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates)
- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service
- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity
- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually)
---
## Quick Setup
1. **Generate a Long-Lived Access Token** in Home Assistant:
- Open HA → your **Profile** (bottom-left avatar) → **Security****Long-Lived Access Tokens** → **Create Token**
- Copy the generated token — you won't see it again.
2. **Open EverShelf Settings** → tab **🏠 Home Assistant**.
3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token.
4. Click **Test connection** — you should see ✅.
5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**.
---
## REST Sensors
Add EverShelf pantry data as native HA sensor entities that update automatically.
### Endpoints
| URL | Returns | Sensor |
|-----|---------|--------|
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
### Generate & Copy YAML
In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL.
### Manual YAML example
```yaml
# configuration.yaml
sensor:
- platform: rest
name: "EverShelf Overview"
unique_id: evershelf_overview
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor"
scan_interval: 300 # seconds
value_template: "{{ value_json.state }}"
json_attributes:
- expiring_soon
- expiring_3d
- expired_items
- total_items
- shopping_items
- expiring_list # full product details for expiring items
- expired_list # full product details for expired items
- low_stock_list # full product details for items with quantity ≤ 1
- next_expiry_name
- next_expiry_date
- days_to_next_expiry
- last_updated
unit_of_measurement: "items"
- platform: rest
name: "EverShelf Shopping Count"
unique_id: evershelf_shopping
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping"
scan_interval: 180
value_template: "{{ value_json.state }}"
unit_of_measurement: "items"
# Full product inventory — each item includes all details (location, brand, category, …)
- platform: rest
name: "EverShelf Products"
unique_id: evershelf_products
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
scan_interval: 600
value_template: "{{ value_json.state }}"
json_attributes:
- items
- last_updated
unit_of_measurement: "items"
```
Restart Home Assistant after editing `configuration.yaml`.
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
```json
{
"product_id": 42,
"inventory_id": 7,
"name": "Latte intero",
"brand": "Parmalat",
"category": "Lattiero-caseari",
"quantity": 2.0,
"unit": "conf",
"default_quantity": 1000.0,
"package_unit": "ml",
"location": "frigo",
"expiry_date": "2025-06-15",
"days_remaining": 3,
"opened_at": "2025-06-10",
"vacuum_sealed": false
}
```
Field details:
| Field | Type | Description |
|-------|------|-------------|
| `product_id` | int | Products table ID |
| `inventory_id` | int | Inventory row ID |
| `name` | string | Product name |
| `brand` | string\|null | Brand (if set) |
| `category` | string\|null | Category (if set) |
| `quantity` | float | Current quantity in inventory |
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
| `opened_at` | string\|null | ISO date when the package was opened |
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
---
## Webhook Automations
EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
### Create the HA Webhook Automation
1. HA → **Settings****Automations & Scenes** → **Create Automation**
2. Click **Add Trigger** → choose **Webhook**
3. HA generates a **Webhook ID** — copy it
4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID**
5. Select which events should trigger the webhook
### Supported Events
| Event key | When it fires |
|-----------|--------------|
| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days |
| `shopping_add` | Item added to the shopping list |
| `stock_update` | Inventory quantity changed |
| `barcode_scan` | (reserved for future use) |
### Webhook Payload (POST body)
```json
{
"event": "expiry_alert",
"timestamp": "2025-06-12T08:00:00+00:00",
"data": {
"type": "expiring_soon",
"count": 3,
"days": 3,
"summary": "Milk, Yogurt, Butter",
"items": [
{
"product_id": 42,
"inventory_id": 7,
"name": "Milk",
"brand": "Parmalat",
"category": "Dairy",
"quantity": 2.0,
"unit": "conf",
"default_quantity": 1000.0,
"package_unit": "ml",
"location": "frigo",
"expiry_date": "2025-06-14",
"days_remaining": 2,
"opened_at": "2025-06-10",
"vacuum_sealed": false
}
]
}
}
```
### Example: Expiry Alert → Telegram
```yaml
alias: EverShelf Expiry Alert
trigger:
- platform: webhook
webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID
action:
- service: notify.telegram_bot
data:
message: >
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
{% for item in trigger.json.data.items %}
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
{% endfor %}
```
### Example: Automation on location
You can filter by location in the automation template to only alert for fridge items:
```yaml
condition:
- condition: template
value_template: >
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
```
---
## Push Notifications
If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly:
1. Find your notify service name in HA: **Developer Tools → Services** → search `notify`
2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`)
3. Save
EverShelf will call this service from the cron job whenever expiry alerts fire.
---
## TTS on Smart Speakers
Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`.
### Configuration
1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`)
- Find it in HA: **Developer Tools → States**
2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers
3. Save settings
### How it Works
When recipe step TTS is triggered, EverShelf calls:
```
POST /api/services/tts/speak
Authorization: Bearer <HA_TOKEN>
{
"entity_id": "media_player.kitchen_display",
"message": "Add 200 g of flour and mix well."
}
```
The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues).
---
## Environment Variables Reference
All settings are configurable from `.env` or from the in-app Settings panel.
| Variable | Default | Description |
|----------|---------|-------------|
| `HA_ENABLED` | `false` | Master switch for all HA features |
| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash |
| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token |
| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS |
| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation |
| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events |
| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) |
| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert |
---
## Troubleshooting
**Test shows ❌ "Connection failed"**
- Verify the URL is reachable from the EverShelf server (not just your browser)
- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead
- Check that port 8123 (or your custom port) is open on the HA host
**Test shows ❌ "bad_token"**
- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile
**Webhook not firing**
- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA
- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes)
- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS`
**TTS not speaking**
- Ensure the media player entity is online in HA (check its state in Developer Tools)
- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab
- Check HA logs for `tts.speak` errors (some platforms require `tts_options`)
**Sensors show unavailable in HA**
- The EverShelf URL must be reachable from the HA host
- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible
- Use `scan_interval` ≥ 60 to avoid hammering the server
+4 -4
View File
@@ -5,14 +5,14 @@ plugins {
android {
namespace = "it.dadaloop.evershelf.kiosk"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 17
versionName = "1.7.16"
targetSdk = 35
versionCode = 18
versionName = "1.7.17"
}
signingConfigs {
@@ -18,7 +18,9 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.media.AudioManager
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
@@ -99,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
@@ -144,6 +160,25 @@ class KioskActivity : AppCompatActivity() {
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
tts?.language = Locale.getDefault()
}
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
runOnUiThread {
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
}
}
@Deprecated("Deprecated in API 21")
override fun onError(utteranceId: String?) {
runOnUiThread {
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
runOnUiThread {
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
}
}
})
ttsReady = true
}
}
@@ -466,7 +501,10 @@ class KioskActivity : AppCompatActivity() {
if (!ttsReady) return
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
engine.setPitch(pitch.coerceIn(0.1f, 4f))
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
val params = Bundle().apply {
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC)
}
engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, "kiosk_tts")
}
@JavascriptInterface
fun stopSpeech() { tts?.stop() }
@@ -774,7 +812,13 @@ class KioskActivity : AppCompatActivity() {
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
var dmStatus = -1
var dmReason = -1
if (c.moveToFirst()) {
dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL
}
c.close()
if (ok) {
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
@@ -784,7 +828,12 @@ class KioskActivity : AppCompatActivity() {
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
ErrorReporter.reportMessage(
"install_download_failed",
"DownloadManager returned failure for URL: $apkUrl",
mapOf("dm_status" to dmStatus, "dm_reason" to dmReason,
"device" to buildDeviceLabel())
)
}
}
}
@@ -868,6 +917,11 @@ class KioskActivity : AppCompatActivity() {
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
// on some OEM/Android versions even when the package name is correct.
// setInstallReason is required on Android 14+ (API 34+) for PackageInstaller
// to accept self-updates; without it Android 16 returns STATUS_FAILURE=1.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER)
}
val sessionId = pi.createSession(params)
val session = pi.openSession(sessionId)
try {
@@ -123,6 +123,9 @@ class SettingsActivity : AppCompatActivity() {
// Back
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
// Advanced settings → back to webapp (where HA, Gemini, Bring! etc. are configured)
findViewById<MaterialButton>(R.id.btnOpenAppSettings).setOnClickListener { finish() }
// Test connection
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
@@ -400,6 +400,7 @@ class SetupActivity : AppCompatActivity() {
scaleTestCard.visibility = View.GONE
testWeightBox.visibility = View.GONE
bleSetupCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE // restore nav buttons (back/next)
tvSelectedScale.text = ""
tvSelectedScale.visibility = View.GONE
tvScanStatus.text = getString(R.string.ble_not_confirmed)
@@ -960,6 +961,8 @@ class SetupActivity : AppCompatActivity() {
testWeightBox.visibility = View.GONE
testHasWeight = false
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
// Always re-enable retry so the user is never stuck
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
}
override fun onWeightReceived(reading: WeightReading) {
if (!isInTestMode) return
@@ -224,6 +224,43 @@
</LinearLayout>
</LinearLayout>
<!-- Advanced / App Settings link -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="IMPOSTAZIONI AVANZATE"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Home Assistant, Gemini AI, Bring!, TTS, notifiche e tutte le altre funzionalità si configurano direttamente nell'app EverShelf."
android:textColor="#94a3b8"
android:textSize="13sp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOpenAppSettings"
android:layout_width="match_parent"
android:layout_height="44dp"
android:text="← Torna all'app per le impostazioni avanzate"
android:textSize="13sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
</LinearLayout>
<!-- Danger Zone -->
<TextView
android:layout_width="match_parent"
+349 -31
View File
@@ -11,9 +11,13 @@
<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=20260603a">
<!-- Core modules (auth, DOM helpers) -->
<script src="assets/js/core/dom.js?v=20260603a"></script>
<script src="assets/js/core/auth.js?v=20260603b"></script>
<!-- QuaggaJS — local vendor with CDN fallback -->
<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 +29,15 @@
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/';
let pipeline, env;
try {
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
} catch (_) {
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
}
env.localModelPath = localBase;
env.allowRemoteModels = true;
env.useBrowserCache = true;
const pipe = await pipeline(
@@ -64,7 +72,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.23</span>
<span class="app-preloader-version" id="preloader-version">v1.7.37</span>
</div>
</div>
@@ -77,7 +85,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.23</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.37</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -169,10 +177,12 @@
<div id="expired-list"></div>
</div>
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
<div id="dashboard-insight-wrap" style="position:relative">
<div id="waste-chart-section" style="display:none"></div>
<div id="nutrition-section" style="display:none"></div>
<div id="monthly-stats-section" style="display:none"></div>
<div id="macros-section" style="display:none"></div>
</div>
<!-- Alert for soonest expiring items -->
@@ -192,7 +202,7 @@
<!-- ===== INVENTORY LIST ===== -->
<section class="page" id="page-inventory">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
</div>
@@ -223,7 +233,7 @@
<!-- ===== SCAN PAGE ===== -->
<section class="page" id="page-scan">
<div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="scan.title">Scansiona</h2>
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
</div>
@@ -249,6 +259,19 @@
</div>
<!-- Live partial code preview -->
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
<!-- Scan status bar -->
<div class="scan-status-bar" id="scan-status-bar">
<span id="scan-status-method" class="scan-status-method"></span>
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
</div>
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
<div class="scan-ai-overlay-inner">
<div class="loading-spinner"></div>
<span class="scan-ai-overlay-label">Gemini Vision</span>
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
</div>
</div>
<!-- Success flash overlay -->
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
<div class="scan-confirm-check"></div>
@@ -267,6 +290,9 @@
<!-- 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>
<!-- Recent scans -->
<div class="scan-recents" id="scan-recents" style="display:none">
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
@@ -326,13 +352,14 @@
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
<section class="page" id="page-action">
<div class="page-header">
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
</div>
<!-- Banner: shopping list scan context -->
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
<div class="product-preview product-preview-large" id="action-product-preview"></div>
<div class="product-preview product-preview-small" id="action-product-preview"></div>
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
<div id="action-related-stock" style="display:none"></div>
<div class="action-buttons" id="action-buttons-container">
<button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span>
@@ -348,7 +375,7 @@
<!-- ===== ADD TO INVENTORY FORM ===== -->
<section class="page" id="page-add">
<div class="page-header">
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
</div>
<div class="product-preview-small" id="add-product-preview"></div>
@@ -411,7 +438,7 @@
<!-- ===== USE FROM INVENTORY FORM ===== -->
<section class="page" id="page-use">
<div class="page-header">
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="use.title">Usa / Consuma</h2>
</div>
<div class="product-preview-small" id="use-product-preview"></div>
@@ -467,7 +494,7 @@
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
<section class="page" id="page-product-form">
<div class="page-header">
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 id="product-form-title">Nuovo Prodotto</h2>
</div>
<form class="form" onsubmit="submitProduct(event)">
@@ -655,7 +682,7 @@
<!-- ===== ALL PRODUCTS PAGE ===== -->
<section class="page" id="page-products">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
</div>
<div class="search-bar">
@@ -667,11 +694,11 @@
<!-- ===== RECIPE PAGE ===== -->
<section class="page" id="page-recipe">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
</div>
<div class="recipe-page-container">
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
✨ Genera nuova ricetta
</button>
<div id="recipe-archive" class="recipe-archive"></div>
@@ -681,7 +708,7 @@
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
<section class="page" id="page-shopping">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
</div>
<div class="shopping-container">
@@ -789,7 +816,7 @@
<!-- ===== AI IDENTIFICATION PAGE ===== -->
<section class="page" id="page-ai">
<div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
</div>
<div class="ai-container">
@@ -827,20 +854,22 @@
<!-- ===== SETTINGS PAGE ===== -->
<section class="page" id="page-settings">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
</div>
<div class="settings-tabs">
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-ha'); _loadHaTab();" data-tab="tab-ha" title="Home Assistant" data-i18n-title="settings.ha.tab">🏠</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info"></button>
</div>
<div class="settings-panels">
@@ -954,9 +983,36 @@
</div>
<!-- Bring! Tab -->
<div class="settings-panel" id="tab-bring">
<!-- Shopping enable + provider -->
<div class="settings-card">
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" id="shopping-mode-group">
<label data-i18n="settings.shopping.mode_label">Provider</label>
<div class="radio-group" style="margin-top:6px">
<label class="radio-option">
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
</label>
<label class="radio-option" style="margin-left:16px">
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
</label>
</div>
</div>
</div>
<!-- Bring! sub-section (shown only when mode = bring) -->
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
<div class="form-group">
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
@@ -967,6 +1023,37 @@
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
</div>
<!-- Smart suggestions + forecast -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-smart-suggestions">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-forecast">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" style="margin-top:8px">
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
<div class="qty-control" style="margin-top:6px">
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)"></button>
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
</div>
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
</div>
</div>
<!-- Price Estimation Settings -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
@@ -1115,6 +1202,16 @@
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
</div>
<div class="form-group" style="margin-top:14px">
<label class="toggle-row">
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-barcode-ai-fallback">
<span class="toggle-slider"></span>
</span>
</label>
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
</div>
</div>
</div>
<!-- Security Tab -->
@@ -1124,10 +1221,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>
@@ -1253,10 +1350,127 @@
</div>
</div><!-- /tts-server-section -->
<button class="btn btn-large btn-secondary full-width mt-2" onclick="testSound()" data-i18n="settings.tts.test_sound_btn">🔔 Esegui Test Suono</button>
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
<!-- HA TTS quick-fill hint -->
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
<span data-i18n="settings.ha.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
</div>
</div>
</div>
<!-- Home Assistant Tab -->
<div class="settings-panel" id="tab-ha">
<!-- Connection card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.title">🏠 Home Assistant</h4>
<p class="settings-hint" data-i18n="settings.ha.hint">Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span data-i18n="settings.ha.enabled">✅ Abilita integrazione Home Assistant</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-ha-enabled" onchange="onHaEnabledChange()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div id="ha-config-section">
<div class="form-group">
<label data-i18n="settings.ha.url_label">🌐 Home Assistant URL</label>
<input type="url" id="setting-ha-url" class="form-input" placeholder="http://192.168.1.50:8123">
<p class="settings-hint" data-i18n="settings.ha.url_hint">URL base della tua istanza HA (senza slash finale). Es: <code>http://homeassistant.local:8123</code></p>
</div>
<div class="form-group">
<label data-i18n="settings.ha.token_label">🔑 Long-Lived Access Token</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="password" id="setting-ha-token" class="form-input" style="flex:1" placeholder="eyJhbGci...">
<button class="btn btn-secondary" style="flex-shrink:0" onclick="togglePasswordVisibility('setting-ha-token')" data-i18n="btn.toggle_password">👁️</button>
</div>
<p class="settings-hint" data-i18n="settings.ha.token_hint">Genera un token in HA → Profilo → Token di accesso a lungo termine.</p>
</div>
<button class="btn btn-secondary full-width" onclick="testHaConnection()" data-i18n="settings.ha.test_btn">🔗 Testa connessione HA</button>
<div id="ha-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
</div>
<!-- TTS via HA card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.tts_title">🔊 TTS su Speaker Smart</h4>
<p class="settings-hint" data-i18n="settings.ha.tts_hint">Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).</p>
<div class="form-group">
<label data-i18n="settings.ha.tts_entity_label">🔈 Entity ID del media player</label>
<input type="text" id="setting-ha-tts-entity" class="form-input" placeholder="media_player.living_room">
<p class="settings-hint" data-i18n="settings.ha.tts_entity_hint">Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.</p>
</div>
<div class="form-group">
<label data-i18n="settings.ha.tts_platform_label">🎙️ Piattaforma TTS</label>
<select id="setting-ha-tts-platform" class="form-input">
<option value="tts.speak" data-i18n="settings.ha.tts_platform_speak">tts.speak (raccomandato)</option>
<option value="notify" data-i18n="settings.ha.tts_platform_notify">notify.* (servizio notifiche)</option>
</select>
</div>
<button class="btn btn-secondary full-width" onclick="applyHaTtsPreset()" data-i18n="settings.ha.tts_apply_btn">✅ Applica preset HA al TTS</button>
<p class="settings-hint mt-2" data-i18n="settings.ha.tts_apply_hint">Configura automaticamente il tab TTS con i parametri HA corretti.</p>
</div>
<!-- Webhook card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.webhook_title">⚡ Automazioni Webhook</h4>
<p class="settings-hint" data-i18n="settings.ha.webhook_hint">EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).</p>
<div class="form-group">
<label data-i18n="settings.ha.webhook_id_label">🔗 Webhook ID</label>
<input type="text" id="setting-ha-webhook-id" class="form-input" placeholder="evershelf_events">
<p class="settings-hint" data-i18n="settings.ha.webhook_id_hint">Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. <a href="#" onclick="showHaWebhookHelp();return false" style="color:var(--accent)">Come farlo?</a></p>
</div>
<div class="form-group">
<label data-i18n="settings.ha.webhook_events_label">📋 Eventi da notificare</label>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
<input type="checkbox" id="ha-event-expiry" value="expiry"> <span data-i18n="settings.ha.event_expiry">Prodotti in scadenza (cron giornaliero)</span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
<input type="checkbox" id="ha-event-shopping" value="shopping_add"> <span data-i18n="settings.ha.event_shopping">Aggiunta alla lista della spesa</span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
<input type="checkbox" id="ha-event-stock" value="stock_update"> <span data-i18n="settings.ha.event_stock">Aggiornamento scorte (quantità modificata)</span>
</label>
</div>
</div>
<div class="form-group">
<label data-i18n="settings.ha.expiry_days_label">📅 Giorni anticipo per scadenze</label>
<input type="number" id="setting-ha-expiry-days" class="form-input" min="1" max="30" value="3">
<p class="settings-hint" data-i18n="settings.ha.expiry_days_hint">Quanti giorni prima della scadenza inviare l'alert.</p>
</div>
</div>
<!-- Notify service card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.notify_title">📱 Notifiche Push</h4>
<p class="settings-hint" data-i18n="settings.ha.notify_hint">EverShelf invia notifiche push tramite il servizio <code>notify.*</code> di HA (Telegram, Pushover, app mobile, ecc.).</p>
<div class="form-group">
<label data-i18n="settings.ha.notify_service_label">📣 Servizio notify</label>
<input type="text" id="setting-ha-notify-service" class="form-input" placeholder="notify.mobile_app_mio_telefono">
<p class="settings-hint" data-i18n="settings.ha.notify_service_hint">Formato: <code>notify.NOME_SERVIZIO</code>. Lascia vuoto per disabilitare. Richiede token HA configurato.</p>
</div>
</div>
<!-- Sensor card (read-only info) -->
<div class="settings-card">
<h4 data-i18n="settings.ha.sensor_title">📊 Sensori REST per HA</h4>
<p class="settings-hint" data-i18n="settings.ha.sensor_hint">HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a <code>configuration.yaml</code>:</p>
<div id="ha-sensor-yaml" style="background:var(--bg-secondary,#f1f5f9);border-radius:8px;padding:12px;font-family:monospace;font-size:0.75rem;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;border:1px solid var(--border,#e2e8f0)"></div>
<button class="btn btn-secondary full-width mt-2" onclick="copyHaSensorYaml()" data-i18n="settings.ha.sensor_copy_btn">📋 Copia YAML</button>
</div>
<!-- Save button -->
<button class="btn btn-large btn-accent full-width" onclick="saveHaSettings()" data-i18n="settings.ha.save_btn">💾 Salva impostazioni HA</button>
<div id="ha-save-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
<!-- Scale Tab -->
<div class="settings-panel" id="tab-scale">
<div class="settings-card">
@@ -1342,6 +1556,93 @@
</div>
<!-- Language Tab -->
<!-- Backup Tab -->
<div class="settings-panel" id="tab-backup">
<!-- Local Backup -->
<div class="settings-card">
<h4 data-i18n="settings.backup.local_title">💾 Backup Locale</h4>
<p class="settings-hint" data-i18n="settings.backup.local_hint">Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).</p>
<div id="backup-last-info" style="margin-bottom:12px;padding:10px 12px;background:var(--bg-secondary,#f8fafc);border-radius:8px;font-size:0.83rem;color:var(--text-secondary)">
<span data-i18n="settings.info.loading">Caricamento…</span>
</div>
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
<label data-i18n="settings.backup.retention_days" style="flex-shrink:0">Retention (giorni):</label>
<input type="number" id="setting-backup-retention-days" class="form-input" style="width:80px" min="1" max="90" value="3">
</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>
<!-- List of backups -->
<div id="backup-list-container" style="margin-top:14px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- Google Drive -->
<div class="settings-card">
<h4 data-i18n="settings.backup.gdrive_title">☁️ Google Drive</h4>
<p class="settings-hint" data-i18n="settings.backup.gdrive_hint">Carica automaticamente il backup su Google Drive usando un Service Account.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span data-i18n="settings.backup.gdrive_enabled">Abilita backup Google Drive</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-gdrive-enabled" onchange="saveSettings()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div id="gdrive-config-section">
<!-- Folder ID (shared between both methods) -->
<div class="form-group">
<label data-i18n="settings.backup.gdrive_folder_id">ID Cartella Drive</label>
<input type="text" id="setting-gdrive-folder-id" class="form-input" placeholder="1ABCdef_xyz…">
<p class="settings-hint" data-i18n="settings.backup.gdrive_folder_id_hint">Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong></p>
</div>
<!-- OAuth 2.0 section -->
<div id="gdrive-oauth-section">
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
<summary style="cursor:pointer;font-weight:600;font-size:0.83rem" data-i18n="settings.backup.gdrive_oauth_how_to">📋 Come configurare OAuth 2.0 (passo dopo passo)</summary>
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8" data-i18n-html="settings.backup.gdrive_oauth_steps"></ol>
</details>
<div class="form-group">
<label data-i18n="settings.backup.gdrive_client_id">Client ID</label>
<input type="text" id="setting-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com">
</div>
<div class="form-group">
<label data-i18n="settings.backup.gdrive_client_secret">Client Secret</label>
<input type="password" id="setting-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…">
</div>
<div class="form-group" style="background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px;font-size:0.82rem">
<span data-i18n="settings.backup.gdrive_redirect_uri_label">Redirect URI (aggiungi in Google Cloud Console):</span>
<code id="gdrive-redirect-uri-display" style="display:block;margin-top:4px;word-break:break-all;color:var(--text-primary);font-size:0.78rem">http://localhost</code>
<p class="settings-hint" style="margin-top:6px;margin-bottom:0" data-i18n="settings.backup.gdrive_redirect_uri_hint">Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa <strong>http://localhost</strong>.</p>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px">
<button class="btn btn-secondary" onclick="_gdriveAuthorize()" id="btn-gdrive-authorize" data-i18n="settings.backup.gdrive_oauth_authorize">🔑 Autorizza con Google</button>
<span id="gdrive-oauth-token-status" style="font-size:0.83rem"></span>
</div>
<!-- Manual code entry (appears after clicking Authorize) -->
<div id="gdrive-code-section" style="display:none;margin-top:12px;padding:12px 14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;border:1px solid var(--border)">
<p style="font-size:0.82rem;margin-bottom:8px;font-weight:600" data-i18n="settings.backup.gdrive_code_title">Incolla l'URL o il codice di autorizzazione</p>
<p class="settings-hint" style="margin-bottom:8px" data-i18n="settings.backup.gdrive_code_hint">Dopo aver autorizzato su Google, il browser proverà ad aprire <code>http://localhost</code> e mostrerà un errore. Copia l'intero URL dalla barra degli indirizzi e incollalo qui sotto.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="text" id="gdrive-code-input" class="form-input" style="flex:1;min-width:0" placeholder="http://localhost/?code=4%2F0A… oppure solo il codice">
<button class="btn btn-primary" onclick="_gdriveSubmitCode()" id="btn-gdrive-submit-code" data-i18n="settings.backup.gdrive_code_submit">Conferma</button>
</div>
</div>
</div>
<!-- Retention + action buttons (shared) -->
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
<label data-i18n="settings.backup.gdrive_retention_days" style="flex-shrink:0">Retention Drive (giorni, 0=tutto):</label>
<input type="number" id="setting-gdrive-retention-days" class="form-input" style="width:80px" min="0" max="365" value="30">
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<button class="btn btn-secondary" onclick="_gdriveTest()" id="btn-gdrive-test" data-i18n="settings.backup.gdrive_test">🔗 Testa Connessione</button>
<button class="btn btn-accent" onclick="_gdrivePushNow()" id="btn-gdrive-push" data-i18n="settings.backup.gdrive_push_now">☁️ Carica Ora su Drive</button>
</div>
<div id="gdrive-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
</div>
</div>
<!-- Info Tab -->
<div class="settings-panel" id="tab-info">
<!-- Gemini AI Usage card -->
@@ -1552,9 +1853,15 @@
</div>
<div id="recipe-result" style="display:none" class="recipe-result">
<div id="recipe-content"></div>
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
<button id="recipe-regen-btn" class="btn btn-large btn-secondary full-width mt-2" onclick="showRegenChoice()" data-i18n="recipes.regenerate">
🔄 Generane un'altra
</button>
<div id="recipe-regen-choice" style="display:none" class="recipe-regen-choice">
<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>
</div>
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
✅ Chiudi
</button>
@@ -1611,6 +1918,17 @@
</div>
</div>
<!-- ===== NETWORK ERROR OVERLAY ===== -->
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
<div class="net-error-body">
<div class="net-error-icon" id="net-error-icon">📡</div>
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
<div class="net-error-status" id="net-error-status"></div>
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
</div>
</div>
<!-- ===== COOKING MODE OVERLAY ===== -->
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
@@ -1652,6 +1970,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260518c"></script>
<script src="assets/js/app.js?v=20260604e"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.23",
"version": "1.7.37",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+9
View File
@@ -0,0 +1,9 @@
{
"name": "evershelf",
"private": true,
"scripts": {
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
"build": "npm run build:js && npm run build:css"
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
/**
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
*
* Usage:
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
*/
if ($argc < 3) {
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
exit(1);
}
require_once __DIR__ . '/../api/lib/github.php';
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Fix ownership and permissions for EverShelf runtime directories.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WEB_USER="${WEB_USER:-www-data}"
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
chmod 750 "${ROOT}/data" "${ROOT}/logs"
chmod 640 "${ROOT}/.env" 2>/dev/null || true
find "${ROOT}/data" -type f -exec chmod 660 {} \;
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
echo "Permissions updated for ${WEB_USER}"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env php
<?php
/**
* One-time merge of duplicate product records (same normalized name + compatible brand).
* Opened-package splits remain as separate inventory rows on the canonical product.
*
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
*/
declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv, true);
$dbPath = __DIR__ . '/../data/evershelf.db';
if (!file_exists($dbPath)) {
fwrite(STDERR, "Database not found: $dbPath\n");
exit(1);
}
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
function normName(string $name): string {
return mb_strtolower(trim($name));
}
function normBrand(string $brand): string {
return mb_strtolower(trim($brand));
}
function brandsCompatible(string $a, string $b): bool {
$na = normBrand($a);
$nb = normBrand($b);
return $na === $nb || $na === '' || $nb === '';
}
function productScore(PDO $db, int $id): float {
$tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn();
$inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn();
return $tx * 10 + $inv;
}
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
$db->beginTransaction();
try {
$db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
$db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
$db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$byName = [];
foreach ($products as $p) {
$key = normName($p['name']);
if ($key === '') {
continue;
}
$byName[$key][] = $p;
}
$merged = 0;
foreach ($byName as $nameKey => $group) {
if (count($group) < 2) {
continue;
}
// Split into compatible-brand clusters
$clusters = [];
foreach ($group as $p) {
$placed = false;
foreach ($clusters as &$cluster) {
$ref = $cluster[0];
if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) {
$cluster[] = $p;
$placed = true;
break;
}
}
unset($cluster);
if (!$placed) {
$clusters[] = [$p];
}
}
foreach ($clusters as $cluster) {
if (count($cluster) < 2) {
continue;
}
usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id']));
$keep = (int)$cluster[0]['id'];
$keepName = $cluster[0]['name'];
for ($i = 1; $i < count($cluster); $i++) {
$drop = (int)$cluster[$i]['id'];
echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n";
if (!$dryRun) {
mergeProducts($db, $keep, $drop);
}
$merged++;
}
}
}
echo $dryRun
? "Dry run: $merged merge(s) would be performed.\n"
: "Done: $merged duplicate product(s) merged.\n";
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env php
<?php
/**
* One-time security migration: GitHub token encrypted .env, optional API_TOKEN.
*/
require_once __DIR__ . '/../api/lib/env.php';
require_once __DIR__ . '/../api/lib/github.php';
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
fwrite(STDERR, ".env not found\n");
exit(1);
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
$vars = loadEnv();
$changed = false;
// Migrate legacy XOR token from previous index.php if still in git history
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
$legacyKey = 'D1sp3ns4!Ev3r#26';
$encBin = hex2bin($legacyEnc);
$plain = '';
if ($encBin) {
for ($i = 0; $i < strlen($encBin); $i++) {
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
}
}
if ($plain !== '' && str_starts_with($plain, 'github_')) {
$newKey = bin2hex(random_bytes(16));
$enc = evershelfEncryptGhToken($plain, $newKey);
$lines[] = '';
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
$changed = true;
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
}
}
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
$token = bin2hex(random_bytes(24));
$lines[] = '';
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
$lines[] = 'API_TOKEN=' . $token;
$changed = true;
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
}
if ($changed) {
file_put_contents($envFile, implode("\n", $lines) . "\n");
chmod($envFile, 0640);
echo "Updated .env\n";
} else {
echo "No migration needed\n";
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Re-apply stock hints and 5% use-all rule to an archived recipe.
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
*/
define('CRON_MODE', true);
require __DIR__ . '/../api/index.php';
$id = (int)($argv[1] ?? 0);
if ($id <= 0) {
fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php <recipe_id>\n");
exit(1);
}
$db = getDB();
$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
fwrite(STDERR, "Recipe {$id} not found\n");
exit(1);
}
$recipe = json_decode($row['recipe_json'], true);
if (!is_array($recipe)) {
fwrite(STDERR, "Invalid recipe JSON for id {$id}\n");
exit(1);
}
$stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC, p.name ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
recipeApplyStockHintsToRecipe($db, $recipe);
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
$upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
foreach ($recipe['ingredients'] ?? [] as $ing) {
if (empty($ing['from_pantry'])) {
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
continue;
}
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
echo sprintf(
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
$ing['name'] ?? '?',
$ing['qty'] ?? '?',
$ing['stock_have'] ?? 0,
$ing['stock_unit'] ?? '',
$ing['stock_remain'] ?? 0,
$ing['stock_unit'] ?? '',
$useAll
);
}
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Sync translation files: ensure all locales have the same keys as it.json (reference)."""
from __future__ import annotations
import copy
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent / 'translations'
REF = 'it.json'
LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json']
# New keys added across all locales (nested path -> value per locale)
NEW_KEYS: dict[str, dict[str, str]] = {
'dashboard.banner_prediction_confirmed': {
'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni',
'en': '✅ Confirmed — forecasts will recalculate from your next entries',
'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet',
'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements',
'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros',
},
'dashboard.banner_anomaly_explain_fail': {
'it': 'Impossibile ottenere spiegazione AI',
'en': 'Could not get AI explanation',
'de': 'KI-Erklärung konnte nicht abgerufen werden',
'fr': 'Impossible d\'obtenir l\'explication IA',
'es': 'No se pudo obtener la explicación de IA',
},
'dashboard.banner_anomaly_dismissed': {
'it': 'Anomalia ignorata',
'en': 'Anomaly dismissed',
'de': 'Anomalie ignoriert',
'fr': 'Anomalie ignorée',
'es': 'Anomalía descartada',
},
'error.copy_failed': {
'it': 'Copia negli appunti non riuscita',
'en': 'Copy to clipboard failed',
'de': 'Kopieren in die Zwischenablage fehlgeschlagen',
'fr': 'Échec de la copie dans le presse-papiers',
'es': 'Error al copiar al portapapeles',
},
'error.invalid_quantity': {
'it': 'Quantità non valida',
'en': 'Invalid quantity',
'de': 'Ungültige Menge',
'fr': 'Quantité invalide',
'es': 'Cantidad no válida',
},
'dashboard.banner_finished_restore_prompt': {
'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})',
'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})',
'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})',
'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})',
'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})',
},
'time.just_now': {
'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora',
},
'time.seconds_ago': {
'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s',
},
'time.minutes_ago': {
'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min',
},
'time.hours_ago': {
'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h',
},
'time.days_ago': {
'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d',
},
'use.locations_short': {
'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones',
},
'move.moved_simple': {
'it': '📦 Spostato in {location}',
'en': '📦 Moved to {location}',
'de': '📦 Nach {location} verschoben',
'fr': '📦 Déplacé vers {location}',
'es': '📦 Movido a {location}',
},
'product.history_badge': {
'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial',
},
'ai.conservation_hint': {
'it': '🤖 AI: conserva in {location}',
'en': '🤖 AI: store in {location}',
'de': '🤖 KI: lagere in {location}',
'fr': '🤖 IA : conserve dans {location}',
'es': '🤖 IA: conserva en {location}',
},
'settings.kiosk_update_required': {
'it': '⚠️ Aggiorna il kiosk per usare questa funzione',
'en': '⚠️ Update the kiosk app to use this feature',
'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen',
'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction',
'es': '⚠️ Actualiza la app kiosk para usar esta función',
},
'shopping.bring_names_migrated': {
'it': '🔄 {n} nomi generalizzati in Bring!',
'en': '🔄 {n} names generalized in Bring!',
'de': '🔄 {n} Namen in Bring! verallgemeinert',
'fr': '🔄 {n} noms généralisés dans Bring !',
'es': '🔄 {n} nombres generalizados en Bring!',
},
'scan.mode_shopping_activated': {
'it': '🛒 Modalità Spesa attivata!',
'en': '🛒 Shopping mode activated!',
'de': '🛒 Einkaufsmodus aktiviert!',
'fr': '🛒 Mode courses activé !',
'es': '🛒 ¡Modo compras activado!',
},
'settings.scale.discover_scanning': {
'it': '🔍 Scansione rete locale per gateway bilancia…',
'en': '🔍 Scanning local network for scale gateway…',
'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…',
'fr': '🔍 Recherche du gateway balance sur le réseau local…',
'es': '🔍 Buscando pasarela de báscula en la red local…',
},
'settings.scale.discover_found': {
'it': '✅ Gateway trovato: {url}{more}',
'en': '✅ Gateway found: {url}{more}',
'de': '✅ Gateway gefunden: {url}{more}',
'fr': '✅ Gateway trouvé : {url}{more}',
'es': '✅ Pasarela encontrada: {url}{more}',
},
'settings.scale.discover_not_found': {
'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.',
'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.',
'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.',
'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.',
'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.',
},
'settings.scale.discover_failed': {
'it': '❌ Ricerca fallita: {error}',
'en': '❌ Discovery failed: {error}',
'de': '❌ Suche fehlgeschlagen: {error}',
'fr': '❌ Échec de la recherche : {error}',
'es': '❌ Búsqueda fallida: {error}',
},
'settings.scale.discover_auto': {
'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto',
},
'settings.scale.unknown_device': {
'it': 'Dispositivo sconosciuto',
'en': 'Unknown device',
'de': 'Unbekanntes Gerät',
'fr': 'Appareil inconnu',
'es': 'Dispositivo desconocido',
},
'product.from_history': {
'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)',
},
'recipes.ing_stock_line': {
'it': 'Hai {have} · restano {remain} dopo l\'uso',
'en': 'You have {have} · {remain} left after use',
'de': 'Du hast {have} · {remain} bleiben nach Gebrauch',
'fr': 'Vous avez {have} · il reste {remain} après usage',
'es': 'Tienes {have} · quedan {remain} después del uso',
},
'recipes.ing_use_all_note': {
'it': 'uso totale (<5% della confezione intera)',
'en': 'use all (<5% of full package left)',
'de': 'alles verwenden (<5% der Vollpackung)',
'fr': 'tout utiliser (<5% du conditionnement entier)',
'es': 'usar todo (<5% del envase completo)',
},
}
# fr/es gaps filled with proper translations (flat key -> value)
FR_FILL: dict[str, str] = {
'action.related_stock_title': 'Aussi à la maison',
'dashboard.banner_expired_action_modify': 'Modifier',
'dashboard.banner_expired_action_vacuum': 'Mettre sous vide',
'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.',
'scan.stock_in_pantry': 'Déjà à la maison :',
'scanner.expiry_found': 'Date trouvée',
'scanner.expiry_raw_label': 'Lu',
'scanner.expiry_read_fail': 'Impossible de lire la date.',
'settings.info.act_new_products': 'Nouveaux produits',
'settings.info.act_restock': 'Réapprovisionnements',
'settings.info.act_title': 'Activité mensuelle',
'settings.info.act_tx_month': 'Mouvements',
'settings.info.act_tx_year': 'Mouvements annuels',
'settings.info.act_use': 'Utilisations',
'settings.info.ai_calls': 'Appels',
'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.',
'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système',
'settings.info.ai_title': 'Gemini AI — Utilisation des tokens',
'settings.info.bring_days': 'jeton expire dans {n} jours',
'settings.info.bring_expired': 'jeton expiré',
'settings.info.by_action': 'Répartition par fonction',
'settings.info.by_model': 'Répartition par modèle',
'settings.info.cache_entries': 'produits',
'settings.info.calls_unit': 'appels',
'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.',
'settings.info.currency_title': 'Devise',
'settings.info.db_size': 'Base de données',
'settings.info.est_cost': 'Coût est.',
'settings.info.input_tok': 'Tokens entrée',
'settings.info.inv_active': 'Actifs',
'settings.info.inv_expired': 'Expirés',
'settings.info.inv_expiring': 'Expirent (7j)',
'settings.info.inv_finished': 'Terminés',
'settings.info.inv_products': 'Produits totaux',
'settings.info.inv_title': 'Inventaire',
'settings.info.last_backup': 'Dernière sauvegarde',
'settings.info.loading': 'Chargement…',
'settings.info.log_level': 'Niveau de log',
'settings.info.log_size': 'Logs',
'settings.info.output_tok': 'Tokens sortie',
'settings.info.price_cache': 'Cache prix',
'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
'settings.info.system_title': 'Système',
'settings.info.tab': 'Info',
'settings.info.total_tokens': 'Tokens totaux',
'settings.info.year_label': 'Année {year}',
'settings.tab_general': 'Général',
'settings.tts.test_sound_btn': '🔔 Test sonore',
'shopping.pantry_hint': 'Déjà à la maison : {qty}',
'startup.check_db_legacy': 'Ancienne BD (dispensa.db)',
'startup.check_scale': 'Passerelle balance',
'startup.check_tts': 'URL synthèse vocale',
'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :',
'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.',
'toast.vacuum_sealed': '{name} enregistré sous vide',
}
ES_FILL = {
'action.related_stock_title': 'También en casa',
'dashboard.banner_expired_action_modify': 'Editar',
'dashboard.banner_expired_action_vacuum': 'Poner al vacío',
'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.',
'scan.stock_in_pantry': 'Ya en despensa:',
'scanner.expiry_found': 'Fecha encontrada',
'scanner.expiry_raw_label': 'Leído',
'scanner.expiry_read_fail': 'No se puede leer la fecha.',
'settings.info.act_new_products': 'Productos nuevos',
'settings.info.act_restock': 'Reabastecimientos',
'settings.info.act_title': 'Actividad mensual',
'settings.info.act_tx_month': 'Movimientos',
'settings.info.act_tx_year': 'Movimientos anuales',
'settings.info.act_use': 'Usos',
'settings.info.ai_calls': 'Llamadas',
'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.',
'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema',
'settings.info.ai_title': 'Gemini AI — Uso de tokens',
'settings.info.bring_days': 'token expira en {n} días',
'settings.info.bring_expired': 'token expirado',
'settings.info.by_action': 'Desglose por función',
'settings.info.by_model': 'Desglose por modelo',
'settings.info.cache_entries': 'productos',
'settings.info.calls_unit': 'llamadas',
'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.',
'settings.info.currency_title': 'Moneda',
'settings.info.db_size': 'Base de datos',
'settings.info.est_cost': 'Coste est.',
'settings.info.input_tok': 'Tokens de entrada',
'settings.info.inv_active': 'Activos',
'settings.info.inv_expired': 'Caducados',
'settings.info.inv_expiring': 'Caducan (7d)',
'settings.info.inv_finished': 'Agotados',
'settings.info.inv_products': 'Productos totales',
'settings.info.inv_title': 'Inventario',
'settings.info.last_backup': 'Última copia',
'settings.info.loading': 'Cargando…',
'settings.info.log_level': 'Nivel de log',
'settings.info.log_size': 'Logs',
'settings.info.output_tok': 'Tokens de salida',
'settings.info.price_cache': 'Caché de precios',
'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
'settings.info.system_title': 'Sistema',
'settings.info.tab': 'Info',
'settings.info.total_tokens': 'Tokens totales',
'settings.info.year_label': 'Año {year}',
'settings.tab_general': 'General',
'settings.tts.test_sound_btn': '🔔 Prueba de sonido',
'shopping.pantry_hint': 'Ya en casa: {qty}',
'startup.check_db_legacy': 'BD antigua (dispensa.db)',
'startup.check_scale': 'Pasarela báscula',
'startup.check_tts': 'URL texto a voz',
'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:',
'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.',
'toast.vacuum_sealed': '{name} guardado al vacío',
}
def flatten(obj: dict, prefix: str = '') -> dict[str, str]:
out: dict[str, str] = {}
for k, v in obj.items():
key = f'{prefix}.{k}' if prefix else k
if isinstance(v, dict):
out.update(flatten(v, key))
else:
out[key] = v
return out
def set_nested(root: dict, dotted: str, value: str) -> None:
parts = dotted.split('.')
d = root
for p in parts[:-1]:
d = d.setdefault(p, {})
d[parts[-1]] = value
def main() -> None:
ref = json.loads((ROOT / REF).read_text(encoding='utf-8'))
ref_flat = flatten(ref)
en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8')))
for fname in LOCALES:
lang = fname.replace('.json', '')
path = ROOT / fname
data = json.loads(path.read_text(encoding='utf-8'))
flat = flatten(data)
# Fill missing keys from reference (Italian text as last resort via en)
for key, ref_val in ref_flat.items():
if key not in flat:
if lang == 'fr' and key in FR_FILL:
val = FR_FILL[key]
elif lang == 'es' and key in ES_FILL:
val = ES_FILL[key]
elif lang == 'en':
val = en_flat.get(key, ref_val)
else:
val = en_flat.get(key, ref_val)
set_nested(data, key, val)
flat[key] = val
# Inject new keys
for key, per_lang in NEW_KEYS.items():
set_nested(data, key, per_lang[lang if lang in per_lang else 'en'])
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(f'Updated {fname}')
if __name__ == '__main__':
main()
+269 -20
View File
@@ -143,14 +143,22 @@
"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",
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
"banner_dup_loss_title": "Prüfung Doppelabbuchung: {name}",
"banner_dup_loss_detail": "Mögliche doppelte Buchung in {location}: zwei schnelle Abgänge ({qty_pair}) in ~{seconds}s. Bitte prüfen und ggf. korrigieren.",
"banner_dup_loss_action_fix": "Menge korrigieren",
"banner_dup_loss_action_open": "Produktkarte öffnen",
"banner_dup_loss_action_done": "Bereits geprüft",
"banner_dup_loss_toast_done": "Prüfung als erledigt markiert",
"consumed": "Verbraucht: {n} ({pct}%)",
"wasted": "Weggeworfen: {n} ({pct}%)",
"more_opened": "und {n} weitere geöffnet...",
@@ -158,7 +166,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",
@@ -213,7 +225,32 @@
"barcode_acquired": "🔖 Barcode gescannt: {code}",
"scan_barcode": "🔖 Barcode scannen",
"create_named": "{name} erstellen",
"new_without_barcode": "Neues Produkt ohne Barcode"
"new_without_barcode": "Neues Produkt ohne Barcode",
"stock_in_pantry": "Bereits im Vorrat:",
"status_ready": "Kamera auf Barcode richten",
"status_scanning": "Scanne...",
"status_partial": "Erkannt: {code} — prüfe...",
"status_invalid": "Ungültig: {code} — versuche erneut",
"status_confirmed": "Bestätigt!",
"status_parallel": "Kombinierter Scan aktiv...",
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"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_msg": "Gemini Vision analysiert das Produkt...",
"ai_retry_btn": "Mit KI erneut versuchen",
"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",
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
},
"action": {
"title": "Was möchtest du tun?",
@@ -227,7 +264,8 @@
"throw_btn": "🗑️ ENTSORGEN",
"throw_sub": "wegwerfen",
"edit_sub": "Ablauf, Ort…",
"create_recipe_btn": "Rezept"
"create_recipe_btn": "Rezept",
"related_stock_title": "Auch zuhause"
},
"add": {
"title": "Zum Vorrat hinzufügen",
@@ -285,14 +323,17 @@
"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"
},
"product": {
"title_new": "Neues Produkt",
@@ -332,7 +373,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": " (aus Verlauf)"
},
"products": {
"title": "📦 Alle Produkte",
@@ -359,12 +402,16 @@
"loading_msg": "Rezept wird vorbereitet...",
"start_cooking": "👨‍🍳 Kochmodus",
"regenerate": "🔄 Noch eins generieren",
"regen_choice_title": "Was möchtest du mit diesem Rezept machen?",
"regen_replace": "🔄 Neues generieren (dieses verwerfen)",
"regen_save_new": "💾 Im Archiv speichern & neues generieren",
"close_btn": "✅ Schließen",
"ingredients_title": "🧾 Zutaten",
"tools_title": "Benötigte Geräte",
"steps_title": "👨‍🍳 Zubereitung",
"no_steps": "Keine Zubereitungsschritte verfügbar",
"generate_error": "Fehler bei der Generierung",
"stream_interrupted": "Generierung unterbrochen (unvollstaendige Antwort vom Server). Protokolle pruefen oder erneut versuchen.",
"persons_short": "Pers.",
"use_ingredient_title": "Zutat verwenden",
"recipe_qty_label": "Rezept",
@@ -378,7 +425,21 @@
"scale_wait_stable": "10s stabiles Gewicht für Auto-Ausfüllen abwarten…",
"ingredient_scaled_toast": "📦 Zutat vom Vorrat abgezogen!",
"finished_added_bring_toast": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt!",
"load_error": "Fehler beim Laden"
"load_error": "Fehler beim Laden",
"favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen",
"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",
@@ -465,6 +526,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...",
@@ -473,7 +535,9 @@
"priority_medium": "Mittel",
"priority_low": "Niedrig",
"smart_last_update": "Aktualisiert {time}",
"names_already_updated": "Alle Namen sind bereits aktuell"
"names_already_updated": "Alle Namen sind bereits aktuell",
"pantry_hint": "Bereits zuhause: {qty}",
"bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert"
},
"ai": {
"title": "🤖 KI-Identifikation",
@@ -484,7 +548,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",
@@ -648,7 +713,9 @@
"back": "📱 Rückkamera (Standard)",
"front": "🤳 Frontkamera",
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
"detect_btn": "🔄 Kameras erkennen"
"detect_btn": "🔄 Kameras erkennen",
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini."
},
"security": {
"title": "🔒 HTTPS-Zertifikat",
@@ -686,6 +753,7 @@
"extra_fields_label": " Zusätzliche Felder (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
"test_sound_btn": "🔔 Klangtest ausführen",
"test_btn": "🔊 Testansage senden",
"voices_loading": "Stimmen werden geladen…",
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
@@ -693,7 +761,12 @@
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
"url_missing": "⚠️ Endpunkt-URL fehlt.",
"test_sending": "⏳ Wird gesendet…",
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat."
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat.",
"heard_question": "Hast du die Stimme gehört?",
"heard_yes": "Ja, ich habe es gehört",
"heard_no": "Nein, ich habe nichts gehört",
"test_ok_kiosk": "TTS funktioniert.",
"test_fail_steps": "Prüfe: 1) Medienvolume ist nicht 0; 2) Google Text-to-Speech installiert und aktualisiert; 3) Deutsches Sprachpaket in den Android TTS-Einstellungen heruntergeladen."
},
"language": {
"title": "🌐 Sprache",
@@ -731,7 +804,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) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>",
"discover_scanning": "🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…",
"discover_found": "✅ Gateway gefunden: {url}{more}",
"discover_not_found": "❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.",
"discover_failed": "❌ Suche fehlgeschlagen: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Unbekanntes Gerät"
},
"kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
@@ -762,6 +841,53 @@
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
"label": "Tipps beim Kochen anzeigen"
},
"backup": {
"tab": "Backup",
"local_title": "Lokales Backup",
"local_hint": "Täglicher Datenbank-Snapshot. Konfiguriere, wie viele Tage Backups aufbewahrt werden.",
"enabled": "Tägliches automatisches Backup aktivieren",
"retention_days": "Aufbewahrung (Tage)",
"retention_info": "Backups werden aufbewahrt für",
"backup_now": "Jetzt sichern",
"backing_up": "Sicherung läuft…",
"backed_up": "Sicherung abgeschlossen",
"backup_error": "Sicherungsfehler",
"last_backup": "Letztes Backup",
"no_backup_yet": "Noch kein Backup erstellt",
"list_empty": "Keine Backups verfügbar",
"restore_btn": "Wiederherstellen",
"restore_confirm": "Backup wiederherstellen",
"delete_btn": "Löschen",
"delete_confirm": "Backup löschen",
"gdrive_title": "Google Drive",
"gdrive_hint": "Backups automatisch via OAuth 2.0 auf Google Drive hochladen. Keine externen Bibliotheken erforderlich.",
"gdrive_enabled": "Google Drive Backup aktivieren",
"gdrive_folder_id": "Drive-Ordner-ID",
"gdrive_folder_id_hint": "Kopiere die ID aus der Drive-Ordner-URL: …/folders/<strong>ID</strong>",
"gdrive_retention_days": "Drive-Aufbewahrung (Tage, 0=alles behalten)",
"gdrive_test": "Verbindung testen",
"gdrive_ok": "Verbindung erfolgreich!",
"gdrive_error": "Verbindung fehlgeschlagen",
"gdrive_push_now": "Jetzt auf Drive hochladen",
"gdrive_pushing": "Wird hochgeladen…",
"gdrive_pushed": "Auf Drive hochgeladen",
"gdrive_wizard_hint": "Optional: täglich automatisch via OAuth 2.0 auf Google Drive sichern.",
"gdrive_skip": "Überspringen — später in Einstellungen konfigurieren",
"gdrive_client_id": "Client-ID",
"gdrive_client_secret": "Client-Secret",
"gdrive_redirect_uri_hint": "Füge <strong>http://localhost</strong> als autorisierten Weiterleitungs-URI in der Google Cloud Console hinzu. Funktioniert auf jedem Server, auch ohne öffentliche Domain.",
"gdrive_code_title": "Autorisierungs-URL oder Code einfügen",
"gdrive_code_hint": "Nach der Autorisierung öffnet der Browser http://localhost und zeigt möglicherweise einen Verbindungsfehler — das ist normal. Kopiere die URL aus der Adressleiste (z.B. <code>http://localhost/?code=4%2F0A...</code>) und füge sie hier ein.",
"gdrive_code_submit": "Bestätigen",
"gdrive_code_empty": "Bitte zuerst die URL oder den Autorisierungscode einfügen",
"gdrive_redirect_uri_label": "Redirect-URI (in Google Cloud Console eintragen):",
"gdrive_oauth_authorize": "Mit Google autorisieren",
"gdrive_oauth_authorized": "Autorisiert",
"gdrive_oauth_not_authorized": "Noch nicht autorisiert",
"gdrive_oauth_window_opened": "Browserfenster geöffnet — autorisieren und zurückkehren",
"gdrive_oauth_how_to": "OAuth 2.0 einrichten (Schritt für Schritt)",
"gdrive_oauth_steps": "<li>Gehe zu <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> und wähle dein Projekt</li><li>Aktiviere die <strong>Google Drive API</strong>: <em>APIs &amp; Dienste → APIs aktivieren → Google Drive API</em></li><li>Gehe zu <em>APIs &amp; Dienste → Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID</em></li><li>Anwendungstyp: <strong>Webanwendung</strong>; füge die unten angezeigte URL als <em>Autorisierter Weiterleitungs-URI</em> hinzu</li><li>Kopiere <strong>Client-ID</strong> und <strong>Client-Secret</strong> in die Felder oben und speichere</li><li>Klicke auf <strong>Mit Google autorisieren</strong>: melde dich an und erteile den Zugriff</li><li>Das Fenster schließt sich automatisch und Backups sind bereit</li>"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Token-Nutzung",
@@ -802,7 +928,77 @@
"currency_title": "Währung",
"currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird."
},
"tab_general": "Allgemein"
"tab_general": "Allgemein",
"shopping": {
"tab": "Einkaufsliste",
"title": "Einkaufsliste",
"hint": "Konfiguriere die integrierte Einkaufsliste oder verbinde Bring!.",
"enable_label": "Einkaufsliste aktivieren",
"mode_label": "Anbieter",
"mode_internal": "Intern (ohne Bring!)",
"mode_bring": "Bring! (externe App)",
"bring_section_title": "Bring!-Konfiguration",
"ai_section_title": "KI-Unterstützung",
"smart_suggestions_label": "KI-Vorschläge",
"forecast_label": "Prognose für bald leere Produkte",
"auto_add_label": "Automatisch hinzufügen wenn",
"auto_add_suffix": "im Lager verbleibend (0 = nur wenn leer)"
},
"ha": {
"tab": "Home Assistant",
"title": "Home Assistant",
"hint": "Verbinde EverShelf mit Home Assistant für Automationen, Push-Benachrichtigungen und REST-Sensoren.",
"enabled": "Home Assistant-Integration aktivieren",
"connection_title": "Verbindung",
"url_label": "Home Assistant URL",
"url_placeholder": "http://192.168.1.50:8123",
"url_hint": "Basis-URL deiner Home Assistant-Instanz (z.B. http://homeassistant.local:8123).",
"token_label": "Long-Lived Access Token",
"token_hint": "Erstelle unter HA-Profil → Sicherheit → Langlebige Zugangstoken.",
"token_placeholder": "eyJhbGci...",
"token_saved": "Token gespeichert (aus Sicherheitsgründen verborgen)",
"test_btn": "Verbindung testen",
"test_ok": "Verbunden mit {version}",
"test_fail": "Verbindung fehlgeschlagen: {error}",
"test_bad_token": "HA erreichbar, aber Token ist ungültig",
"testing": "Teste…",
"error_no_url": "Bitte zuerst die Home Assistant URL eingeben.",
"tts_title": "TTS auf Smart Speaker",
"tts_hint": "Rezeptschritte auf einem Home Assistant Media Player vorlesen.",
"tts_entity_label": "Media Player Entity ID",
"tts_entity_placeholder": "media_player.wohnzimmer",
"tts_entity_hint": "Entity-ID des HA-Media-Players. Zu finden unter HA: Entwicklertools → Zustände.",
"tts_platform_label": "TTS-Plattform",
"tts_platform_speak": "tts.speak (empfohlen)",
"tts_platform_notify": "notify.* (Benachrichtigungsdienst)",
"tts_apply_btn": "HA-Voreinstellung auf TTS-Tab anwenden",
"tts_apply_hint": "Füllt den TTS-Tab mit der Home Assistant URL und dem Token aus.",
"tts_preset_applied": "HA-Voreinstellung auf TTS-Tab angewendet.",
"webhook_title": "Webhook-Automationen",
"webhook_hint": "Sende Daten an Home Assistant, wenn Ereignisse in der Vorratskammer auftreten.",
"webhook_id_label": "Webhook-ID",
"webhook_id_placeholder": "evershelf_webhook_abc123",
"webhook_id_hint": "ID des in HA erstellten Webhooks. Kopiere aus: HA → Einstellungen → Automationen → Erstellen → Webhook-Auslöser.",
"webhook_events_label": "Benachrichtige bei diesen Ereignissen",
"event_expiry": "Ablaufende Produkte (täglich)",
"event_shopping": "Artikel zur Einkaufsliste hinzugefügt",
"event_stock": "Lagerbestand aktualisiert",
"expiry_days_label": "Ablaufwarnung im Voraus (Tage)",
"expiry_days_hint": "Sende die Ablaufwarnung N Tage vor dem Ablaufdatum.",
"webhook_help": "In HA: Einstellungen → Automationen → Automation erstellen → Auslöser: Webhook → ID kopieren.",
"notify_title": "Push-Benachrichtigungen",
"notify_hint": "Sende Push-Benachrichtigungen über einen Home Assistant notify-Dienst.",
"notify_service_label": "Notify-Dienst",
"notify_service_placeholder": "notify.mobile_app_mein_handy",
"notify_service_hint": "Name des HA-notify-Dienstes (z.B. notify.mobile_app_phone). Leer lassen zum Deaktivieren.",
"sensor_title": "REST-Sensoren",
"sensor_hint": "Zur configuration.yaml hinzufügen, um EverShelf-Sensoren in Home Assistant zu erstellen.",
"sensor_copy_btn": "YAML kopieren",
"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",
@@ -876,6 +1072,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"
},
@@ -925,6 +1122,7 @@
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
"barcode_empty": "Barcode eingeben",
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
"barcode_checksum": "Ungültiger EAN-Prüfziffer — bitte die Barcode-Ziffern prüfen",
"min_chars": "Mindestens 2 Zeichen eingeben",
"not_in_inventory": "Produkt nicht im Bestand",
"appliance_exists": "Gerät bereits vorhanden",
@@ -936,13 +1134,27 @@
"server_retry": "Erneut versuchen",
"unknown": "Unbekannter Fehler",
"prefix": "Fehler",
"no_inventory_entry": "Kein Inventareintrag gefunden"
"no_inventory_entry": "Kein Inventareintrag gefunden",
"offline_title": "Keine Verbindung",
"offline_subtitle": "Die App kann den Server nicht erreichen. Überprüfe deine WLAN-Verbindung.",
"offline_checking": "Verbindung prüfen…",
"offline_restored": "Verbindung wiederhergestellt!",
"offline_continue": "Im Offline-Modus fortfahren",
"offline_reading_cache": "Lese aus lokalem Cache",
"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",
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
"invalid_quantity": "Ungültige Menge"
},
"confirm_placeholder_search": null,
"confirm": {
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
"kiosk_exit": "Kioskmodus verlassen?",
"cancel": "Abbrechen",
"proceed": "Bestätigen"
"proceed": "Bestätigen",
"discard_one": "1 Stück wegwerfen"
},
"location": {
"dispensa": "Vorratskammer",
@@ -954,7 +1166,8 @@
"unknown_hint": "Produktname und Informationen eingeben",
"label_name": "🏷️ Produktname",
"choose_location_title": "Welchen Ort?",
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
"choose_location_hint": "Wähle den zu bearbeitenden Ort:",
"confirm_large_qty": "Du setzt die Menge auf {qty} {unit}. Das scheint ungewöhnlich hoch zu sein. Bestätigen?"
},
"screensaver": {
"recipe_btn": "Rezepte",
@@ -1034,7 +1247,10 @@
"retake_btn": "🔄 Erneut aufnehmen",
"camera_error_hint": "Stelle sicher, dass du HTTPS verwendest und Kameraberechtigungen erteilt hast.<br>Du kannst den Barcode manuell eingeben oder die KI-Identifikation verwenden.",
"no_barcode": "Kein Barcode",
"save_new_btn": "🆕 Keines davon — als neu speichern"
"save_new_btn": "🆕 Keines davon — als neu speichern",
"expiry_found": "Datum gefunden",
"expiry_read_fail": "Datum konnte nicht gelesen werden.",
"expiry_raw_label": "Erkannt"
},
"lowstock": {
"title": "⚠️ Wird knapp!",
@@ -1052,7 +1268,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",
@@ -1105,7 +1322,13 @@
"source": "Basierend auf {n} Produkten in deiner Vorratskammer · EverShelf",
"products_count": "Produkte",
"today_title": "🥗 Deine Vorratskammer heute",
"products_n": "{n} Produkte"
"products_n": "{n} Produkte",
"macros_title": "Geschätzte Makronährstoffe",
"macros_proteins": "Proteine",
"macros_carbs": "Kohlenhydrate",
"macros_fat": "Fett",
"macros_fiber": "Ballaststoffe",
"macros_source": "Schätzung basierend auf {n} Vorratsprodukten"
},
"facts": {
"greeting_morning": "Guten Morgen",
@@ -1279,6 +1502,32 @@
"critical_error_intro": "Die App kann aufgrund folgender Probleme nicht gestartet werden:",
"error_network": "Server nicht erreichbar.",
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
"retry": "Erneut versuchen"
"retry": "Erneut versuchen",
"syncing_local": "Lokale Daten synchronisieren...",
"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",
"consumed": "Produkte verbraucht",
"trend_up": "+{pct}% vs. {prev}",
"trend_down": "-{pct}% vs. {prev}",
"trend_same": "gleiches Tempo wie letzten Monat",
"added": "hinzugefügt",
"wasted": "verschwendet",
"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"
}
}
}
+269 -20
View File
@@ -143,14 +143,22 @@
"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",
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
"banner_anomaly_ghost_title": "you have less stock than expected",
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
"banner_dup_loss_title": "Double-consume check: {name}",
"banner_dup_loss_detail": "Possible duplicate entry in {location}: two close out events ({qty_pair}) in ~{seconds}s. Please verify and fix if needed.",
"banner_dup_loss_action_fix": "Fix quantity",
"banner_dup_loss_action_open": "Open product card",
"banner_dup_loss_action_done": "Already checked",
"banner_dup_loss_toast_done": "Check marked as reviewed",
"consumed": "Consumed: {n} ({pct}%)",
"wasted": "Wasted: {n} ({pct}%)",
"more_opened": "and {n} more opened...",
@@ -158,7 +166,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",
@@ -213,7 +225,32 @@
"barcode_acquired": "🔖 Barcode scanned: {code}",
"scan_barcode": "🔖 Scan Barcode",
"create_named": "Create {name}",
"new_without_barcode": "New product without barcode"
"new_without_barcode": "New product without barcode",
"stock_in_pantry": "Already in pantry:",
"status_ready": "Point camera at barcode",
"status_scanning": "Scanning...",
"status_partial": "Detected: {code} — verifying...",
"status_invalid": "Invalid: {code} — retrying",
"status_confirmed": "Confirmed!",
"status_parallel": "Using combined scan methods...",
"status_ocr_searching": "Reading the barcode digits...",
"status_ai_visual_searching": "Now trying to recognize the product...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"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_msg": "Gemini Vision is analyzing the product...",
"ai_retry_btn": "Retry with AI",
"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",
"mode_shopping_activated": "🛒 Shopping mode activated!"
},
"action": {
"title": "What do you want to do?",
@@ -227,7 +264,8 @@
"throw_btn": "🗑️ DISCARD",
"throw_sub": "throw away",
"edit_sub": "expiry, location…",
"create_recipe_btn": "Recipe"
"create_recipe_btn": "Recipe",
"related_stock_title": "Also at home"
},
"add": {
"title": "Add to Pantry",
@@ -285,14 +323,17 @@
"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"
},
"product": {
"title_new": "New Product",
@@ -332,7 +373,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": " (from history)"
},
"products": {
"title": "📦 All Products",
@@ -359,12 +402,16 @@
"loading_msg": "Preparing your recipe...",
"start_cooking": "👨‍🍳 Cooking Mode",
"regenerate": "🔄 Generate another one",
"regen_choice_title": "What do you want to do with this recipe?",
"regen_replace": "🔄 Generate another (discard this one)",
"regen_save_new": "💾 Save to archive & generate a new one",
"close_btn": "✅ Close",
"ingredients_title": "🧾 Ingredients",
"tools_title": "Equipment needed",
"steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available",
"generate_error": "Generation error",
"stream_interrupted": "Generation interrupted (incomplete server response). Check logs or try again.",
"persons_short": "serv.",
"use_ingredient_title": "Use ingredient",
"recipe_qty_label": "Recipe",
@@ -378,7 +425,21 @@
"scale_wait_stable": "Wait 10s of stable weight for auto-fill…",
"ingredient_scaled_toast": "📦 Ingredient deducted from pantry!",
"finished_added_bring_toast": "🛒 Finished product → added to Bring!",
"load_error": "Loading error"
"load_error": "Loading error",
"favorite": "Add to favourites",
"unfavorite": "Remove from favourites",
"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",
@@ -465,6 +526,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...",
@@ -473,7 +535,9 @@
"priority_medium": "Medium",
"priority_low": "Low",
"smart_last_update": "Updated {time}",
"names_already_updated": "All names are already up to date"
"names_already_updated": "All names are already up to date",
"pantry_hint": "Already at home: {qty}",
"bring_names_migrated": "🔄 {n} names generalized in Bring!"
},
"ai": {
"title": "🤖 AI Identification",
@@ -484,7 +548,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",
@@ -648,7 +713,9 @@
"back": "📱 Rear (default)",
"front": "🤳 Front",
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
"detect_btn": "🔄 Detect cameras"
"detect_btn": "🔄 Detect cameras",
"ai_fallback_label": "AI visual identification (5s fallback)",
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured."
},
"security": {
"title": "🔒 HTTPS Certificate",
@@ -686,6 +753,7 @@
"extra_fields_label": " Extra fields (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
"test_sound_btn": "🔔 Run Sound Test",
"test_btn": "🔊 Send Test Voice",
"voices_loading": "Loading voices…",
"voice_not_supported": "Voice not supported by this browser",
@@ -693,7 +761,12 @@
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
"url_missing": "⚠️ Endpoint URL missing.",
"test_sending": "⏳ Sending…",
"test_ok": "✅ Response {code} — check that the speaker has spoken."
"test_ok": "✅ Response {code} — check that the speaker has spoken.",
"heard_question": "Did you hear the voice?",
"heard_yes": "Yes, I heard it",
"heard_no": "No, I didn't hear it",
"test_ok_kiosk": "TTS is working.",
"test_fail_steps": "Check: 1) media volume is not 0; 2) Google Text-to-Speech is installed and updated; 3) Italian voice package is downloaded in Android TTS settings."
},
"language": {
"title": "🌐 Language",
@@ -731,7 +804,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) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>",
"discover_scanning": "🔍 Scanning local network for scale gateway…",
"discover_found": "✅ Gateway found: {url}{more}",
"discover_not_found": "❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.",
"discover_failed": "❌ Discovery failed: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Unknown device"
},
"kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
@@ -762,6 +841,53 @@
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
"label": "Show tips during cooking"
},
"backup": {
"tab": "Backup",
"local_title": "Local Backup",
"local_hint": "Daily database snapshot. Configure how many days of backups to keep.",
"enabled": "Enable daily automatic backup",
"retention_days": "Retention (days)",
"retention_info": "Backups are kept for",
"backup_now": "Backup Now",
"backing_up": "Backing up…",
"backed_up": "Backup complete",
"backup_error": "Backup error",
"last_backup": "Last backup",
"no_backup_yet": "No backup has been created yet",
"list_empty": "No backups available",
"restore_btn": "Restore",
"restore_confirm": "Restore backup",
"delete_btn": "Delete",
"delete_confirm": "Delete backup",
"gdrive_title": "Google Drive",
"gdrive_hint": "Automatically back up to Google Drive via OAuth 2.0. No external libraries required.",
"gdrive_enabled": "Enable Google Drive backup",
"gdrive_folder_id": "Drive Folder ID",
"gdrive_folder_id_hint": "Copy the ID from the Drive folder URL: …/folders/<strong>ID</strong>",
"gdrive_retention_days": "Drive retention (days, 0=keep all)",
"gdrive_test": "Test Connection",
"gdrive_ok": "Connection successful!",
"gdrive_error": "Connection failed",
"gdrive_push_now": "Upload to Drive Now",
"gdrive_pushing": "Uploading…",
"gdrive_pushed": "Uploaded to Drive",
"gdrive_wizard_hint": "Optional: automatically back up to Google Drive daily via OAuth 2.0.",
"gdrive_skip": "Skip — configure later in Settings",
"gdrive_client_id": "Client ID",
"gdrive_client_secret": "Client Secret",
"gdrive_redirect_uri_hint": "Add <strong>http://localhost</strong> as an authorized redirect URI in Google Cloud Console. This works on any server, even without a public domain.",
"gdrive_code_title": "Paste the authorization URL or code",
"gdrive_code_hint": "After authorizing, the browser will open http://localhost and may show a connection error — that is expected. Copy the URL from the address bar (e.g. <code>http://localhost/?code=4%2F0A...</code>) and paste it here.",
"gdrive_code_submit": "Submit",
"gdrive_code_empty": "Paste the URL or authorization code first",
"gdrive_redirect_uri_label": "Redirect URI (add this in Google Cloud Console):",
"gdrive_oauth_authorize": "Authorize with Google",
"gdrive_oauth_authorized": "Authorized",
"gdrive_oauth_not_authorized": "Not authorized yet",
"gdrive_oauth_window_opened": "Browser window opened — authorize and come back",
"gdrive_oauth_how_to": "How to set up OAuth 2.0 (step by step)",
"gdrive_oauth_steps": "<li>Go to <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> and select your project</li><li>Enable the <strong>Google Drive API</strong>: <em>APIs &amp; Services → Enable APIs → Google Drive API</em></li><li>Go to <em>APIs &amp; Services → Credentials → Create Credentials → OAuth client ID</em></li><li>Application type: <strong>Web application</strong>; add <strong>http://localhost</strong> as an <em>Authorized redirect URI</em></li><li>Copy the <strong>Client ID</strong> and <strong>Client Secret</strong> into the fields above and save</li><li>Click <strong>Authorize with Google</strong>, sign in and grant access</li><li>The browser will open <code>http://localhost</code> (a connection error is expected): copy the URL from the address bar and paste it in the field that appears below</li>"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Token Usage",
@@ -802,7 +928,77 @@
"currency_title": "Currency",
"currency_hint": "The currency used for all costs and prices in the app."
},
"tab_general": "General"
"tab_general": "General",
"shopping": {
"tab": "Shopping list",
"title": "Shopping list",
"hint": "Configure the built-in shopping list or connect Bring!.",
"enable_label": "Enable shopping list",
"mode_label": "Provider",
"mode_internal": "Built-in (no Bring!)",
"mode_bring": "Bring! (external app)",
"bring_section_title": "Bring! configuration",
"ai_section_title": "AI assistance",
"smart_suggestions_label": "AI suggestions",
"forecast_label": "Forecast low-stock products",
"auto_add_label": "Auto-add to list when",
"auto_add_suffix": "remaining in stock (0 = only when empty)"
},
"ha": {
"tab": "Home Assistant",
"title": "Home Assistant",
"hint": "Connect EverShelf to Home Assistant for automations, push notifications and REST sensors.",
"enabled": "Enable Home Assistant integration",
"connection_title": "Connection",
"url_label": "Home Assistant URL",
"url_placeholder": "http://192.168.1.50:8123",
"url_hint": "Base URL of your Home Assistant instance (e.g. http://homeassistant.local:8123).",
"token_label": "Long-Lived Access Token",
"token_hint": "Generate from HA Profile → Security → Long-Lived Access Tokens.",
"token_placeholder": "eyJhbGci...",
"token_saved": "Token saved (hidden for security)",
"test_btn": "Test connection",
"test_ok": "Connected to {version}",
"test_fail": "Connection failed: {error}",
"test_bad_token": "HA reachable but token is invalid",
"testing": "Testing…",
"error_no_url": "Please enter the Home Assistant URL first.",
"tts_title": "TTS on Smart Speaker",
"tts_hint": "Read recipe steps aloud on a Home Assistant media player.",
"tts_entity_label": "Media player entity ID",
"tts_entity_placeholder": "media_player.living_room",
"tts_entity_hint": "Entity ID of the HA media player. Find it in HA: Developer Tools → States.",
"tts_platform_label": "TTS platform",
"tts_platform_speak": "tts.speak (recommended)",
"tts_platform_notify": "notify.* (notification service)",
"tts_apply_btn": "Apply HA preset to TTS tab",
"tts_apply_hint": "Pre-fills the TTS tab with the Home Assistant URL and token.",
"tts_preset_applied": "HA preset applied to TTS tab.",
"webhook_title": "Webhook Automations",
"webhook_hint": "Send data to Home Assistant when pantry events occur. Create an HA automation with a Webhook trigger and paste the generated ID here.",
"webhook_id_label": "Webhook ID",
"webhook_id_placeholder": "evershelf_webhook_abc123",
"webhook_id_hint": "ID of the webhook created in HA. Copy from: HA → Settings → Automations → Create → Webhook Trigger.",
"webhook_events_label": "Notify on these events",
"event_expiry": "Expiring products (daily)",
"event_shopping": "Item added to shopping list",
"event_stock": "Stock level updated",
"expiry_days_label": "Expiry lead time (days)",
"expiry_days_hint": "Send the expiry alert N days before the expiry date.",
"webhook_help": "In HA: Settings → Automations → Create automation → Trigger: Webhook → copy the generated ID above.",
"notify_title": "Push Notifications",
"notify_hint": "Send push notifications to your phone via a Home Assistant notify service.",
"notify_service_label": "Notify service",
"notify_service_placeholder": "notify.mobile_app_my_phone",
"notify_service_hint": "HA notify service name (e.g. notify.mobile_app_phone). Leave empty to disable.",
"sensor_title": "REST Sensors",
"sensor_hint": "Add to configuration.yaml to create EverShelf sensors in Home Assistant.",
"sensor_copy_btn": "Copy YAML",
"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",
@@ -876,6 +1072,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"
},
@@ -925,6 +1122,7 @@
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
"barcode_empty": "Enter a barcode",
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
"barcode_checksum": "Invalid EAN checksum — please check the barcode digits",
"min_chars": "Type at least 2 characters",
"not_in_inventory": "Product not in inventory",
"appliance_exists": "Appliance already exists",
@@ -936,13 +1134,27 @@
"server_retry": "Retry",
"unknown": "Unknown error",
"prefix": "Error",
"no_inventory_entry": "No inventory entry found"
"no_inventory_entry": "No inventory entry found",
"offline_title": "No connection",
"offline_subtitle": "The app cannot reach the server. Check your Wi-Fi connection.",
"offline_checking": "Checking connection…",
"offline_restored": "Connection restored!",
"offline_continue": "Continue in offline mode",
"offline_reading_cache": "Reading from local cache",
"offline_ops_pending": "{n} operations pending",
"offline_synced": "{n} operations synced",
"offline_ai_disabled": "Not available offline",
"offline_cache_ready": "Offline — {n} items cached",
"copy_failed": "Copy to clipboard failed",
"invalid_quantity": "Invalid quantity"
},
"confirm_placeholder_search": null,
"confirm": {
"remove_item": "Do you really want to remove this product from inventory?",
"kiosk_exit": "Exit kiosk mode?",
"cancel": "Cancel",
"proceed": "Confirm"
"proceed": "Confirm",
"discard_one": "Discard 1 piece"
},
"location": {
"dispensa": "Pantry",
@@ -954,7 +1166,8 @@
"unknown_hint": "Enter the product name and information",
"label_name": "🏷️ Product name",
"choose_location_title": "Which location?",
"choose_location_hint": "Choose the location to edit:"
"choose_location_hint": "Choose the location to edit:",
"confirm_large_qty": "You are setting the quantity to {qty} {unit}. This seems unusually high. Confirm?"
},
"screensaver": {
"recipe_btn": "Recipes",
@@ -1034,7 +1247,10 @@
"retake_btn": "🔄 Retake",
"camera_error_hint": "Ensure you use HTTPS and have granted camera permissions.<br>You can enter the barcode manually or use AI identification.",
"no_barcode": "No barcode",
"save_new_btn": "🆕 None of these — save as new"
"save_new_btn": "🆕 None of these — save as new",
"expiry_found": "Date found",
"expiry_read_fail": "Cannot read the date.",
"expiry_raw_label": "Read"
},
"lowstock": {
"title": "⚠️ Running low!",
@@ -1052,7 +1268,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",
@@ -1105,7 +1322,13 @@
"source": "Based on {n} products in your pantry · EverShelf",
"products_count": "products",
"today_title": "🥗 Your pantry today",
"products_n": "{n} products"
"products_n": "{n} products",
"macros_title": "Estimated Macronutrients",
"macros_proteins": "Proteins",
"macros_carbs": "Carbohydrates",
"macros_fat": "Fat",
"macros_fiber": "Fibre",
"macros_source": "Estimate based on {n} pantry products"
},
"facts": {
"greeting_morning": "Good morning",
@@ -1279,6 +1502,32 @@
"critical_error_intro": "The app cannot start due to the following issues:",
"error_network": "Cannot reach the server.",
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
"retry": "Retry"
"retry": "Retry",
"syncing_local": "Syncing local data...",
"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",
"consumed": "products used",
"trend_up": "+{pct}% vs {prev}",
"trend_down": "-{pct}% vs {prev}",
"trend_same": "same pace as last month",
"added": "added",
"wasted": "wasted",
"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"
}
}
}
+319 -21
View File
@@ -141,14 +141,22 @@
"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",
"banner_anomaly_untracked_detail": "Tienes <strong>{inv_qty} {unit}</strong> en inventario, pero las salidas registradas superan las entradas — el stock inicial probablemente nunca se añadió como transacción «entrada». Puedes corregir la cantidad o registrar las entradas faltantes.",
"banner_anomaly_ghost_title": "tienes menos stock del esperado",
"banner_anomaly_ghost_detail": "Según las operaciones registradas deberías tener {expected_qty} {unit} de {name}, pero el inventario solo muestra {inv_qty} {unit}. ¿Tomaste stock sin registrarlo?",
"banner_dup_loss_title": "Control de doble salida: {name}",
"banner_dup_loss_detail": "Posible registro duplicado en {location}: dos salidas seguidas ({qty_pair}) en ~{seconds}s. Revisa y corrige si hace falta.",
"banner_dup_loss_action_fix": "Corregir cantidad",
"banner_dup_loss_action_open": "Abrir ficha del producto",
"banner_dup_loss_action_done": "Ya revisado",
"banner_dup_loss_toast_done": "Control marcado como revisado",
"consumed": "Consumido: {n} ({pct}%)",
"wasted": "Desperdiciado: {n} ({pct}%)",
"more_opened": "y {n} más abiertos...",
@@ -156,7 +164,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",
@@ -211,7 +225,32 @@
"barcode_acquired": "🔖 Código de barras escaneado: {code}",
"scan_barcode": "🔖 Escanear código de barras",
"create_named": "Crear {name}",
"new_without_barcode": "Nuevo producto sin código de barras"
"new_without_barcode": "Nuevo producto sin código de barras",
"status_ready": "Apunta la cámara al código de barras",
"status_scanning": "Escaneando...",
"status_partial": "Detectado: {code} — verificando...",
"status_invalid": "Inválido: {code} — reintentando",
"status_confirmed": "Confirmado!",
"status_parallel": "Escaneo combinado activo...",
"status_ocr_searching": "Estoy leyendo los números del código de barras...",
"status_ai_visual_searching": "Ahora intento reconocer el producto...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"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_msg": "Gemini Vision está analizando el producto...",
"ai_retry_btn": "Reintentar con IA",
"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",
"stock_in_pantry": "Ya en despensa:",
"mode_shopping_activated": "🛒 ¡Modo compras activado!"
},
"action": {
"title": "¿Qué quieres hacer?",
@@ -225,7 +264,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",
@@ -283,14 +323,17 @@
"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"
},
"product": {
"title_new": "Nuevo producto",
@@ -330,7 +373,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": " (del historial)"
},
"products": {
"title": "📦 Todos los productos",
@@ -357,6 +402,9 @@
"loading_msg": "Preparando tu receta...",
"start_cooking": "👨‍🍳 Modo cocina",
"regenerate": "🔄 Generar otra",
"regen_choice_title": "¿Qué quieres hacer con esta receta?",
"regen_replace": "🔄 Generar otra (descartar esta)",
"regen_save_new": "💾 Guardar en el archivo y generar una nueva",
"close_btn": "✅ Cerrar",
"ingredients_title": "🧾 Ingredientes",
"tools_title": "Equipo necesario",
@@ -376,7 +424,22 @@
"scale_wait_stable": "Espera 10s de peso estable para el relleno automático…",
"ingredient_scaled_toast": "📦 ¡Ingrediente deducido de la despensa!",
"finished_added_bring_toast": "🛒 Producto terminado → ¡añadido a Bring!",
"load_error": "Error de carga"
"load_error": "Error de carga",
"favorite": "Añadir a favoritos",
"unfavorite": "Quitar de favoritos",
"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",
@@ -463,6 +526,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...",
@@ -471,7 +535,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",
@@ -482,7 +548,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",
@@ -646,7 +713,9 @@
"back": "📱 Trasera (por defecto)",
"front": "🤳 Frontal",
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
"detect_btn": "🔄 Detectar cámaras"
"detect_btn": "🔄 Detectar cámaras",
"ai_fallback_label": "Identificación visual IA (repuesto 5s)",
"ai_fallback_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."
},
"security": {
"title": "🔒 Certificado HTTPS",
@@ -691,7 +760,13 @@
"voices_hint": "Las voces disponibles dependen del SO y el navegador. Pulsa ↺ si la lista no carga.",
"url_missing": "⚠️ URL del endpoint faltante.",
"test_sending": "⏳ Enviando…",
"test_ok": "✅ Respuesta {code} — comprueba que el altavoz haya hablado."
"test_ok": "✅ Respuesta {code} — comprueba que el altavoz haya hablado.",
"heard_question": "¿Has escuchado la voz?",
"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_sound_btn": "🔔 Prueba de sonido"
},
"language": {
"title": "🌐 Idioma",
@@ -729,7 +804,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) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>",
"discover_scanning": "🔍 Buscando pasarela de báscula en la red local…",
"discover_found": "✅ Pasarela encontrada: {url}{more}",
"discover_not_found": "❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.",
"discover_failed": "❌ Búsqueda fallida: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Dispositivo desconocido"
},
"kiosk": {
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
@@ -759,7 +840,165 @@
"card_title": "♻️ Consejos sin desperdicios",
"card_hint": "Durante la cocción, muestra consejos sobre cómo reutilizar los restos generados en cada paso (peladuras, agua de cocción, etc.). Desactivado por defecto.",
"label": "Mostrar consejos durante la cocción"
}
},
"backup": {
"tab": "Copia de seguridad",
"local_title": "Copia local",
"local_hint": "Instantánea diaria de la base de datos. Configura cuántos días de copias de seguridad conservar.",
"enabled": "Activar copia de seguridad diaria automática",
"retention_days": "Retención (días)",
"retention_info": "Las copias se conservan durante",
"backup_now": "Hacer copia ahora",
"backing_up": "Haciendo copia…",
"backed_up": "Copia completada",
"backup_error": "Error en la copia",
"last_backup": "Última copia",
"no_backup_yet": "Aún no se ha creado ninguna copia",
"list_empty": "No hay copias disponibles",
"restore_btn": "Restaurar",
"restore_confirm": "Restaurar la copia",
"delete_btn": "Eliminar",
"delete_confirm": "Eliminar la copia",
"gdrive_title": "Google Drive",
"gdrive_hint": "Copias de seguridad automáticas en Google Drive via OAuth 2.0. No se requieren bibliotecas externas.",
"gdrive_enabled": "Activar copia en Google Drive",
"gdrive_folder_id": "ID de carpeta de Drive",
"gdrive_folder_id_hint": "Copia el ID desde la URL de la carpeta de Drive: …/folders/<strong>ID</strong>",
"gdrive_retention_days": "Retención en Drive (días, 0=mantener todo)",
"gdrive_test": "Probar conexión",
"gdrive_ok": "Conexión exitosa!",
"gdrive_error": "Conexión fallida",
"gdrive_push_now": "Subir a Drive ahora",
"gdrive_pushing": "Subiendo…",
"gdrive_pushed": "Subido a Drive",
"gdrive_wizard_hint": "Opcional: copia de seguridad diaria automática en Google Drive via OAuth 2.0.",
"gdrive_skip": "Omitir — configurar después en Ajustes",
"gdrive_client_id": "Client ID",
"gdrive_client_secret": "Client Secret",
"gdrive_redirect_uri_hint": "Agrega <strong>http://localhost</strong> como URI de redireccionamiento autorizado en Google Cloud Console. Funciona en cualquier servidor, incluso sin dominio público.",
"gdrive_code_title": "Pegar la URL o el código de autorización",
"gdrive_code_hint": "Tras autorizar, el navegador abrirá http://localhost y puede mostrar un error de conexión — es normal. Copia la URL de la barra de direcciones (ej. <code>http://localhost/?code=4%2F0A...</code>) y pégala aquí.",
"gdrive_code_submit": "Confirmar",
"gdrive_code_empty": "Pega primero la URL o el código de autorización",
"gdrive_redirect_uri_label": "URI de redirección (agregar en Google Cloud Console):",
"gdrive_oauth_authorize": "Autorizar con Google",
"gdrive_oauth_authorized": "Autorizado",
"gdrive_oauth_not_authorized": "Aún no autorizado",
"gdrive_oauth_window_opened": "Ventana abierta — autoriza y regresa aquí",
"gdrive_oauth_how_to": "Cómo configurar OAuth 2.0 (paso a paso)",
"gdrive_oauth_steps": "<li>Ve a <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> y selecciona tu proyecto</li><li>Habilita la <strong>API de Google Drive</strong>: <em>API y servicios → Habilitar API → Google Drive API</em></li><li>Ve a <em>API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth</em></li><li>Tipo de aplicación: <strong>Aplicación web</strong>; agrega la URL mostrada abajo como <em>URI de redirección autorizado</em></li><li>Copia el <strong>Client ID</strong> y el <strong>Client Secret</strong> en los campos de arriba y guarda</li><li>Haz clic en <strong>Autorizar con Google</strong>: inicia sesión en tu cuenta de Google y concede acceso</li><li>La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas</li>"
},
"shopping": {
"tab": "Lista de la compra",
"title": "Lista de la compra",
"hint": "Configura la lista de la compra integrada o conecta Bring!.",
"enable_label": "Activar lista de la compra",
"mode_label": "Proveedor",
"mode_internal": "Integrado (sin Bring!)",
"mode_bring": "Bring! (app externa)",
"bring_section_title": "Configuración de Bring!",
"ai_section_title": "Asistencia IA",
"smart_suggestions_label": "Sugerencias IA",
"forecast_label": "Previsión de productos por agotar",
"auto_add_label": "Añadir automáticamente cuando",
"auto_add_suffix": "restante en stock (0 = solo cuando se agota)"
},
"ha": {
"tab": "Home Assistant",
"title": "Home Assistant",
"hint": "Conecta EverShelf a Home Assistant para automatizaciones, notificaciones push y sensores REST.",
"enabled": "Activar integración con Home Assistant",
"connection_title": "Conexión",
"url_label": "URL de Home Assistant",
"url_placeholder": "http://192.168.1.50:8123",
"url_hint": "URL base de tu instancia de Home Assistant.",
"token_label": "Token de acceso de larga duración",
"token_hint": "Genera desde Perfil HA → Seguridad → Tokens de acceso de larga duración.",
"token_placeholder": "eyJhbGci...",
"token_saved": "Token guardado (oculto por seguridad)",
"test_btn": "Probar conexión",
"test_ok": "Conectado a {version}",
"test_fail": "Conexión fallida: {error}",
"test_bad_token": "HA accesible pero el token no es válido",
"testing": "Probando…",
"error_no_url": "Por favor, introduce primero la URL de Home Assistant.",
"tts_title": "TTS en altavoz inteligente",
"tts_hint": "Lee los pasos de la receta en un reproductor de medios de Home Assistant.",
"tts_entity_label": "Entity ID del reproductor multimedia",
"tts_entity_placeholder": "media_player.salon",
"tts_entity_hint": "ID de entidad del reproductor multimedia HA. Encuéntralo en HA: Herramientas para desarrolladores → Estados.",
"tts_platform_label": "Plataforma TTS",
"tts_platform_speak": "tts.speak (recomendado)",
"tts_platform_notify": "notify.* (servicio de notificaciones)",
"tts_apply_btn": "Aplicar preset HA a la pestaña TTS",
"tts_apply_hint": "Pre-rellena la pestaña TTS con la URL y el token de Home Assistant.",
"tts_preset_applied": "Preset HA aplicado a la pestaña TTS.",
"webhook_title": "Automatizaciones Webhook",
"webhook_hint": "Envía datos a Home Assistant cuando ocurren eventos en la despensa.",
"webhook_id_label": "ID de Webhook",
"webhook_id_placeholder": "evershelf_webhook_abc123",
"webhook_id_hint": "ID del webhook creado en HA. Copia desde: HA → Ajustes → Automatizaciones → Crear → Disparador Webhook.",
"webhook_events_label": "Notificar en estos eventos",
"event_expiry": "Productos próximos a caducar (diario)",
"event_shopping": "Artículo añadido a la lista de compras",
"event_stock": "Nivel de stock actualizado",
"expiry_days_label": "Antelación de caducidad (días)",
"expiry_days_hint": "Enviar alerta de caducidad N días antes de la fecha.",
"webhook_help": "En HA: Ajustes → Automatizaciones → Crear automatización → Disparador: Webhook → copia el ID generado.",
"notify_title": "Notificaciones push",
"notify_hint": "Envía notificaciones push a tu teléfono mediante un servicio notify de Home Assistant.",
"notify_service_label": "Servicio notify",
"notify_service_placeholder": "notify.mobile_app_mi_telefono",
"notify_service_hint": "Nombre del servicio notify de HA. Déjalo vacío para desactivar.",
"sensor_title": "Sensores REST",
"sensor_hint": "Añade a configuration.yaml para crear sensores de EverShelf en Home Assistant.",
"sensor_copy_btn": "Copiar YAML",
"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",
@@ -832,8 +1071,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",
@@ -881,6 +1122,7 @@
"ai_quota": "Cuota de IA agotada. Inténtalo de nuevo en unos minutos.",
"barcode_empty": "Introduce un código de barras",
"barcode_format": "El código de barras solo puede contener números (4-14 dígitos)",
"barcode_checksum": "Suma de comprobación EAN inválida — verifica los dígitos del código",
"min_chars": "Escribe al menos 2 caracteres",
"not_in_inventory": "Producto no en inventario",
"appliance_exists": "El electrodoméstico ya existe",
@@ -892,13 +1134,27 @@
"server_retry": "Reintentar",
"unknown": "Error desconocido",
"prefix": "Error",
"no_inventory_entry": "No se encontró ninguna entrada de inventario"
"no_inventory_entry": "No se encontró ninguna entrada de inventario",
"offline_title": "Sin conexión",
"offline_subtitle": "La app no puede conectar con el servidor. Verifica tu conexión Wi-Fi.",
"offline_checking": "Verificando conexión…",
"offline_restored": "¡Conexión restaurada!",
"offline_continue": "Continuar en modo sin conexión",
"offline_reading_cache": "Leyendo desde caché local",
"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é",
"copy_failed": "Error al copiar al portapapeles",
"invalid_quantity": "Cantidad no válida"
},
"confirm_placeholder_search": null,
"confirm": {
"remove_item": "¿Realmente quieres eliminar este producto del inventario?",
"kiosk_exit": "¿Salir del modo kiosco?",
"cancel": "Cancelar",
"proceed": "Confirmar"
"proceed": "Confirmar",
"discard_one": "Tirar 1 unidad"
},
"location": {
"dispensa": "Despensa",
@@ -910,7 +1166,8 @@
"unknown_hint": "Introduce el nombre del producto y la información",
"label_name": "🏷️ Nombre del producto",
"choose_location_title": "¿Qué ubicación?",
"choose_location_hint": "Elige la ubicación a editar:"
"choose_location_hint": "Elige la ubicación a editar:",
"confirm_large_qty": "Estás configurando la cantidad a {qty} {unit}. Esto parece inusualmente alto. ¿Confirmar?"
},
"screensaver": {
"recipe_btn": "Recetas",
@@ -990,7 +1247,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!",
@@ -1008,7 +1268,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",
@@ -1061,7 +1322,13 @@
"source": "Basado en {n} productos en tu despensa · EverShelf",
"products_count": "productos",
"today_title": "🥗 Tu despensa hoy",
"products_n": "{n} productos"
"products_n": "{n} productos",
"macros_title": "Macronutrientes estimados",
"macros_proteins": "Proteínas",
"macros_carbs": "Carbohidratos",
"macros_fat": "Grasas",
"macros_fiber": "Fibra",
"macros_source": "Estimación basada en {n} productos en despensa"
},
"facts": {
"greeting_morning": "Buenos días",
@@ -1230,6 +1497,37 @@
"critical_error_short": "Error crítico",
"critical_error": "Error crítico: la aplicación no puede iniciarse. Revisa los registros del servidor.",
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
"retry": "Reintentar"
"retry": "Reintentar",
"syncing_local": "Sincronizando datos locales...",
"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",
"consumed": "productos usados",
"trend_up": "+{pct}% vs {prev}",
"trend_down": "-{pct}% vs {prev}",
"trend_same": "mismo ritmo que el mes pasado",
"added": "añadidos",
"wasted": "desperdiciados",
"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"
}
}
}
+319 -21
View File
@@ -141,14 +141,22 @@
"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",
"banner_anomaly_untracked_detail": "Vous avez <strong>{inv_qty} {unit}</strong> en inventaire, mais les sorties enregistrées dépassent les entrées — le stock initial n'a probablement jamais été ajouté comme transaction « entrée ». Vous pouvez corriger la quantité ou saisir les entrées manquantes.",
"banner_anomaly_ghost_title": "vous avez moins de stock que prévu",
"banner_anomaly_ghost_detail": "D'après les opérations enregistrées vous devriez avoir {expected_qty} {unit} de {name}, mais l'inventaire n'en montre que {inv_qty} {unit}. Avez-vous pris du stock sans l'enregistrer ?",
"banner_dup_loss_title": "Vérification double sortie : {name}",
"banner_dup_loss_detail": "Doublon possible dans {location} : deux sorties rapprochées ({qty_pair}) en ~{seconds}s. Vérifiez et corrigez si besoin.",
"banner_dup_loss_action_fix": "Corriger la quantité",
"banner_dup_loss_action_open": "Ouvrir la fiche produit",
"banner_dup_loss_action_done": "Déjà vérifié",
"banner_dup_loss_toast_done": "Contrôle marqué comme vérifié",
"consumed": "Consommé : {n} ({pct}%)",
"wasted": "Gaspillé : {n} ({pct}%)",
"more_opened": "et {n} autres ouverts...",
@@ -156,7 +164,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",
@@ -211,7 +225,32 @@
"barcode_acquired": "🔖 Code-barres scanné : {code}",
"scan_barcode": "🔖 Scanner le code-barres",
"create_named": "Créer {name}",
"new_without_barcode": "Nouveau produit sans code-barres"
"new_without_barcode": "Nouveau produit sans code-barres",
"status_ready": "Pointez la caméra sur le code-barres",
"status_scanning": "Scan en cours...",
"status_partial": "Lu : {code} — vérification...",
"status_invalid": "Invalide : {code} — nouvel essai",
"status_confirmed": "Confirmé !",
"status_parallel": "Scan combiné actif...",
"status_ocr_searching": "Je lis les chiffres du code-barres...",
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"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_msg": "Gemini Vision analyse le produit...",
"ai_retry_btn": "Reessayer avec l'IA",
"ai_match_title": "Produit reconnu par l'IA",
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
"ai_match_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",
"stock_in_pantry": "Déjà à la maison :",
"mode_shopping_activated": "🛒 Mode courses activé !"
},
"action": {
"title": "Que voulez-vous faire ?",
@@ -225,7 +264,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",
@@ -283,14 +323,17 @@
"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"
},
"product": {
"title_new": "Nouveau produit",
@@ -330,7 +373,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": " (historique)"
},
"products": {
"title": "📦 Tous les produits",
@@ -357,6 +402,9 @@
"loading_msg": "Préparation de votre recette...",
"start_cooking": "👨‍🍳 Mode cuisine",
"regenerate": "🔄 En générer une autre",
"regen_choice_title": "Que veux-tu faire de cette recette ?",
"regen_replace": "🔄 En générer une autre (ignorer celle-ci)",
"regen_save_new": "💾 Sauvegarder dans l'archive et en générer une nouvelle",
"close_btn": "✅ Fermer",
"ingredients_title": "🧾 Ingrédients",
"tools_title": "Matériel nécessaire",
@@ -376,7 +424,22 @@
"scale_wait_stable": "Attendez 10s de poids stable pour le remplissage automatique…",
"ingredient_scaled_toast": "📦 Ingrédient déduit du garde-manger !",
"finished_added_bring_toast": "🛒 Produit terminé → ajouté à Bring !",
"load_error": "Erreur de chargement"
"load_error": "Erreur de chargement",
"favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris",
"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": {
"title": "🛒 Liste de courses",
@@ -463,6 +526,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...",
@@ -471,7 +535,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",
@@ -482,7 +548,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",
@@ -646,7 +713,9 @@
"back": "📱 Arrière (par défaut)",
"front": "🤳 Frontale",
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
"detect_btn": "🔄 Détecter les caméras"
"detect_btn": "🔄 Détecter les caméras",
"ai_fallback_label": "Identification visuelle IA (repli 5s)",
"ai_fallback_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é."
},
"security": {
"title": "🔒 Certificat HTTPS",
@@ -691,7 +760,13 @@
"voices_hint": "Les voix disponibles dépendent du système d'exploitation et du navigateur. Appuyez sur ↺ si la liste ne se charge pas.",
"url_missing": "⚠️ URL de l'endpoint manquante.",
"test_sending": "⏳ Envoi…",
"test_ok": "✅ Réponse {code} — vérifiez que le haut-parleur a parlé."
"test_ok": "✅ Réponse {code} — vérifiez que le haut-parleur a parlé.",
"heard_question": "Avez-vous entendu la voix ?",
"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_sound_btn": "🔔 Test sonore"
},
"language": {
"title": "🌐 Langue",
@@ -729,7 +804,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) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>",
"discover_scanning": "🔍 Recherche du gateway balance sur le réseau local…",
"discover_found": "✅ Gateway trouvé : {url}{more}",
"discover_not_found": "❌ Aucun gateway sur {subnet}. Lancez l'app Android sur le même Wi-Fi.",
"discover_failed": "❌ Échec de la recherche : {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Appareil inconnu"
},
"kiosk": {
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.",
@@ -759,7 +840,165 @@
"card_title": "♻️ Conseils zéro déchet",
"card_hint": "Pendant la cuisson, affichez des conseils pour réutiliser les déchets produits à chaque étape (épluchures, eau de cuisson, etc.). Désactivé par défaut.",
"label": "Afficher les conseils pendant la cuisson"
}
},
"backup": {
"tab": "Sauvegarde",
"local_title": "Sauvegarde locale",
"local_hint": "Instantané quotidien de la base de données. Configurez le nombre de jours de rétention.",
"enabled": "Activer la sauvegarde automatique quotidienne",
"retention_days": "Rétention (jours)",
"retention_info": "Les sauvegardes sont conservées pendant",
"backup_now": "Sauvegarder maintenant",
"backing_up": "Sauvegarde en cours…",
"backed_up": "Sauvegarde terminée",
"backup_error": "Erreur de sauvegarde",
"last_backup": "Dernière sauvegarde",
"no_backup_yet": "Aucune sauvegarde créée",
"list_empty": "Aucune sauvegarde disponible",
"restore_btn": "Restaurer",
"restore_confirm": "Restaurer la sauvegarde",
"delete_btn": "Supprimer",
"delete_confirm": "Supprimer la sauvegarde",
"gdrive_title": "Google Drive",
"gdrive_hint": "Sauvegardez automatiquement sur Google Drive via OAuth 2.0. Aucune bibliothèque externe requise.",
"gdrive_enabled": "Activer la sauvegarde Google Drive",
"gdrive_folder_id": "ID du dossier Drive",
"gdrive_folder_id_hint": "Copiez l'ID depuis l'URL du dossier Drive : …/folders/<strong>ID</strong>",
"gdrive_retention_days": "Rétention Drive (jours, 0=tout garder)",
"gdrive_test": "Tester la connexion",
"gdrive_ok": "Connexion réussie !",
"gdrive_error": "Échec de la connexion",
"gdrive_push_now": "Téléverser sur Drive maintenant",
"gdrive_pushing": "Téléversement en cours…",
"gdrive_pushed": "Téléversé sur Drive",
"gdrive_wizard_hint": "Optionnel : sauvegarde quotidienne automatique sur Google Drive via OAuth 2.0.",
"gdrive_skip": "Passer — configurer plus tard dans Paramètres",
"gdrive_client_id": "Client ID",
"gdrive_client_secret": "Client Secret",
"gdrive_redirect_uri_hint": "Ajoute <strong>http://localhost</strong> comme URI de redirection autorisé dans la Google Cloud Console. Fonctionne sur n'importe quel serveur, même sans domaine public.",
"gdrive_code_title": "Coller l'URL ou le code d'autorisation",
"gdrive_code_hint": "Après autorisation, le navigateur ouvre http://localhost et peut afficher une erreur de connexion — c'est normal. Copie l'URL dans la barre d'adresse (ex. <code>http://localhost/?code=4%2F0A...</code>) et colle-la ici.",
"gdrive_code_submit": "Confirmer",
"gdrive_code_empty": "Coller d'abord l'URL ou le code d'autorisation",
"gdrive_redirect_uri_label": "URI de redirection (ajouter dans Google Cloud Console) :",
"gdrive_oauth_authorize": "Autoriser avec Google",
"gdrive_oauth_authorized": "Autorisé",
"gdrive_oauth_not_authorized": "Pas encore autorisé",
"gdrive_oauth_window_opened": "Fenêtre ouverte — autorisez et revenez ici",
"gdrive_oauth_how_to": "Configurer OAuth 2.0 (étape par étape)",
"gdrive_oauth_steps": "<li>Allez sur <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> et sélectionnez votre projet</li><li>Activez l<strong>API Google Drive</strong> : <em>API et services → Activer les API → Google Drive API</em></li><li>Allez dans <em>API et services → Identifiants → Créer des identifiants → ID client OAuth</em></li><li>Type dapplication : <strong>Application Web</strong> ; ajoutez lURL affichée ci-dessous comme <em>URI de redirection autorisé</em></li><li>Copiez le <strong>Client ID</strong> et le <strong>Client Secret</strong> dans les champs ci-dessus et enregistrez</li><li>Cliquez sur <strong>Autoriser avec Google</strong> : connectez-vous et accordez laccès</li><li>La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes</li>"
},
"shopping": {
"tab": "Liste de courses",
"title": "Liste de courses",
"hint": "Configurez la liste de courses intégrée ou connectez Bring!.",
"enable_label": "Activer la liste de courses",
"mode_label": "Fournisseur",
"mode_internal": "Intégré (sans Bring!)",
"mode_bring": "Bring! (application externe)",
"bring_section_title": "Configuration Bring!",
"ai_section_title": "Assistance IA",
"smart_suggestions_label": "Suggestions IA",
"forecast_label": "Prévision des produits bientôt épuisés",
"auto_add_label": "Ajouter automatiquement quand",
"auto_add_suffix": "restant en stock (0 = seulement quand épuisé)"
},
"ha": {
"tab": "Home Assistant",
"title": "Home Assistant",
"hint": "Connectez EverShelf à Home Assistant pour les automations, les notifications push et les capteurs REST.",
"enabled": "Activer l'intégration Home Assistant",
"connection_title": "Connexion",
"url_label": "URL Home Assistant",
"url_placeholder": "http://192.168.1.50:8123",
"url_hint": "URL de base de votre instance Home Assistant.",
"token_label": "Jeton d'accès longue durée",
"token_hint": "Générez depuis Profil HA → Sécurité → Jetons d'accès longue durée.",
"token_placeholder": "eyJhbGci...",
"token_saved": "Jeton enregistré (masqué pour des raisons de sécurité)",
"test_btn": "Tester la connexion",
"test_ok": "Connecté à {version}",
"test_fail": "Connexion échouée : {error}",
"test_bad_token": "HA accessible mais le jeton est invalide",
"testing": "Test en cours…",
"error_no_url": "Veuillez d'abord saisir l'URL de Home Assistant.",
"tts_title": "TTS sur enceinte connectée",
"tts_hint": "Lisez les étapes de recette sur un media player Home Assistant.",
"tts_entity_label": "Entity ID du lecteur multimédia",
"tts_entity_placeholder": "media_player.salon",
"tts_entity_hint": "Entity ID du lecteur multimédia HA. Disponible dans HA : Outils développeur → États.",
"tts_platform_label": "Plateforme TTS",
"tts_platform_speak": "tts.speak (recommandé)",
"tts_platform_notify": "notify.* (service de notification)",
"tts_apply_btn": "Appliquer le preset HA à l'onglet TTS",
"tts_apply_hint": "Pré-remplit l'onglet TTS avec l'URL et le jeton de Home Assistant.",
"tts_preset_applied": "Preset HA appliqué à l'onglet TTS.",
"webhook_title": "Automations Webhook",
"webhook_hint": "Envoyez des données à Home Assistant lors d'événements dans le garde-manger.",
"webhook_id_label": "ID Webhook",
"webhook_id_placeholder": "evershelf_webhook_abc123",
"webhook_id_hint": "ID du webhook créé dans HA. Copiez depuis : HA → Paramètres → Automations → Créer → Déclencheur Webhook.",
"webhook_events_label": "Notifier pour ces événements",
"event_expiry": "Produits expirant bientôt (quotidien)",
"event_shopping": "Article ajouté à la liste de courses",
"event_stock": "Niveau de stock mis à jour",
"expiry_days_label": "Préavis d'expiration (jours)",
"expiry_days_hint": "Envoyer l'alerte d'expiration N jours avant la date d'expiration.",
"webhook_help": "Dans HA : Paramètres → Automations → Créer → Déclencheur : Webhook → copier l'ID généré.",
"notify_title": "Notifications push",
"notify_hint": "Envoyez des notifications push sur votre téléphone via un service notify de Home Assistant.",
"notify_service_label": "Service notify",
"notify_service_placeholder": "notify.mobile_app_mon_telephone",
"notify_service_hint": "Nom du service notify HA. Laissez vide pour désactiver.",
"sensor_title": "Capteurs REST",
"sensor_hint": "Ajoutez à configuration.yaml pour créer des capteurs EverShelf dans Home Assistant.",
"sensor_copy_btn": "Copier le YAML",
"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",
@@ -832,8 +1071,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",
@@ -881,6 +1122,7 @@
"ai_quota": "Quota IA épuisé. Réessayez dans quelques minutes.",
"barcode_empty": "Entrez un code-barres",
"barcode_format": "Le code-barres ne doit contenir que des chiffres (4-14 chiffres)",
"barcode_checksum": "Somme de contrôle EAN invalide — vérifiez les chiffres du code-barres",
"min_chars": "Tapez au moins 2 caractères",
"not_in_inventory": "Produit absent de l'inventaire",
"appliance_exists": "L'appareil existe déjà",
@@ -892,13 +1134,27 @@
"server_retry": "Réessayer",
"unknown": "Erreur inconnue",
"prefix": "Erreur",
"no_inventory_entry": "Aucune entrée d'inventaire trouvée"
"no_inventory_entry": "Aucune entrée d'inventaire trouvée",
"offline_title": "Aucune connexion",
"offline_subtitle": "L'app ne peut pas atteindre le serveur. Vérifiez votre connexion Wi-Fi.",
"offline_checking": "Vérification de la connexion…",
"offline_restored": "Connexion rétablie !",
"offline_continue": "Continuer en mode hors ligne",
"offline_reading_cache": "Lecture depuis le cache local",
"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",
"copy_failed": "Échec de la copie dans le presse-papiers",
"invalid_quantity": "Quantité invalide"
},
"confirm_placeholder_search": null,
"confirm": {
"remove_item": "Voulez-vous vraiment supprimer ce produit de l'inventaire ?",
"kiosk_exit": "Quitter le mode kiosque ?",
"cancel": "Annuler",
"proceed": "Confirmer"
"proceed": "Confirmer",
"discard_one": "Jeter 1 pièce"
},
"location": {
"dispensa": "Garde-manger",
@@ -910,7 +1166,8 @@
"unknown_hint": "Entrez le nom du produit et les informations",
"label_name": "🏷️ Nom du produit",
"choose_location_title": "Quel emplacement ?",
"choose_location_hint": "Choisissez l'emplacement à modifier :"
"choose_location_hint": "Choisissez l'emplacement à modifier :",
"confirm_large_qty": "Vous définissez la quantité à {qty} {unit}. Cela semble inhabituellement élevé. Confirmer ?"
},
"screensaver": {
"recipe_btn": "Recettes",
@@ -990,7 +1247,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 !",
@@ -1008,7 +1268,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é",
@@ -1061,7 +1322,13 @@
"source": "Basé sur {n} produits dans votre garde-manger · EverShelf",
"products_count": "produits",
"today_title": "🥗 Votre garde-manger aujourd'hui",
"products_n": "{n} produits"
"products_n": "{n} produits",
"macros_title": "Macronutriments estimés",
"macros_proteins": "Protéines",
"macros_carbs": "Glucides",
"macros_fat": "Lipides",
"macros_fiber": "Fibres",
"macros_source": "Estimation basée sur {n} produits en stock"
},
"facts": {
"greeting_morning": "Bonjour",
@@ -1230,6 +1497,37 @@
"critical_error_short": "Erreur critique",
"critical_error": "Erreur critique : l'application ne peut pas démarrer. Vérifiez les logs.",
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
"retry": "Réessayer"
"retry": "Réessayer",
"syncing_local": "Synchronisation des données locales...",
"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",
"consumed": "produits utilisés",
"trend_up": "+{pct}% vs {prev}",
"trend_down": "-{pct}% vs {prev}",
"trend_same": "même rythme que le mois dernier",
"added": "ajoutés",
"wasted": "gaspillés",
"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"
}
}
}
+268 -20
View File
@@ -143,14 +143,22 @@
"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",
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
"banner_dup_loss_title": "Controllo doppio scarico: {name}",
"banner_dup_loss_detail": "Possibile doppia registrazione in {location}: due uscite ravvicinate ({qty_pair}) in ~{seconds}s. Verifica se va corretta.",
"banner_dup_loss_action_fix": "Correggi quantità",
"banner_dup_loss_action_open": "Apri scheda prodotto",
"banner_dup_loss_action_done": "Già verificato",
"banner_dup_loss_toast_done": "Controllo segnato come verificato",
"consumed": "Consumati: {n} ({pct}%)",
"wasted": "Buttati: {n} ({pct}%)",
"more_opened": "e altri {n} prodotti aperti...",
@@ -158,7 +166,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",
@@ -213,7 +225,32 @@
"barcode_acquired": "🔖 Barcode acquisito: {code}",
"scan_barcode": "🔖 Scansiona Barcode",
"create_named": "Crea {name}",
"new_without_barcode": "Nuovo prodotto senza barcode"
"new_without_barcode": "Nuovo prodotto senza barcode",
"stock_in_pantry": "Hai gia in dispensa:",
"status_ready": "Inquadra il codice a barre",
"status_scanning": "Scansione in corso...",
"status_partial": "Letto: {code} — verifico...",
"status_invalid": "Non valido: {code} — riprovo",
"status_confirmed": "Confermato!",
"status_parallel": "Doppia scansione attiva...",
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
"method_ai_ocr": "Gemini OCR",
"method_ai_vision": "Gemini Vision",
"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_msg": "Gemini Vision sta analizzando il prodotto...",
"ai_retry_btn": "Riprova con AI",
"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",
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
},
"action": {
"title": "Cosa vuoi fare?",
@@ -227,7 +264,8 @@
"throw_btn": "🗑️ BUTTA",
"throw_sub": "butta il prodotto",
"edit_sub": "scadenza, luogo…",
"create_recipe_btn": "Ricetta"
"create_recipe_btn": "Ricetta",
"related_stock_title": "Hai anche in casa"
},
"add": {
"title": "Aggiungi alla Dispensa",
@@ -285,14 +323,17 @@
"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"
},
"product": {
"title_new": "Nuovo Prodotto",
@@ -332,7 +373,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": " (da storico)"
},
"products": {
"title": "📦 Tutti i Prodotti",
@@ -359,12 +402,16 @@
"loading_msg": "Sto preparando la ricetta...",
"start_cooking": "👨‍🍳 Modalità Cucina",
"regenerate": "🔄 Generane un'altra",
"regen_choice_title": "Cosa vuoi fare con questa ricetta?",
"regen_replace": "🔄 Genera un'altra (scarta questa)",
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
"close_btn": "✅ Chiudi",
"ingredients_title": "🧾 Ingredienti",
"tools_title": "Strumenti necessari",
"steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile",
"generate_error": "Errore nella generazione",
"stream_interrupted": "Generazione interrotta (risposta incompleta dal server). Controlla i log o riprova.",
"persons_short": "pers.",
"use_ingredient_title": "Usa ingrediente",
"recipe_qty_label": "Ricetta",
@@ -378,7 +425,21 @@
"scale_wait_stable": "Attendi 10s di stabilità per la compilazione automatica…",
"ingredient_scaled_toast": "📦 Ingrediente scalato dalla dispensa!",
"finished_added_bring_toast": "🛒 Prodotto finito → aggiunto a Bring!",
"load_error": "Errore nel caricamento"
"load_error": "Errore nel caricamento",
"favorite": "Aggiungi ai preferiti",
"unfavorite": "Rimuovi dai preferiti",
"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",
@@ -465,6 +526,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...",
@@ -473,7 +535,9 @@
"priority_medium": "Media",
"priority_low": "Bassa",
"smart_last_update": "Aggiornato {time}",
"names_already_updated": "Tutti i nomi sono già aggiornati"
"names_already_updated": "Tutti i nomi sono già aggiornati",
"pantry_hint": "Hai gia {qty} in dispensa",
"bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!"
},
"ai": {
"title": "🤖 Identificazione AI",
@@ -484,7 +548,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",
@@ -648,7 +713,9 @@
"back": "📱 Posteriore (default)",
"front": "🤳 Anteriore",
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
"detect_btn": "🔄 Rileva fotocamere"
"detect_btn": "🔄 Rileva fotocamere",
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
"ai_fallback_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."
},
"security": {
"title": "🔒 Certificato HTTPS",
@@ -686,6 +753,7 @@
"extra_fields_label": " Campi extra (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
"test_sound_btn": "🔔 Esegui Test Suono",
"test_btn": "🔊 Invia Test Vocale",
"voices_loading": "Caricamento voci…",
"voice_not_supported": "Voce non supportata dal browser",
@@ -693,7 +761,12 @@
"voices_hint": "Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.",
"url_missing": "⚠️ URL endpoint mancante.",
"test_sending": "⏳ Invio in corso…",
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato."
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato.",
"heard_question": "Hai sentito la voce?",
"heard_yes": "Sì, ho sentito",
"heard_no": "No, non ho sentito",
"test_ok_kiosk": "TTS funzionante.",
"test_fail_steps": "Controlla: 1) volume media del dispositivo non sia 0; 2) Google Text-to-Speech installato e aggiornato; 3) pacchetto vocale italiano scaricato nelle impostazioni TTS Android."
},
"language": {
"title": "🌐 Lingua / Language",
@@ -731,7 +804,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) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>",
"discover_scanning": "🔍 Scansione rete locale per gateway bilancia…",
"discover_found": "✅ Gateway trovato: {url}{more}",
"discover_not_found": "❌ Nessun gateway su {subnet}. Avvia l'app Android sulla stessa Wi-Fi.",
"discover_failed": "❌ Ricerca fallita: {error}",
"discover_auto": "🔍 Auto",
"unknown_device": "Dispositivo sconosciuto"
},
"kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
@@ -762,6 +841,53 @@
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
"label": "Mostra suggerimenti durante la cottura"
},
"backup": {
"tab": "Backup",
"local_title": "Backup Locale",
"local_hint": "Snapshot giornaliero del database. Configura quanti giorni di backup conservare.",
"enabled": "Backup automatico quotidiano",
"retention_days": "Giorni di retention",
"retention_info": "I backup vengono conservati per",
"backup_now": "Backup Ora",
"backing_up": "Backup in corso…",
"backed_up": "Backup completato",
"backup_error": "Errore backup",
"last_backup": "Ultimo backup",
"no_backup_yet": "Nessun backup ancora eseguito",
"list_empty": "Nessun backup disponibile",
"restore_btn": "Ripristina",
"restore_confirm": "Ripristinare il backup",
"delete_btn": "Elimina",
"delete_confirm": "Eliminare il backup",
"gdrive_title": "Google Drive",
"gdrive_hint": "Backup automatici su Google Drive via OAuth 2.0. Nessuna libreria esterna richiesta.",
"gdrive_enabled": "Abilita backup Google Drive",
"gdrive_folder_id": "ID Cartella Drive",
"gdrive_folder_id_hint": "Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong>",
"gdrive_retention_days": "Retention Drive (giorni, 0=tutto)",
"gdrive_test": "Testa Connessione",
"gdrive_ok": "Connessione riuscita!",
"gdrive_error": "Connessione fallita",
"gdrive_push_now": "Carica Ora su Drive",
"gdrive_pushing": "Upload in corso…",
"gdrive_pushed": "Caricato su Drive",
"gdrive_wizard_hint": "Opzionale: backup giornaliero automatico su Google Drive via OAuth 2.0.",
"gdrive_skip": "Salta — configura dopo in Impostazioni",
"gdrive_client_id": "Client ID",
"gdrive_client_secret": "Client Secret",
"gdrive_redirect_uri_label": "Redirect URI (da aggiungere in Google Cloud Console):",
"gdrive_redirect_uri_hint": "Aggiungi <strong>http://localhost</strong> come URI di reindirizzamento autorizzato in Google Cloud Console. Funziona su qualsiasi server, anche senza dominio pubblico.",
"gdrive_oauth_authorize": "Autorizza con Google",
"gdrive_oauth_authorized": "Autorizzato",
"gdrive_oauth_not_authorized": "Non ancora autorizzato",
"gdrive_oauth_window_opened": "Finestra aperta — autorizza e torna qui",
"gdrive_oauth_how_to": "Come configurare OAuth 2.0 (passo dopo passo)",
"gdrive_oauth_steps": "<li>Vai su <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> e seleziona il progetto</li><li>Abilita la <strong>Google Drive API</strong>: <em>API e servizi → Abilita API → Google Drive API</em></li><li>Vai su <em>API e servizi → Credenziali → Crea credenziali → ID client OAuth 2.0</em></li><li>Tipo applicazione: <strong>Applicazione web</strong>; aggiungi <strong>http://localhost</strong> come <em>URI di reindirizzamento autorizzato</em></li><li>Copia <strong>Client ID</strong> e <strong>Client Secret</strong> nei campi qui sopra e salva</li><li>Clicca <strong>Autorizza con Google</strong>, accedi e concedi l'accesso</li><li>Il browser aprirà <code>http://localhost</code> (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sotto</li>",
"gdrive_code_title": "Incolla l'URL o il codice di autorizzazione",
"gdrive_code_hint": "Dopo aver autorizzato, il browser aprirà http://localhost e potrebbe mostrare un errore. Copia l'URL dalla barra degli indirizzi (es. <code>http://localhost/?code=4%2F0A...</code>) e incollalo qui.",
"gdrive_code_submit": "Conferma",
"gdrive_code_empty": "Incolla prima l'URL o il codice di autorizzazione"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Utilizzo Token",
@@ -802,7 +928,77 @@
"currency_title": "Valuta",
"currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app."
},
"tab_general": "Generali"
"tab_general": "Generali",
"shopping": {
"tab": "Lista spesa",
"title": "Lista della spesa",
"hint": "Configura la lista della spesa integrata o collega Bring!.",
"enable_label": "Abilita lista della spesa",
"mode_label": "Provider",
"mode_internal": "Interno (senza Bring!)",
"mode_bring": "Bring! (app esterna)",
"bring_section_title": "Configurazione Bring!",
"ai_section_title": "Assistenza AI",
"smart_suggestions_label": "Suggerimenti AI",
"forecast_label": "Previsione prodotti in esaurimento",
"auto_add_label": "Aggiungi automaticamente quando",
"auto_add_suffix": "rimasto in magazzino (0 = solo quando esaurito)"
},
"ha": {
"tab": "Home Assistant",
"title": "Home Assistant",
"hint": "Collega EverShelf a Home Assistant per automazioni, notifiche push e sensori REST.",
"enabled": "Abilita integrazione Home Assistant",
"connection_title": "Connessione",
"url_label": "URL Home Assistant",
"url_placeholder": "http://192.168.1.50:8123",
"url_hint": "URL del tuo server Home Assistant (es. http://homeassistant.local:8123).",
"token_label": "Long-Lived Access Token",
"token_hint": "Genera da Profilo HA → Sicurezza → Token di accesso a lungo termine.",
"token_placeholder": "eyJhbGci...",
"token_saved": "Token salvato (non mostrato per sicurezza)",
"test_btn": "Testa connessione",
"test_ok": "Connesso a {version}",
"test_fail": "Connessione fallita: {error}",
"test_bad_token": "HA raggiungibile ma token non valido",
"testing": "Test in corso…",
"error_no_url": "Inserisci prima l'URL di Home Assistant.",
"tts_title": "TTS su Speaker Smart",
"tts_hint": "Leggi i passi delle ricette su un media player di Home Assistant.",
"tts_entity_label": "Entity ID media player",
"tts_entity_placeholder": "media_player.living_room",
"tts_entity_hint": "Entity ID del media player su cui vuoi la voce. Puoi trovarlo in HA: Strumenti per sviluppatori → Stati.",
"tts_platform_label": "Piattaforma TTS",
"tts_platform_speak": "tts.speak (raccomandato)",
"tts_platform_notify": "notify.* (servizio notifiche)",
"tts_apply_btn": "Applica preset HA al tab TTS",
"tts_apply_hint": "Pre-compila il tab TTS con l'URL e il token di Home Assistant.",
"tts_preset_applied": "Preset HA applicato al tab TTS.",
"webhook_title": "Automazioni Webhook",
"webhook_hint": "Invia dati a Home Assistant quando avvengono eventi nella dispensa. Crea un'automazione in HA con trigger Webhook e copia l'ID generato.",
"webhook_id_label": "Webhook ID",
"webhook_id_placeholder": "evershelf_webhook_abc123",
"webhook_id_hint": "ID del webhook creato in HA. Copia da: HA → Impostazioni → Automazioni → Crea → Trigger Webhook.",
"webhook_events_label": "Notifica per questi eventi",
"event_expiry": "Prodotti in scadenza (giornaliero)",
"event_shopping": "Aggiunta alla lista della spesa",
"event_stock": "Aggiornamento scorte",
"expiry_days_label": "Anticipo scadenze (giorni)",
"expiry_days_hint": "Invia la notifica di scadenza N giorni prima della data di scadenza.",
"webhook_help": "In HA: Impostazioni → Automazioni → Crea automazione → Trigger: Webhook → copia l'ID generato qui sopra.",
"notify_title": "Notifiche Push",
"notify_hint": "Invia notifiche push al tuo telefono tramite il servizio notify di Home Assistant.",
"notify_service_label": "Servizio notify",
"notify_service_placeholder": "notify.mobile_app_mio_telefono",
"notify_service_hint": "Nome del servizio notify HA (es. notify.mobile_app_phone). Lascia vuoto per disabilitare.",
"sensor_title": "Sensori REST",
"sensor_hint": "Aggiungi a configuration.yaml per creare sensori EverShelf in Home Assistant.",
"sensor_copy_btn": "Copia YAML",
"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",
@@ -876,6 +1072,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"
},
@@ -925,6 +1122,7 @@
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
"barcode_empty": "Inserisci un codice a barre",
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
"barcode_checksum": "Checksum EAN non valido — verifica le cifre del codice",
"min_chars": "Scrivi almeno 2 caratteri",
"not_in_inventory": "Prodotto non nell'inventario",
"appliance_exists": "Elettrodomestico già presente",
@@ -936,13 +1134,26 @@
"server_retry": "Riprova",
"unknown": "Errore sconosciuto",
"prefix": "Errore",
"no_inventory_entry": "Nessuna voce di inventario trovata"
"no_inventory_entry": "Nessuna voce di inventario trovata",
"offline_title": "Nessuna connessione",
"offline_subtitle": "L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.",
"offline_checking": "Verifica connessione…",
"offline_restored": "Connessione ripristinata!",
"offline_continue": "Continua in modalità offline",
"offline_reading_cache": "Lettura dalla cache locale",
"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",
"copy_failed": "Copia negli appunti non riuscita",
"invalid_quantity": "Quantità non valida"
},
"confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
"kiosk_exit": "Uscire dalla modalità kiosk?",
"cancel": "Annulla",
"proceed": "Conferma"
"proceed": "Conferma",
"discard_one": "Butta 1 pezzo"
},
"location": {
"dispensa": "Dispensa",
@@ -954,7 +1165,8 @@
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
"label_name": "🏷️ Nome prodotto",
"choose_location_title": "Quale modifica?",
"choose_location_hint": "Scegli la posizione da modificare:"
"choose_location_hint": "Scegli la posizione da modificare:",
"confirm_large_qty": "Stai impostando la quantità a {qty} {unit}. Questo sembra un valore insolitamente alto. Confermare?"
},
"screensaver": {
"recipe_btn": "Ricette",
@@ -1034,7 +1246,10 @@
"retake_btn": "🔄 Riscatta",
"camera_error_hint": "Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.<br>Puoi inserire il barcode manualmente o usare l'identificazione AI.",
"no_barcode": "Senza barcode",
"save_new_btn": "🆕 Non è nessuno di questi — salva come nuovo"
"save_new_btn": "🆕 Non è nessuno di questi — salva come nuovo",
"expiry_found": "Data trovata",
"expiry_read_fail": "Non riesco a leggere la data.",
"expiry_raw_label": "Letto"
},
"lowstock": {
"title": "⚠️ Sta per finire!",
@@ -1052,7 +1267,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",
@@ -1116,7 +1332,13 @@
"source": "Basato su {n} prodotti in dispensa · EverShelf",
"products_count": "prodotti",
"today_title": "🥗 La tua dispensa oggi",
"products_n": "{n} prodotti"
"products_n": "{n} prodotti",
"macros_title": "Macronutrienti stimati",
"macros_proteins": "Proteine",
"macros_carbs": "Carboidrati",
"macros_fat": "Grassi",
"macros_fiber": "Fibre",
"macros_source": "Stima basata su {n} prodotti in dispensa"
},
"facts": {
"greeting_morning": "Buongiorno",
@@ -1279,6 +1501,32 @@
"critical_error_intro": "L'app non può avviarsi a causa dei seguenti problemi:",
"error_network": "Impossibile contattare il server.",
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
"retry": "Riprova"
"retry": "Riprova",
"syncing_local": "Sincronizzazione dati locali...",
"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",
"consumed": "prodotti usati",
"trend_up": "+{pct}% rispetto a {prev}",
"trend_down": "-{pct}% rispetto a {prev}",
"trend_same": "stesso ritmo del mese scorso",
"added": "aggiunti",
"wasted": "sprecati",
"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"
}
}
}