Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f55071fa | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 | |||
| d80199e4f1 | |||
| 1637cc1020 | |||
| 904a398009 | |||
| bc39361246 | |||
| 7f173770fc | |||
| b83db76a8d | |||
| cfd089a0a3 | |||
| ade121f43f | |||
| 2f665f777b | |||
| f46b12e3ad | |||
| a932d3de11 | |||
| 6120fad40b | |||
| 8ac6fec5a2 | |||
| fe7587e9e4 | |||
| 4f68925a7c | |||
| f4ea9e74e6 | |||
| 8f217fd166 | |||
| b985247b95 | |||
| efbed479df | |||
| 695c23fc21 | |||
| 7a34406b07 | |||
| 50660f634f | |||
| fb06b42107 | |||
| c16067d9e5 | |||
| 605d8590f6 | |||
| 149cff3ca5 | |||
| ec7d172ed9 | |||
| 0479e34c7f | |||
| 730efe4d87 | |||
| be3dceeebb | |||
| 875250626d | |||
| 245d007e29 | |||
| 63a9f70f86 | |||
| 1a6e0c87ce | |||
| 73f43cb296 | |||
| baed815a48 | |||
| 8aa934f5ca | |||
| 83b5eb3063 | |||
| 59c6f9d76c | |||
| bac9485e4e | |||
| 11178af001 | |||
| 4e4a736dba | |||
| 52afdd6bfa | |||
| 3d27433eb3 | |||
| eddb622c85 | |||
| 95c20adbbd | |||
| 6fa2e4d830 | |||
| 6ff1dfe0cc | |||
| 43e0ac9da3 | |||
| 1ce32cb5f0 | |||
| d75cde7eb6 | |||
| 43fe1c7bb5 | |||
| b2c87ae343 | |||
| fbdae35516 | |||
| d9ebc51e71 | |||
| 56ca58bc18 | |||
| b2e0f6d683 | |||
| ddb9bd9f75 | |||
| 965a672abe | |||
| 7249daa8eb | |||
| ec53f7529c | |||
| 1074dff87d | |||
| 3989d11094 | |||
| b010ced1a6 | |||
| cc0fa09219 | |||
| c0a076749e | |||
| 6a41b53174 | |||
| 1d04236bc0 | |||
| 561c6e9809 | |||
| 6857c20893 | |||
| 964de98203 | |||
| e28a6e4e39 | |||
| fd9e2471e0 | |||
| 3c8a9693b2 | |||
| b38bdc45f5 | |||
| 83a0df272a | |||
| 6320b575e0 | |||
| 8ccd218c5a | |||
| 5c1afaaaf5 | |||
| 6245b15420 | |||
| 02f673a164 | |||
| 61bb1b5552 | |||
| cbf4bd54da | |||
| 1cdbdb3b25 | |||
| 837d62c335 | |||
| fa36ba83bf | |||
| 1efeaf9236 | |||
| 573bcd1102 | |||
| d3eb82eee2 | |||
| 264b1f648e | |||
| 5e34bc90b3 | |||
| 2ecb3cbac6 | |||
| fba0947945 | |||
| 37fb522e8b | |||
| 47197d0d66 | |||
| b5a6daa557 | |||
| 9e80915a61 | |||
| 7019160704 | |||
| 34df755ba3 | |||
| ef15f3536c | |||
| 5ad24ed73b | |||
| dd0625b253 | |||
| a85414d790 | |||
| 8f6934485a | |||
| d8aff8ac04 | |||
| ff25307662 |
@@ -129,6 +129,33 @@ GDRIVE_RETENTION_DAYS=30
|
||||
# Leave empty to allow anyone with access to the server to change settings.
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Build & Release Kiosk APK
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -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 ───────────────────────────────────
|
||||
@@ -172,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Security Scan (Trivy)
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
@@ -11,6 +11,99 @@ 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.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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](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 **General** 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:00–07: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.
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
|
||||
|
||||
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
|
||||
|
||||
**What you get:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
|
||||
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
|
||||
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
|
||||
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
|
||||
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
|
||||
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
|
||||
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
|
||||
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
|
||||
| **5 languages** | English, Italian, German, French, Spanish |
|
||||
|
||||
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
|
||||
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
|
||||
|
||||
---
|
||||
|
||||
### 📦 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
|
||||
@@ -111,7 +137,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
|
||||
|
||||
@@ -133,3 +133,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,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');
|
||||
@@ -267,6 +277,20 @@ function migrateDB(PDO $db): void {
|
||||
");
|
||||
$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; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,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;
|
||||
|
||||
+1776
-184
File diff suppressed because it is too large
Load Diff
+517
-5
@@ -596,13 +596,37 @@ body {
|
||||
}
|
||||
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
||||
|
||||
/* Pulsing dot shown in the banner while the offline cache is being read */
|
||||
.offline-banner-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #f87171;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
animation: offline-dot-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes offline-dot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* When server is offline, block interactions with the main content */
|
||||
body.server-offline .app-content {
|
||||
body.server-offline:not(.offline-mode) .app-content {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
/* In offline-mode the app is usable; just a subtle left-border indicator */
|
||||
body.offline-mode .app-content {
|
||||
border-left: 3px solid rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
/* Hide the "Retry" button in the banner when in offline mode — use the Continue button instead */
|
||||
body.offline-mode .offline-banner-retry {
|
||||
display: none;
|
||||
}
|
||||
body.server-offline .bottom-nav {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
@@ -1945,6 +1969,46 @@ body.server-offline .bottom-nav {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* — Scan status bar — */
|
||||
.scan-status-bar {
|
||||
position: absolute;
|
||||
bottom: 38px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
}
|
||||
.scan-status-method {
|
||||
font-size: 0.58rem;
|
||||
color: rgba(255,255,255,0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.scan-status-msg {
|
||||
font-size: 0.74rem;
|
||||
color: rgba(255,255,255,0.9);
|
||||
background: rgba(0,0,0,0.55);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
max-width: 92%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.scan-status-msg:empty { visibility: hidden; }
|
||||
.scan-status-msg.state-partial { color: #fbbf24; }
|
||||
.scan-status-msg.state-invalid { color: #f87171; background: rgba(239,68,68,0.28); }
|
||||
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
|
||||
.scan-status-msg.state-retry { color: #fb923c; }
|
||||
|
||||
/* — Viewport overlay controls (torch / zoom / flip) — */
|
||||
.scan-viewport-controls {
|
||||
position: absolute;
|
||||
@@ -2567,6 +2631,17 @@ body.server-offline .bottom-nav {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.shopping-pantry-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
[data-theme="dark"] .shopping-pantry-hint {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.shopping-item-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3027,10 +3102,82 @@ body.server-offline .bottom-nav {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-preview-small {
|
||||
padding: 12px;
|
||||
/* Action and Use page hero card */
|
||||
#page-action .product-preview-small,
|
||||
#page-use .product-preview-small {
|
||||
padding: 14px 16px;
|
||||
gap: 14px;
|
||||
border-left: 4px solid var(--primary);
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* action page: slightly larger name */
|
||||
#page-action .use-hero-name {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
/* barcode pill on action page */
|
||||
.action-pill-barcode { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
.use-hero-icon {
|
||||
font-size: 2.4rem;
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.use-hero-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.use-hero-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.use-hero-brand {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.use-hero-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.use-meta-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: 99px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Expiry pill colours */
|
||||
.use-pill-ok { background: #dcfce7; color: #166534; }
|
||||
.use-pill-warn { background: #fef9c3; color: #854d0e; }
|
||||
.use-pill-soon { background: #fed7aa; color: #7c2d12; }
|
||||
.use-pill-expired { background: #fee2e2; color: #991b1b; }
|
||||
/* Quantity pill */
|
||||
.use-pill-qty { background: #e0f2fe; color: #0c4a6e; }
|
||||
|
||||
.product-preview img, .product-preview-small img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
@@ -3040,8 +3187,11 @@ body.server-offline .bottom-nav {
|
||||
}
|
||||
|
||||
.product-preview-small img {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-preview-emoji {
|
||||
@@ -4130,6 +4280,7 @@ body.server-offline .bottom-nav {
|
||||
.recipe-result .recipe-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -4166,6 +4317,35 @@ body.server-offline .bottom-nav {
|
||||
color: #3730a3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Appliance/mode badge shown inline next to a step text */
|
||||
.recipe-step-appliance {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
color: #15803d;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Regen choice panel */
|
||||
.recipe-regen-choice {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.recipe-regen-choice-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 0 0 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Recipe ingredient use buttons */
|
||||
.recipe-ingredients {
|
||||
@@ -5583,6 +5763,26 @@ body.cooking-mode-active .app-header {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Related stock hint (same generic family, different brand/product) */
|
||||
.action-related-stock-card {
|
||||
background: #f0fdf4;
|
||||
border: 1.5px solid #86efac;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.82rem;
|
||||
color: #166534;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.action-related-stock-card strong { color: #15803d; }
|
||||
.related-stock-item { display: inline-block; margin-right: 8px; }
|
||||
[data-theme="dark"] .action-related-stock-card {
|
||||
background: rgba(21, 128, 61, 0.12);
|
||||
border-color: #166534;
|
||||
color: #86efac;
|
||||
}
|
||||
[data-theme="dark"] .action-related-stock-card strong { color: #4ade80; }
|
||||
|
||||
/* ===== ACTION BUTTONS GRID ===== */
|
||||
.action-buttons-3col {
|
||||
display: grid;
|
||||
@@ -6376,6 +6576,117 @@ body.cooking-mode-active .app-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===== RECIPE FAVORITES (#124) ===== */
|
||||
.recipe-fav-badge {
|
||||
margin-left: auto;
|
||||
font-size: 1.1rem;
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-archive-card-fav {
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.btn-recipe-fav {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
transition: color 0.2s, transform 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-recipe-fav:hover { color: #f59e0b; transform: scale(1.2); }
|
||||
.btn-recipe-fav.active { color: #f59e0b; }
|
||||
|
||||
/* ===== PORTION RESCALER (#123) ===== */
|
||||
.recipe-persons-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0 6px;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-persons-adj {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-persons-adj:hover { background: var(--accent, #6366f1); color: #fff; }
|
||||
|
||||
#recipe-persons-display {
|
||||
white-space: nowrap;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ===== MACRONUTRIENT PANEL (#118) ===== */
|
||||
.macro-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.macro-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.macro-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.macro-bar-wrap {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-secondary, #1e2a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.macro-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.macro-val {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.macro-val small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== SCREENSAVER ===== */
|
||||
.screensaver-overlay {
|
||||
position: fixed;
|
||||
@@ -6765,6 +7076,82 @@ body.cooking-mode-active .app-header {
|
||||
}
|
||||
.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; }
|
||||
|
||||
/* ===== MONTHLY STATS PANEL ===== */
|
||||
.ms-main-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
.ms-main-num {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.ms-main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ms-main-label {
|
||||
font-size: .85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ms-trend {
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ms-cats-section {
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.ms-cats-title {
|
||||
font-size: .68rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.ms-cat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.ms-cat-name {
|
||||
font-size: .74rem;
|
||||
color: #cbd5e1;
|
||||
min-width: 78px;
|
||||
max-width: 78px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ms-cat-bar-wrap {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #1e293b;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ms-cat-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
width: 0;
|
||||
}
|
||||
.ms-cat-cnt {
|
||||
font-size: .7rem;
|
||||
color: #64748b;
|
||||
min-width: 22px;
|
||||
text-align: right;
|
||||
}
|
||||
.ms-badges-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ===== SETUP WIZARD ===== */
|
||||
.setup-wizard-content {
|
||||
max-width: 480px;
|
||||
@@ -7510,11 +7897,22 @@ body.cooking-mode-active .app-header {
|
||||
/* ── Use inventory info ── */
|
||||
[data-theme="dark"] .use-inventory-info { background: #0c2a4e; color: #7dd3fc; }
|
||||
[data-theme="dark"] #use-expiry-hint { background: #2a1e00; border-color: #78350f; color: #fde68a; }
|
||||
[data-theme="dark"] #page-use .product-preview-small { border-left-color: var(--primary); }
|
||||
[data-theme="dark"] #page-action .product-preview-small { border-left-color: var(--primary); }
|
||||
[data-theme="dark"] .action-pill-barcode { background: #1e293b; color: #94a3b8; }
|
||||
[data-theme="dark"] .use-pill-ok { background: #14532d; color: #86efac; }
|
||||
[data-theme="dark"] .use-pill-warn { background: #422006; color: #fde68a; }
|
||||
[data-theme="dark"] .use-pill-soon { background: #431407; color: #fdba74; }
|
||||
[data-theme="dark"] .use-pill-expired { background: #450a0a; color: #fca5a5; }
|
||||
[data-theme="dark"] .use-pill-qty { background: #0c2a4e; color: #7dd3fc; }
|
||||
|
||||
/* ── Recipe components ── */
|
||||
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||
[data-theme="dark"] .recipe-regen-choice { background: #1e293b; border-color: #334155; }
|
||||
[data-theme="dark"] .recipe-regen-choice-title { color: #94a3b8; }
|
||||
[data-theme="dark"] .recipe-subtype-chip { background: #1c1300; border-color: #78350f; color: var(--text); }
|
||||
[data-theme="dark"] .recipe-subtype-chip:has(input:checked) { background: #2a1e00; border-color: #d97706; }
|
||||
|
||||
@@ -7541,3 +7939,117 @@ body.cooking-mode-active .app-header {
|
||||
|
||||
/* ── Appliance remove active ── */
|
||||
[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; }
|
||||
|
||||
/* ===== NETWORK ERROR OVERLAY ===== */
|
||||
#network-error-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 8, 20, 0.97);
|
||||
z-index: 300000; /* highest: above screensaver(10000), cooking(99999), preloader(200000) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
#network-error-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.net-error-body {
|
||||
text-align: center;
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.net-error-icon {
|
||||
font-size: 5.5rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 1.75rem;
|
||||
animation: net-pulse 2.2s ease-in-out infinite;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 32px rgba(248, 113, 113, 0.35));
|
||||
}
|
||||
#network-error-overlay.restored .net-error-icon {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 32px rgba(74, 222, 128, 0.45));
|
||||
}
|
||||
#network-error-overlay.checking .net-error-icon {
|
||||
animation: net-spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes net-pulse {
|
||||
0%, 100% { opacity: 0.45; transform: scale(0.92); }
|
||||
50% { opacity: 1; transform: scale(1.06); }
|
||||
}
|
||||
@keyframes net-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.net-error-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #f87171;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
#network-error-overlay.restored .net-error-title {
|
||||
color: #4ade80;
|
||||
}
|
||||
.net-error-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 420px;
|
||||
line-height: 1.6;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.net-error-status {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.88rem;
|
||||
color: #475569;
|
||||
min-height: 1.3em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
/* "Continue in offline mode" button — appears after 3 s */
|
||||
.net-error-continue-btn {
|
||||
margin-top: 2.2rem;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid rgba(255,255,255,0.22);
|
||||
color: #94a3b8;
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 1.6rem;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, transform 0.3s, opacity 0.3s;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.net-error-continue-btn.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.net-error-continue-btn:hover {
|
||||
background: rgba(255,255,255,0.16);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ─── Offline mode: hide AI and network-dependent UI ────────────────────────
|
||||
Sections that require a live server response or AI are hidden so the user
|
||||
isn't confronted with empty/broken widgets while offline. */
|
||||
body.offline-mode #waste-chart-section,
|
||||
body.offline-mode #nutrition-section,
|
||||
body.offline-mode #quick-recipe-bar,
|
||||
body.offline-mode .header-gemini-btn,
|
||||
body.offline-mode #btn-suggest,
|
||||
body.offline-mode #btn-fetch-prices,
|
||||
body.offline-mode .recipe-generate-btn {
|
||||
display: none !important;
|
||||
}
|
||||
/* Smart-shopping AI section: show as disabled rather than disappearing entirely */
|
||||
body.offline-mode #smart-shopping {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
+2150
-212
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -144,6 +146,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 {
|
||||
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
|
||||
}
|
||||
}
|
||||
@Deprecated("Deprecated in API 21")
|
||||
override fun onError(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
|
||||
}
|
||||
}
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
|
||||
}
|
||||
}
|
||||
})
|
||||
ttsReady = true
|
||||
}
|
||||
}
|
||||
@@ -466,7 +487,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() }
|
||||
|
||||
@@ -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"
|
||||
|
||||
+181
-17
@@ -64,7 +64,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.35</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,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.35</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 +169,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 +194,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 +225,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 +251,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 +282,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 +344,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 +367,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 +430,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 +486,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 +674,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 +686,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 +700,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 +808,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,7 +846,7 @@
|
||||
<!-- ===== SETTINGS PAGE ===== -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
@@ -840,6 +859,7 @@
|
||||
<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>
|
||||
@@ -1174,6 +1194,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 -->
|
||||
@@ -1312,10 +1342,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">
|
||||
@@ -1698,9 +1845,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>
|
||||
@@ -1757,6 +1910,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>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.24",
|
||||
"version": "1.7.35",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+146
-11
@@ -151,6 +151,12 @@
|
||||
"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...",
|
||||
@@ -213,7 +219,31 @@
|
||||
"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"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
@@ -227,7 +257,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",
|
||||
@@ -359,12 +390,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 +413,10 @@
|
||||
"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"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
@@ -473,7 +511,8 @@
|
||||
"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}"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 KI-Identifikation",
|
||||
@@ -648,7 +687,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 +727,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 +735,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",
|
||||
@@ -864,6 +911,60 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
@@ -987,6 +1088,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",
|
||||
@@ -998,13 +1100,25 @@
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
@@ -1016,7 +1130,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",
|
||||
@@ -1170,7 +1285,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",
|
||||
@@ -1344,6 +1465,20 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+146
-11
@@ -151,6 +151,12 @@
|
||||
"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...",
|
||||
@@ -213,7 +219,31 @@
|
||||
"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"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
@@ -227,7 +257,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",
|
||||
@@ -359,12 +390,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 +413,10 @@
|
||||
"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"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Shopping List",
|
||||
@@ -473,7 +511,8 @@
|
||||
"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}"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 AI Identification",
|
||||
@@ -648,7 +687,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 +727,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 +735,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",
|
||||
@@ -864,6 +911,60 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
@@ -987,6 +1088,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",
|
||||
@@ -998,13 +1100,25 @@
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
@@ -1016,7 +1130,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",
|
||||
@@ -1170,7 +1285,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",
|
||||
@@ -1344,6 +1465,20 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+139
-9
@@ -149,6 +149,12 @@
|
||||
"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...",
|
||||
@@ -211,7 +217,30 @@
|
||||
"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"
|
||||
},
|
||||
"action": {
|
||||
"title": "¿Qué quieres hacer?",
|
||||
@@ -357,6 +386,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 +408,10 @@
|
||||
"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"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista de la compra",
|
||||
@@ -646,7 +681,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 +728,12 @@
|
||||
"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."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Idioma",
|
||||
@@ -821,6 +863,60 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
@@ -943,6 +1039,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",
|
||||
@@ -954,13 +1051,25 @@
|
||||
"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é"
|
||||
},
|
||||
"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",
|
||||
@@ -972,7 +1081,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",
|
||||
@@ -1123,7 +1233,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",
|
||||
@@ -1292,6 +1408,20 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+139
-9
@@ -149,6 +149,12 @@
|
||||
"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...",
|
||||
@@ -211,7 +217,30 @@
|
||||
"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"
|
||||
},
|
||||
"action": {
|
||||
"title": "Que voulez-vous faire ?",
|
||||
@@ -357,6 +386,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 +408,10 @@
|
||||
"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"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Liste de courses",
|
||||
@@ -646,7 +681,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 +728,12 @@
|
||||
"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."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Langue",
|
||||
@@ -821,6 +863,60 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
@@ -943,6 +1039,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à",
|
||||
@@ -954,13 +1051,25 @@
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
@@ -972,7 +1081,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",
|
||||
@@ -1123,7 +1233,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",
|
||||
@@ -1292,6 +1408,20 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+145
-11
@@ -151,6 +151,12 @@
|
||||
"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...",
|
||||
@@ -213,7 +219,31 @@
|
||||
"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"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
@@ -227,7 +257,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",
|
||||
@@ -359,12 +390,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 +413,10 @@
|
||||
"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"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
@@ -473,7 +511,8 @@
|
||||
"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"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identificazione AI",
|
||||
@@ -648,7 +687,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 +727,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 +735,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",
|
||||
@@ -864,6 +911,60 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
@@ -987,6 +1088,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",
|
||||
@@ -998,13 +1100,24 @@
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
@@ -1016,7 +1129,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",
|
||||
@@ -1181,7 +1295,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",
|
||||
@@ -1344,6 +1464,20 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user