Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 217626ca2a | |||
| cf65e79010 | |||
| 46bbe0f8d3 | |||
| a0385cfb9b | |||
| 3a938dd7fb | |||
| 0d006625fd | |||
| d5b4a6c4da | |||
| d33b0ca2fe | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 |
+18
-4
@@ -125,10 +125,24 @@ GDRIVE_FOLDER_ID=
|
||||
GDRIVE_RETENTION_DAYS=30
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────────────
|
||||
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
||||
# Leave empty to allow anyone with access to the server to change settings.
|
||||
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
|
||||
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
|
||||
API_TOKEN=
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard)
|
||||
CORS_ORIGIN=
|
||||
|
||||
# GitHub automatic issue reporting (encrypted storage recommended)
|
||||
# Option A — plain ( .env is gitignored ):
|
||||
# GH_ISSUE_TOKEN=ghp_...
|
||||
# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'):
|
||||
GH_ISSUE_TOKEN=
|
||||
GH_ISSUE_TOKEN_ENC=
|
||||
GH_ISSUE_TOKEN_KEY=
|
||||
|
||||
# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens.
|
||||
|
||||
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||
# for Zeroconf discovery label and device name in Home Assistant).
|
||||
# Defaults to the server hostname if left empty.
|
||||
@@ -160,5 +174,5 @@ HA_EXPIRY_DAYS=3
|
||||
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
||||
DEMO_MODE=false
|
||||
|
||||
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
|
||||
# To rotate it, update the GH_ISSUE_TOKEN constant there.
|
||||
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
|
||||
CRON_LOG_MAX_BYTES=524288
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
RewriteEngine On
|
||||
|
||||
# Block sensitive files (Apache 2.4+)
|
||||
<Files ".env">
|
||||
Require all denied
|
||||
</Files>
|
||||
<Files ".env.example">
|
||||
Require all denied
|
||||
</Files>
|
||||
<Files "backup.sh">
|
||||
Require all denied
|
||||
</Files>
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Force HTTPS
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
@@ -11,6 +11,74 @@ 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.36] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
|
||||
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
|
||||
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
|
||||
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
|
||||
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
|
||||
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
|
||||
|
||||
### Fixed
|
||||
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
|
||||
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
|
||||
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
|
||||
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
|
||||
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
|
||||
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
|
||||
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
|
||||
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
|
||||
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
|
||||
|
||||
## [1.7.35] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||
|
||||
## [1.7.34] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
|
||||
|
||||
## [1.7.33] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||
|
||||
|
||||
## [1.7.32] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||
|
||||
|
||||
## [1.7.31] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||
|
||||
|
||||
## [1.7.30] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||
|
||||
|
||||
## [1.7.29] - 2026-05-29
|
||||
|
||||
### Added
|
||||
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||
|
||||
|
||||
## [1.7.28] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135–#183).
|
||||
|
||||
## [1.7.27] - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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)
|
||||
@@ -86,6 +86,7 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor
|
||||
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
|
||||
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **Recipe stock hints** — Each pantry ingredient shows how much you have and what remains after use; when the leftover would be less than 5% of the full sealed package (10% for an already-opened partial pack), the recipe automatically uses everything on hand to avoid waste
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
|
||||
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
@@ -236,7 +237,7 @@ TTS_ENABLED=true
|
||||
|
||||
# Optional: DB retention and cleanup (applied automatically each cron cycle)
|
||||
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
|
||||
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
|
||||
TRANSACTION_RETENTION_DAYS=90 # delete stock transactions older than N days (min 30 enforced)
|
||||
|
||||
# Optional: Vacuum-sealed expiry grace period
|
||||
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
|
||||
@@ -247,8 +248,11 @@ GEMINI_COST_25F_OUT=0.60
|
||||
GEMINI_COST_20F_IN=0.10
|
||||
GEMINI_COST_20F_OUT=0.40
|
||||
|
||||
# Optional: Security — protect the save_settings endpoint
|
||||
# Set a strong random string; the Settings UI will ask for it before saving
|
||||
# Optional: Security — protect all API endpoints
|
||||
# Set a strong random string; clients send it as X-API-Token header (or ?api_token= for HA)
|
||||
API_TOKEN=
|
||||
|
||||
# Optional: Legacy alias for API_TOKEN (settings save only)
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# Optional: Demo mode — block all write operations at the router level
|
||||
@@ -416,8 +420,11 @@ evershelf-kiosk/ # 📺 Android kiosk app (add-on)
|
||||
|
||||
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||
- **Database** stays local — never pushed to remote repositories
|
||||
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
|
||||
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
|
||||
- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access
|
||||
- **API token** — set `API_TOKEN` in `.env` to require `X-API-Token` on all API calls (Home Assistant: `?api_token=`)
|
||||
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `ha_token_set`, …)
|
||||
- **GitHub Issues token** — stored encrypted as `GH_ISSUE_TOKEN_ENC` + `GH_ISSUE_TOKEN_KEY` (see `scripts/encrypt-gh-token.php`)
|
||||
- **Settings write protection** — `save_settings` requires the same API token when configured; validated with `hash_equals`
|
||||
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
|
||||
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
||||
@@ -472,12 +479,6 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
|
||||
4. Push to the branch (`git push origin feature/my-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
EverShelf is a community project and contributions of any size are welcome!
|
||||
|
||||
### Easiest way to start — translate EverShelf into your language
|
||||
|
||||
Translations are just JSON files. No coding, no setup — fork → edit → PR.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf API bootstrap — shared by HTTP router and cron.
|
||||
*/
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/constants.php';
|
||||
require_once __DIR__ . '/lib/github.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
require_once __DIR__ . '/lib/cron_log.php';
|
||||
require_once __DIR__ . '/logger.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
// Define CRON_MODE before loading index.php so the router is skipped
|
||||
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
|
||||
define('CRON_MODE', true);
|
||||
|
||||
// Load all API functions without running the HTTP router
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/index.php';
|
||||
|
||||
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
|
||||
evershelfRotateCronLog();
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
|
||||
|
||||
+1069
-195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — shared path constants.
|
||||
*/
|
||||
|
||||
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
|
||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
|
||||
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
|
||||
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
|
||||
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
|
||||
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
|
||||
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
|
||||
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
|
||||
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
|
||||
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
|
||||
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
|
||||
|
||||
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
|
||||
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
|
||||
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
|
||||
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Rotate data/cron.log — keep last N MB / lines.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/constants.php';
|
||||
|
||||
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
|
||||
$path = CRON_LOG_PATH;
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
|
||||
$size = filesize($path);
|
||||
if ($size === false || $size <= $maxBytes) {
|
||||
return;
|
||||
}
|
||||
for ($i = $keepRotated; $i >= 1; $i--) {
|
||||
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
|
||||
$to = $path . '.' . $i;
|
||||
if ($i === $keepRotated && file_exists($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
if (file_exists($from)) {
|
||||
@rename($from, $to);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — environment variable loader (.env).
|
||||
*/
|
||||
|
||||
function loadEnv(): array {
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
$envFile = dirname(__DIR__, 2) . '/.env';
|
||||
$cache = [];
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
[$key, $val] = explode('=', $line, 2);
|
||||
$cache[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
return $cache;
|
||||
}
|
||||
|
||||
function env(string $key, string $default = ''): string {
|
||||
$vars = loadEnv();
|
||||
return $vars[$key] ?? $default;
|
||||
}
|
||||
|
||||
/** Push a single key into the in-memory env cache (after .env write). */
|
||||
function envCacheSet(string $key, string $value): void {
|
||||
loadEnv();
|
||||
// Force reload on next call — callers should use loadEnv() return for batch updates
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
|
||||
*
|
||||
* Configure ONE of:
|
||||
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
|
||||
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
|
||||
*
|
||||
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
function evershelfDecryptGhToken(string $encB64, string $key): string {
|
||||
$raw = base64_decode($encB64, true);
|
||||
if ($raw === false || strlen($raw) < 28) {
|
||||
return '';
|
||||
}
|
||||
$iv = substr($raw, 0, 12);
|
||||
$tag = substr($raw, 12, 16);
|
||||
$cipher = substr($raw, 28);
|
||||
$plain = openssl_decrypt(
|
||||
$cipher,
|
||||
'aes-256-gcm',
|
||||
hash('sha256', $key, true),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return ($plain !== false) ? $plain : '';
|
||||
}
|
||||
|
||||
function evershelfEncryptGhToken(string $plain, string $key): string {
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt(
|
||||
$plain,
|
||||
'aes-256-gcm',
|
||||
hash('sha256', $key, true),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return base64_encode($iv . $tag . $cipher);
|
||||
}
|
||||
|
||||
/** Decode GitHub Issues token at runtime — never stored in source code. */
|
||||
function _ghToken(): string {
|
||||
static $token = null;
|
||||
if ($token !== null) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$plain = env('GH_ISSUE_TOKEN');
|
||||
if ($plain !== '') {
|
||||
$token = $plain;
|
||||
return $token;
|
||||
}
|
||||
|
||||
$enc = env('GH_ISSUE_TOKEN_ENC');
|
||||
$key = env('GH_ISSUE_TOKEN_KEY');
|
||||
if ($enc !== '' && $key !== '') {
|
||||
$token = evershelfDecryptGhToken($enc, $key);
|
||||
return $token;
|
||||
}
|
||||
|
||||
$token = '';
|
||||
return $token;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
|
||||
function evershelfEffectiveApiToken(): string {
|
||||
$api = env('API_TOKEN');
|
||||
if ($api !== '') {
|
||||
return $api;
|
||||
}
|
||||
return env('SETTINGS_TOKEN', '');
|
||||
}
|
||||
|
||||
function evershelfApiTokenRequired(): bool {
|
||||
return evershelfEffectiveApiToken() !== '';
|
||||
}
|
||||
|
||||
function evershelfGetProvidedApiToken(): string {
|
||||
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
|
||||
return (string)$_SERVER['HTTP_X_API_TOKEN'];
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
|
||||
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
|
||||
}
|
||||
if (isset($_GET['api_token'])) {
|
||||
return (string)$_GET['api_token'];
|
||||
}
|
||||
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return evershelfGetProvidedApiTokenFromHeaders();
|
||||
}
|
||||
|
||||
function evershelfApiTokenValid(): bool {
|
||||
$required = evershelfEffectiveApiToken();
|
||||
if ($required === '') {
|
||||
return true;
|
||||
}
|
||||
$provided = evershelfGetProvidedApiToken();
|
||||
return $provided !== '' && hash_equals($required, $provided);
|
||||
}
|
||||
|
||||
function evershelfGetProvidedApiTokenFromHeaders(): string {
|
||||
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
|
||||
}
|
||||
|
||||
/** Actions reachable without API token (telemetry + public probes). */
|
||||
function evershelfPublicActions(): array {
|
||||
return [
|
||||
'ping',
|
||||
'app_bootstrap',
|
||||
'check_update',
|
||||
'report_error',
|
||||
'report_bug',
|
||||
'client_log',
|
||||
'gdrive_oauth_callback',
|
||||
];
|
||||
}
|
||||
|
||||
/** GET actions that mutate state — require auth when token is configured. */
|
||||
function evershelfMutatingGetActions(): array {
|
||||
return ['db_cleanup', 'export_inventory'];
|
||||
}
|
||||
|
||||
function evershelfDestructiveActions(): array {
|
||||
return [
|
||||
'save_settings', 'db_cleanup',
|
||||
'backup_now', 'backup_delete', 'backup_restore',
|
||||
'gdrive_push', 'gdrive_oauth_exchange',
|
||||
'migrate_units',
|
||||
];
|
||||
}
|
||||
|
||||
function evershelfActionNeedsAuth(string $action, string $method): bool {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($action, evershelfPublicActions(), true)) {
|
||||
return false;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
return true;
|
||||
}
|
||||
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, evershelfDestructiveActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
// Protect all data reads when API token is set
|
||||
return true;
|
||||
}
|
||||
|
||||
function evershelfRequireApiAuth(string $action, string $method): void {
|
||||
if (!evershelfActionNeedsAuth($action, $method)) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'unauthorized',
|
||||
'api_token_required' => true,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function evershelfRequireAuthForSensitive(string $action): void {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function evershelfSendCorsHeaders(): void {
|
||||
$configured = env('CORS_ORIGIN', '');
|
||||
if ($configured === '') {
|
||||
// Same-origin SPA — do not emit wildcard CORS
|
||||
return;
|
||||
}
|
||||
if ($configured === '*') {
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
} else {
|
||||
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = array_filter(array_map('trim', explode(',', $configured)));
|
||||
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
|
||||
header('Access-Control-Allow-Origin: ' . $reqOrigin);
|
||||
header('Vary: Origin');
|
||||
}
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
|
||||
}
|
||||
|
||||
/** Read-only actions allowed in DEMO_MODE. */
|
||||
function evershelfDemoReadOnlyActions(): array {
|
||||
return [
|
||||
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
|
||||
'search_barcode', 'lookup_barcode', 'stock_for_name',
|
||||
'product_get', 'products_list', 'products_search', 'inventory_search',
|
||||
'inventory_list', 'inventory_summary', 'inventory_finished_items',
|
||||
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
|
||||
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
|
||||
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
|
||||
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
|
||||
'recipes_list', 'chat_list', 'app_settings_get',
|
||||
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
|
||||
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
|
||||
'backup_list', 'export_inventory',
|
||||
];
|
||||
}
|
||||
|
||||
function evershelfDemoBlocksAction(string $action, string $method): bool {
|
||||
if (env('DEMO_MODE') !== 'true') {
|
||||
return false;
|
||||
}
|
||||
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
|
||||
return false;
|
||||
}
|
||||
// Block all AI generation in demo (cost + writes)
|
||||
if (str_starts_with($action, 'gemini_') || in_array($action, [
|
||||
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, evershelfMutatingGetActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
return !in_array($action, evershelfDemoReadOnlyActions(), true);
|
||||
}
|
||||
|
||||
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
|
||||
function evershelfAllowedScaleHosts(): array {
|
||||
$hosts = ['127.0.0.1', 'localhost', '::1'];
|
||||
$gw = env('SCALE_GATEWAY_URL', '');
|
||||
if ($gw !== '') {
|
||||
$p = parse_url($gw);
|
||||
if (!empty($p['host'])) {
|
||||
$hosts[] = strtolower($p['host']);
|
||||
}
|
||||
}
|
||||
// Server's own LAN IP — gateway may bind here on kiosk LAN
|
||||
if (function_exists('gethostname')) {
|
||||
$lan = gethostbyname(gethostname());
|
||||
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
|
||||
$hosts[] = $lan;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($hosts));
|
||||
}
|
||||
|
||||
function evershelfScaleHostAllowed(string $host): bool {
|
||||
$host = strtolower(trim($host));
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
foreach (evershelfAllowedScaleHosts() as $allowed) {
|
||||
if ($host === strtolower($allowed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
|
||||
$serverIp = evershelfLocalLanIp();
|
||||
if ($serverIp !== '') {
|
||||
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
|
||||
if (str_starts_with($host, $subnet . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function evershelfLocalLanIp(): string {
|
||||
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($sock) {
|
||||
@socket_connect($sock, '8.8.8.8', 53);
|
||||
@socket_getsockname($sock, $ip);
|
||||
socket_close($sock);
|
||||
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request comes from the EverShelf web UI on the same host.
|
||||
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
|
||||
*/
|
||||
function evershelfIsSameOriginBrowser(): bool {
|
||||
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin !== '') {
|
||||
$oh = parse_url($origin, PHP_URL_HOST);
|
||||
return $oh && strtolower($oh) === $host;
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$rh = parse_url($referer, PHP_URL_HOST);
|
||||
return $rh && strtolower($rh) === $host;
|
||||
}
|
||||
|
||||
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
|
||||
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
|
||||
function evershelfRequireScaleAccess(): void {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfIsSameOriginBrowser()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
+56
-51
@@ -1,57 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Auto-discovery
|
||||
*
|
||||
* Scans the server's local /24 subnet for any host responding on the gateway
|
||||
* port (default 8765) and confirms it with a WebSocket handshake.
|
||||
*
|
||||
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
|
||||
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
evershelfSendCorsHeaders();
|
||||
|
||||
$port = (int)($_GET['port'] ?? 8765);
|
||||
if ($port < 1 || $port > 65535) $port = 8765;
|
||||
|
||||
// ── Determine server LAN IP ────────────────────────────────────────────────
|
||||
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
|
||||
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
|
||||
function localLanIp(): string {
|
||||
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($sock) {
|
||||
@socket_connect($sock, '8.8.8.8', 53);
|
||||
@socket_getsockname($sock, $ip);
|
||||
socket_close($sock);
|
||||
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
}
|
||||
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
|
||||
$ifaces = @net_get_interfaces();
|
||||
if ($ifaces) {
|
||||
foreach ($ifaces as $name => $info) {
|
||||
if ($name === 'lo') continue;
|
||||
foreach ($info['unicast'] ?? [] as $u) {
|
||||
$ip = $u['address'] ?? '';
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$serverIp = localLanIp();
|
||||
// Simple rate limit: max 6 scans per minute per IP
|
||||
$rlDir = dirname(__DIR__) . '/data/rate_limits';
|
||||
if (!is_dir($rlDir)) {
|
||||
@mkdir($rlDir, 0755, true);
|
||||
}
|
||||
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
|
||||
$now = time();
|
||||
$hits = [];
|
||||
if (file_exists($rlFile)) {
|
||||
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
|
||||
}
|
||||
if (count($hits) >= 6) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Too many discovery scans']);
|
||||
exit;
|
||||
}
|
||||
$hits[] = $now;
|
||||
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
|
||||
|
||||
$port = (int)($_GET['port'] ?? 8765);
|
||||
if ($port < 1 || $port > 65535) {
|
||||
$port = 8765;
|
||||
}
|
||||
|
||||
$serverIp = evershelfLocalLanIp();
|
||||
$parts = explode('.', $serverIp);
|
||||
if (count($parts) !== 4) {
|
||||
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
|
||||
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
|
||||
exit;
|
||||
}
|
||||
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
|
||||
|
||||
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
|
||||
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
|
||||
// Total scan budget: 1.5 seconds.
|
||||
|
||||
$candidates = [];
|
||||
for ($i = 1; $i <= 254; $i++) {
|
||||
$ip = $subnet . $i;
|
||||
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
$read = null;
|
||||
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
||||
$n = @stream_select($read, $write, $except, 0, $usec);
|
||||
if ($n === false || $n === 0) break;
|
||||
if ($n === false || $n === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Sockets in $except = connection refused/error
|
||||
$failed = [];
|
||||
foreach ($except as $s) {
|
||||
$ip = array_search($s, $candidates, true);
|
||||
if ($ip !== false) $failed[$ip] = true;
|
||||
if ($ip !== false) {
|
||||
$failed[$ip] = true;
|
||||
}
|
||||
}
|
||||
// Sockets in $write = connection complete (may overlap with $except on error)
|
||||
foreach ($write as $s) {
|
||||
$ip = array_search($s, $candidates, true);
|
||||
if ($ip === false) continue;
|
||||
if ($ip === false) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($failed[$ip])) {
|
||||
$found_tcp[] = $ip;
|
||||
}
|
||||
@fclose($s);
|
||||
unset($candidates[$ip]);
|
||||
}
|
||||
// Close failed sockets too
|
||||
foreach ($failed as $ip => $_) {
|
||||
if (isset($candidates[$ip])) {
|
||||
@fclose($candidates[$ip]);
|
||||
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
|
||||
foreach ($candidates as $s) {
|
||||
@fclose($s);
|
||||
}
|
||||
|
||||
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
|
||||
$gateways = [];
|
||||
foreach ($found_tcp as $ip) {
|
||||
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
||||
if (!$sock) continue;
|
||||
if (!$sock) {
|
||||
continue;
|
||||
}
|
||||
stream_set_timeout($sock, 2);
|
||||
|
||||
$key = base64_encode(random_bytes(16));
|
||||
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
|
||||
$dl = microtime(true) + 2;
|
||||
while (microtime(true) < $dl && !feof($sock)) {
|
||||
$line = fgets($sock, 256);
|
||||
if ($line === false) break;
|
||||
if ($line === false) {
|
||||
break;
|
||||
}
|
||||
$resp .= $line;
|
||||
if ($line === "\r\n") break;
|
||||
if ($line === "\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose($sock);
|
||||
|
||||
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
|
||||
echo json_encode([
|
||||
'found' => $gateways,
|
||||
'subnet' => rtrim($subnet, '.') . '.0/24',
|
||||
'server_ip' => $serverIp,
|
||||
]);
|
||||
|
||||
+16
-7
@@ -1,16 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Connection ping / test
|
||||
*
|
||||
* Performs a WebSocket handshake with the gateway and returns
|
||||
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
|
||||
*
|
||||
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
$host = strtolower($parsed['host'] ?? '');
|
||||
$port = (int)($parsed['port'] ?? 8765);
|
||||
$path = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!evershelfScaleHostAllowed($host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Try to open a TCP connection with a 5-second timeout
|
||||
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
||||
if (!$sock) {
|
||||
|
||||
+17
-1
@@ -8,6 +8,16 @@
|
||||
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────────────
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$wsHost = $parsed['host'] ?? '';
|
||||
$wsHost = strtolower($parsed['host'] ?? '');
|
||||
$wsPort = (int)($parsed['port'] ?? 8765);
|
||||
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!evershelfScaleHostAllowed($wsHost)) {
|
||||
header('Content-Type: text/event-stream');
|
||||
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── SSE headers ───────────────────────────────────────────────────────────────
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
@@ -2009,6 +2009,59 @@ body.server-offline .bottom-nav {
|
||||
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
|
||||
.scan-status-msg.state-retry { color: #fb923c; }
|
||||
|
||||
/* — AI processing overlay (full-viewport, shown during Gemini Vision call) — */
|
||||
.scan-ai-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.scan-ai-overlay-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px 28px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1.5px solid rgba(255,255,255,0.18);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.scan-ai-overlay-label {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.scan-ai-overlay-msg {
|
||||
font-size: 0.88rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
/* — AI retry button (shown below scanner after visual ID fails) — */
|
||||
.scan-ai-retry-btn {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 0.95rem;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--accent);
|
||||
background: rgba(124,58,237,0.1);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.scan-ai-retry-btn:active { background: rgba(124,58,237,0.22); }
|
||||
|
||||
/* — Viewport overlay controls (torch / zoom / flip) — */
|
||||
.scan-viewport-controls {
|
||||
position: absolute;
|
||||
@@ -2059,6 +2112,118 @@ body.server-offline .bottom-nav {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.scan-ai-match-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.scan-ai-match-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.scan-ai-match-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.scan-ai-match-subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.scan-ai-match-list-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.scan-ai-match-list-title {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.scan-ai-match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.scan-ai-candidate-item {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-main);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.scan-ai-candidate-item:active { transform: scale(0.99); }
|
||||
.scan-ai-candidate-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scan-ai-candidate-info {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
.scan-ai-candidate-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scan-ai-candidate-meta {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scan-ai-candidate-cta {
|
||||
font-size: 0.74rem;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scan-ai-match-empty {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-main);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.scan-ai-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.scan-ai-detected-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.scan-ai-detected-pill {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-main);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* — Recent scans — */
|
||||
.scan-recents {
|
||||
display: flex;
|
||||
@@ -4295,6 +4460,93 @@ body.server-offline .bottom-nav {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== RECIPE NUTRITION BLOCK ===== */
|
||||
.recipe-nutrition-block {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.recipe-section-heading {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #15803d;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.recipe-nutrition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.recipe-nutrition-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.recipe-nutrition-icon { font-size: 1.2rem; }
|
||||
.recipe-nutrition-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #15803d;
|
||||
}
|
||||
.recipe-nutrition-label {
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.recipe-nutrition-note {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.recipe-nutrition-footnote {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ===== RECIPE STORAGE CARD ===== */
|
||||
.recipe-storage-card {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.recipe-storage-card .recipe-section-heading { color: #b45309; }
|
||||
.recipe-storage-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.recipe-storage-badge {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 20px;
|
||||
padding: 2px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.recipe-storage-days { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
|
||||
.recipe-storage-now { background: #fee2e2; border-color: #fca5a5; color: #b91c1c; }
|
||||
.recipe-storage-tips {
|
||||
font-size: 0.82rem;
|
||||
color: #78350f;
|
||||
margin: 2px 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recipe-tools-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -4419,6 +4671,13 @@ body.server-offline .bottom-nav {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.recipe-ing-stock {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== SHOPPING SECTION (REPARTO) HEADERS ===== */
|
||||
.shopping-section-divider {
|
||||
display: flex;
|
||||
@@ -5939,6 +6198,12 @@ body.cooking-mode-active .app-header {
|
||||
}
|
||||
.banner-anomaly .alert-banner-title { color: #9a3412; }
|
||||
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
|
||||
.alert-banner.banner-dup-loss {
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.banner-dup-loss .alert-banner-title { color: #991b1b; }
|
||||
.banner-dup-loss .alert-banner-counter .banner-dot.active { background: #dc2626; }
|
||||
.alert-banner.banner-no-expiry {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
|
||||
border-color: #16a34a;
|
||||
@@ -7838,6 +8103,8 @@ body.cooking-mode-active .app-header {
|
||||
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
|
||||
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
|
||||
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
|
||||
[data-theme="dark"] .alert-banner.banner-dup-loss { background: #2a0808; border-color: #dc2626; }
|
||||
[data-theme="dark"] .banner-dup-loss .alert-banner-title { color: #fca5a5; }
|
||||
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
|
||||
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
|
||||
|
||||
@@ -7908,6 +8175,18 @@ body.cooking-mode-active .app-header {
|
||||
|
||||
/* ── Recipe components ── */
|
||||
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-nutrition-block { background: #052e16; border-color: #166534; }
|
||||
[data-theme="dark"] .recipe-section-heading { color: #4ade80; }
|
||||
[data-theme="dark"] .recipe-storage-card .recipe-section-heading { color: #fbbf24; }
|
||||
[data-theme="dark"] .recipe-nutrition-value { color: #4ade80; }
|
||||
[data-theme="dark"] .recipe-nutrition-label { color: #94a3b8; }
|
||||
[data-theme="dark"] .recipe-nutrition-note { color: #64748b; }
|
||||
[data-theme="dark"] .recipe-nutrition-footnote { color: var(--text-muted); }
|
||||
[data-theme="dark"] .recipe-storage-card { background: #1c1400; border-color: #78350f; }
|
||||
[data-theme="dark"] .recipe-storage-badge { background: #2a1e00; border-color: #92400e; color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-storage-days { background: #0c1a2e; border-color: #1d4ed8; color: #93c5fd; }
|
||||
[data-theme="dark"] .recipe-storage-now { background: #2a0a0a; border-color: #b91c1c; color: #fca5a5; }
|
||||
[data-theme="dark"] .recipe-storage-tips { color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||
|
||||
+1045
-208
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* EverShelf core — API token storage and auth headers.
|
||||
*/
|
||||
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
|
||||
|
||||
function getApiToken() {
|
||||
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setApiToken(token) {
|
||||
const t = (token || '').trim();
|
||||
if (t) {
|
||||
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
|
||||
} else {
|
||||
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function apiAuthHeaders() {
|
||||
const fromStorage = getApiToken();
|
||||
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
|
||||
const token = fromSettingsField || fromStorage;
|
||||
if (!token) return {};
|
||||
return { 'X-API-Token': token };
|
||||
}
|
||||
|
||||
/** Fetch API token from server when loading the UI from the same origin. */
|
||||
async function ensureApiToken() {
|
||||
if (getApiToken()) return true;
|
||||
try {
|
||||
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
window._apiTokenRequired = !!data.api_token_required;
|
||||
if (data.api_token) {
|
||||
setApiToken(data.api_token);
|
||||
return true;
|
||||
}
|
||||
} catch (_) { /* offline / network */ }
|
||||
return !!getApiToken();
|
||||
}
|
||||
|
||||
function _promptApiTokenIfNeeded() {
|
||||
if (!window._apiTokenRequired) return;
|
||||
if (getApiToken()) return;
|
||||
const existing = document.getElementById('api-token-overlay');
|
||||
if (existing) return;
|
||||
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
|
||||
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
|
||||
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'api-token-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px;padding:20px">
|
||||
<h3>${title}</h3>
|
||||
<p class="settings-hint">${hint}</p>
|
||||
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
|
||||
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('api-token-save').onclick = () => {
|
||||
const v = document.getElementById('api-token-input').value.trim();
|
||||
if (v) {
|
||||
setApiToken(v);
|
||||
overlay.remove();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.getApiToken = getApiToken;
|
||||
window.setApiToken = setApiToken;
|
||||
window.apiAuthHeaders = apiAuthHeaders;
|
||||
window.ensureApiToken = ensureApiToken;
|
||||
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* EverShelf core — safe HTML escaping (loaded before app.js).
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
window.escapeHtml = escapeHtml;
|
||||
Vendored
+4
File diff suppressed because one or more lines are too long
@@ -1,13 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Daily backup of EverShelf database (local only)
|
||||
# The database is NOT pushed to remote repositories.
|
||||
# Runs via cron: creates a local timestamped backup copy
|
||||
#
|
||||
# Example crontab entry:
|
||||
# 0 3 * * * /var/www/html/evershelf/backup.sh
|
||||
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
|
||||
|
||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
||||
ENV_FILE="${INSTALL_DIR}/.env"
|
||||
|
||||
RETENTION=3
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2)
|
||||
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
|
||||
RETENTION="$val"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
@@ -19,5 +25,5 @@ fi
|
||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
||||
|
||||
# Keep only the last 7 backups
|
||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
||||
# Keep only the newest N backups
|
||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
|
||||
Require all denied
|
||||
@@ -0,0 +1,38 @@
|
||||
# EverShelf — Architecture (modular layout)
|
||||
|
||||
```
|
||||
dispensa/
|
||||
├── api/
|
||||
│ ├── bootstrap.php # Shared init: env, security, DB, logger
|
||||
│ ├── index.php # HTTP handlers + router (split planned per domain)
|
||||
│ ├── database.php # SQLite schema & migrations
|
||||
│ ├── logger.php # Rotating file logger (logs/)
|
||||
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
|
||||
│ ├── lib/
|
||||
│ │ ├── env.php # .env loader
|
||||
│ │ ├── constants.php # Paths & pricing constants
|
||||
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
|
||||
│ │ ├── github.php # Encrypted GitHub Issues token
|
||||
│ │ └── cron_log.php # data/cron.log rotation
|
||||
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
|
||||
├── assets/
|
||||
│ ├── js/
|
||||
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
|
||||
│ │ └── app.js # SPA logic (domain modules: future split)
|
||||
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
|
||||
├── data/ # Runtime data (.htaccess: deny all)
|
||||
├── logs/ # Application logs (.htaccess: deny all)
|
||||
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
|
||||
```
|
||||
|
||||
## Security model
|
||||
|
||||
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
|
||||
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
|
||||
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
|
||||
|
||||
## Planned refactors
|
||||
|
||||
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
|
||||
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
|
||||
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
|
||||
@@ -101,6 +101,20 @@ class KioskActivity : AppCompatActivity() {
|
||||
// Pending WebView permission request
|
||||
private var pendingWebPermission: PermissionRequest? = null
|
||||
|
||||
private fun safeEvalJs(script: String) {
|
||||
if (!::webView.isInitialized) return
|
||||
if (isFinishing || isDestroyed) return
|
||||
if (webView.visibility != View.VISIBLE) return
|
||||
runCatching { webView.evaluateJavascript(script, null) }
|
||||
.onFailure {
|
||||
ErrorReporter.reportMessage(
|
||||
type = "webview-js-bridge-error",
|
||||
message = "Failed to deliver JS callback to WebView",
|
||||
extra = mapOf("error" to (it.message ?: "unknown"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_CHOOSER_REQUEST = 1002
|
||||
private const val PERMISSION_REQUEST_CODE = 1003
|
||||
@@ -150,18 +164,18 @@ class KioskActivity : AppCompatActivity() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
|
||||
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
|
||||
}
|
||||
}
|
||||
@Deprecated("Deprecated in API 21")
|
||||
override fun onError(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
|
||||
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
|
||||
}
|
||||
}
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
|
||||
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+53
-24
@@ -11,9 +11,13 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260603a">
|
||||
<!-- Core modules (auth, DOM helpers) -->
|
||||
<script src="assets/js/core/dom.js?v=20260603a"></script>
|
||||
<script src="assets/js/core/auth.js?v=20260603b"></script>
|
||||
<!-- QuaggaJS — local vendor with CDN fallback -->
|
||||
<script src="assets/vendor/quagga/quagga.min.js?v=20260603a"></script>
|
||||
<script>if(typeof Quagga==='undefined'){document.write('<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"><\\/script>');}</script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
<script type="module">
|
||||
// Lazy-load the embedding pipeline only when first needed.
|
||||
@@ -25,11 +29,15 @@
|
||||
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
|
||||
window._categoryPipelinePromise = (async () => {
|
||||
try {
|
||||
const { pipeline, env } = await import(
|
||||
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
|
||||
);
|
||||
// Keep WASM/model files in the browser cache; disable remote model check
|
||||
// to avoid CORS issues with the self-hosted instance.
|
||||
const localBase = 'assets/vendor/transformers/';
|
||||
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/';
|
||||
let pipeline, env;
|
||||
try {
|
||||
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
|
||||
} catch (_) {
|
||||
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
|
||||
}
|
||||
env.localModelPath = localBase;
|
||||
env.allowRemoteModels = true;
|
||||
env.useBrowserCache = true;
|
||||
const pipe = await pipeline(
|
||||
@@ -64,7 +72,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.36</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +85,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.36</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -194,7 +202,7 @@
|
||||
<!-- ===== INVENTORY LIST ===== -->
|
||||
<section class="page" id="page-inventory">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||
</div>
|
||||
@@ -225,7 +233,7 @@
|
||||
<!-- ===== SCAN PAGE ===== -->
|
||||
<section class="page" id="page-scan">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="scan.title">Scansiona</h2>
|
||||
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||
</div>
|
||||
@@ -256,6 +264,14 @@
|
||||
<span id="scan-status-method" class="scan-status-method"></span>
|
||||
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||
</div>
|
||||
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
|
||||
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||
<div class="scan-ai-overlay-inner">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="scan-ai-overlay-label">Gemini Vision</span>
|
||||
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Success flash overlay -->
|
||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||
<div class="scan-confirm-check">✓</div>
|
||||
@@ -274,6 +290,9 @@
|
||||
<!-- Scan errors -->
|
||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||
|
||||
<!-- AI retry button (shown after visual identification fails) -->
|
||||
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
|
||||
|
||||
<!-- Recent scans -->
|
||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||
@@ -333,7 +352,7 @@
|
||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||
<section class="page" id="page-action">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||
</div>
|
||||
<!-- Banner: shopping list scan context -->
|
||||
@@ -356,7 +375,7 @@
|
||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-add">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="add-product-preview"></div>
|
||||
@@ -419,7 +438,7 @@
|
||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-use">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="use-product-preview"></div>
|
||||
@@ -475,7 +494,7 @@
|
||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||
<section class="page" id="page-product-form">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitProduct(event)">
|
||||
@@ -663,7 +682,7 @@
|
||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||
<section class="page" id="page-products">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
@@ -675,7 +694,7 @@
|
||||
<!-- ===== RECIPE PAGE ===== -->
|
||||
<section class="page" id="page-recipe">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||
</div>
|
||||
<div class="recipe-page-container">
|
||||
@@ -689,7 +708,7 @@
|
||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||
<section class="page" id="page-shopping">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||
</div>
|
||||
<div class="shopping-container">
|
||||
@@ -797,7 +816,7 @@
|
||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||
<section class="page" id="page-ai">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||
</div>
|
||||
<div class="ai-container">
|
||||
@@ -835,7 +854,7 @@
|
||||
<!-- ===== SETTINGS PAGE ===== -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
@@ -1183,6 +1202,16 @@
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:14px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-barcode-ai-fallback">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Security Tab -->
|
||||
@@ -1192,10 +1221,10 @@
|
||||
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.security.token_label">Token di accesso</label>
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
|
||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token API (API_TOKEN nel file .env). Il token viene salvato nel browser.</p>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
||||
@@ -1941,6 +1970,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260518c"></script>
|
||||
<script src="assets/js/app.js?v=20260604c"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.25",
|
||||
"version": "1.7.36",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "evershelf",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
|
||||
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
|
||||
"build": "npm run build:js && npm run build:css"
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
|
||||
*/
|
||||
if ($argc < 3) {
|
||||
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
|
||||
exit(1);
|
||||
}
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Fix ownership and permissions for EverShelf runtime directories.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WEB_USER="${WEB_USER:-www-data}"
|
||||
|
||||
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
|
||||
chmod 750 "${ROOT}/data" "${ROOT}/logs"
|
||||
chmod 640 "${ROOT}/.env" 2>/dev/null || true
|
||||
find "${ROOT}/data" -type f -exec chmod 660 {} \;
|
||||
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
|
||||
echo "Permissions updated for ${WEB_USER}"
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* One-time merge of duplicate product records (same normalized name + compatible brand).
|
||||
* Opened-package splits remain as separate inventory rows on the canonical product.
|
||||
*
|
||||
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
$dbPath = __DIR__ . '/../data/evershelf.db';
|
||||
if (!file_exists($dbPath)) {
|
||||
fwrite(STDERR, "Database not found: $dbPath\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . $dbPath);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
function normName(string $name): string {
|
||||
return mb_strtolower(trim($name));
|
||||
}
|
||||
|
||||
function normBrand(string $brand): string {
|
||||
return mb_strtolower(trim($brand));
|
||||
}
|
||||
|
||||
function brandsCompatible(string $a, string $b): bool {
|
||||
$na = normBrand($a);
|
||||
$nb = normBrand($b);
|
||||
return $na === $nb || $na === '' || $nb === '';
|
||||
}
|
||||
|
||||
function productScore(PDO $db, int $id): float {
|
||||
$tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn();
|
||||
$inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn();
|
||||
return $tx * 10 + $inv;
|
||||
}
|
||||
|
||||
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
|
||||
$db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
|
||||
$db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$byName = [];
|
||||
foreach ($products as $p) {
|
||||
$key = normName($p['name']);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$byName[$key][] = $p;
|
||||
}
|
||||
|
||||
$merged = 0;
|
||||
foreach ($byName as $nameKey => $group) {
|
||||
if (count($group) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into compatible-brand clusters
|
||||
$clusters = [];
|
||||
foreach ($group as $p) {
|
||||
$placed = false;
|
||||
foreach ($clusters as &$cluster) {
|
||||
$ref = $cluster[0];
|
||||
if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) {
|
||||
$cluster[] = $p;
|
||||
$placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($cluster);
|
||||
if (!$placed) {
|
||||
$clusters[] = [$p];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($clusters as $cluster) {
|
||||
if (count($cluster) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id']));
|
||||
$keep = (int)$cluster[0]['id'];
|
||||
$keepName = $cluster[0]['name'];
|
||||
for ($i = 1; $i < count($cluster); $i++) {
|
||||
$drop = (int)$cluster[$i]['id'];
|
||||
echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n";
|
||||
if (!$dryRun) {
|
||||
mergeProducts($db, $keep, $drop);
|
||||
}
|
||||
$merged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo $dryRun
|
||||
? "Dry run: $merged merge(s) would be performed.\n"
|
||||
: "Done: $merged duplicate product(s) merged.\n";
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* One-time security migration: GitHub token → encrypted .env, optional API_TOKEN.
|
||||
*/
|
||||
require_once __DIR__ . '/../api/lib/env.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
|
||||
$envFile = dirname(__DIR__) . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
fwrite(STDERR, ".env not found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
||||
$vars = loadEnv();
|
||||
$changed = false;
|
||||
|
||||
// Migrate legacy XOR token from previous index.php if still in git history
|
||||
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
|
||||
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
|
||||
$legacyKey = 'D1sp3ns4!Ev3r#26';
|
||||
$encBin = hex2bin($legacyEnc);
|
||||
$plain = '';
|
||||
if ($encBin) {
|
||||
for ($i = 0; $i < strlen($encBin); $i++) {
|
||||
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
|
||||
}
|
||||
}
|
||||
if ($plain !== '' && str_starts_with($plain, 'github_')) {
|
||||
$newKey = bin2hex(random_bytes(16));
|
||||
$enc = evershelfEncryptGhToken($plain, $newKey);
|
||||
$lines[] = '';
|
||||
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
|
||||
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
|
||||
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
|
||||
$changed = true;
|
||||
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$lines[] = '';
|
||||
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
|
||||
$lines[] = 'API_TOKEN=' . $token;
|
||||
$changed = true;
|
||||
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
file_put_contents($envFile, implode("\n", $lines) . "\n");
|
||||
chmod($envFile, 0640);
|
||||
echo "Updated .env\n";
|
||||
} else {
|
||||
echo "No migration needed\n";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Re-apply stock hints and 5% use-all rule to an archived recipe.
|
||||
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
|
||||
*/
|
||||
define('CRON_MODE', true);
|
||||
require __DIR__ . '/../api/index.php';
|
||||
|
||||
$id = (int)($argv[1] ?? 0);
|
||||
if ($id <= 0) {
|
||||
fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php <recipe_id>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
fwrite(STDERR, "Recipe {$id} not found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$recipe = json_decode($row['recipe_json'], true);
|
||||
if (!is_array($recipe)) {
|
||||
fwrite(STDERR, "Invalid recipe JSON for id {$id}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
|
||||
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
|
||||
$upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
|
||||
|
||||
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
|
||||
foreach ($recipe['ingredients'] ?? [] as $ing) {
|
||||
if (empty($ing['from_pantry'])) continue;
|
||||
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
|
||||
echo sprintf(
|
||||
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
|
||||
$ing['name'] ?? '?',
|
||||
$ing['qty'] ?? '?',
|
||||
$ing['stock_have'] ?? 0,
|
||||
$ing['stock_unit'] ?? '',
|
||||
$ing['stock_remain'] ?? 0,
|
||||
$ing['stock_unit'] ?? '',
|
||||
$useAll
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync translation files: ensure all locales have the same keys as it.json (reference)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent / 'translations'
|
||||
REF = 'it.json'
|
||||
LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json']
|
||||
|
||||
# New keys added across all locales (nested path -> value per locale)
|
||||
NEW_KEYS: dict[str, dict[str, str]] = {
|
||||
'dashboard.banner_prediction_confirmed': {
|
||||
'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni',
|
||||
'en': '✅ Confirmed — forecasts will recalculate from your next entries',
|
||||
'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet',
|
||||
'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements',
|
||||
'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros',
|
||||
},
|
||||
'dashboard.banner_anomaly_explain_fail': {
|
||||
'it': 'Impossibile ottenere spiegazione AI',
|
||||
'en': 'Could not get AI explanation',
|
||||
'de': 'KI-Erklärung konnte nicht abgerufen werden',
|
||||
'fr': 'Impossible d\'obtenir l\'explication IA',
|
||||
'es': 'No se pudo obtener la explicación de IA',
|
||||
},
|
||||
'dashboard.banner_anomaly_dismissed': {
|
||||
'it': 'Anomalia ignorata',
|
||||
'en': 'Anomaly dismissed',
|
||||
'de': 'Anomalie ignoriert',
|
||||
'fr': 'Anomalie ignorée',
|
||||
'es': 'Anomalía descartada',
|
||||
},
|
||||
'error.copy_failed': {
|
||||
'it': 'Copia negli appunti non riuscita',
|
||||
'en': 'Copy to clipboard failed',
|
||||
'de': 'Kopieren in die Zwischenablage fehlgeschlagen',
|
||||
'fr': 'Échec de la copie dans le presse-papiers',
|
||||
'es': 'Error al copiar al portapapeles',
|
||||
},
|
||||
'error.invalid_quantity': {
|
||||
'it': 'Quantità non valida',
|
||||
'en': 'Invalid quantity',
|
||||
'de': 'Ungültige Menge',
|
||||
'fr': 'Quantité invalide',
|
||||
'es': 'Cantidad no válida',
|
||||
},
|
||||
'dashboard.banner_finished_restore_prompt': {
|
||||
'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})',
|
||||
'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})',
|
||||
'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})',
|
||||
'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})',
|
||||
'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})',
|
||||
},
|
||||
'time.just_now': {
|
||||
'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora',
|
||||
},
|
||||
'time.seconds_ago': {
|
||||
'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s',
|
||||
},
|
||||
'time.minutes_ago': {
|
||||
'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min',
|
||||
},
|
||||
'time.hours_ago': {
|
||||
'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h',
|
||||
},
|
||||
'time.days_ago': {
|
||||
'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d',
|
||||
},
|
||||
'use.locations_short': {
|
||||
'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones',
|
||||
},
|
||||
'move.moved_simple': {
|
||||
'it': '📦 Spostato in {location}',
|
||||
'en': '📦 Moved to {location}',
|
||||
'de': '📦 Nach {location} verschoben',
|
||||
'fr': '📦 Déplacé vers {location}',
|
||||
'es': '📦 Movido a {location}',
|
||||
},
|
||||
'product.history_badge': {
|
||||
'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial',
|
||||
},
|
||||
'ai.conservation_hint': {
|
||||
'it': '🤖 AI: conserva in {location}',
|
||||
'en': '🤖 AI: store in {location}',
|
||||
'de': '🤖 KI: lagere in {location}',
|
||||
'fr': '🤖 IA : conserve dans {location}',
|
||||
'es': '🤖 IA: conserva en {location}',
|
||||
},
|
||||
'settings.kiosk_update_required': {
|
||||
'it': '⚠️ Aggiorna il kiosk per usare questa funzione',
|
||||
'en': '⚠️ Update the kiosk app to use this feature',
|
||||
'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen',
|
||||
'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction',
|
||||
'es': '⚠️ Actualiza la app kiosk para usar esta función',
|
||||
},
|
||||
'shopping.bring_names_migrated': {
|
||||
'it': '🔄 {n} nomi generalizzati in Bring!',
|
||||
'en': '🔄 {n} names generalized in Bring!',
|
||||
'de': '🔄 {n} Namen in Bring! verallgemeinert',
|
||||
'fr': '🔄 {n} noms généralisés dans Bring !',
|
||||
'es': '🔄 {n} nombres generalizados en Bring!',
|
||||
},
|
||||
'scan.mode_shopping_activated': {
|
||||
'it': '🛒 Modalità Spesa attivata!',
|
||||
'en': '🛒 Shopping mode activated!',
|
||||
'de': '🛒 Einkaufsmodus aktiviert!',
|
||||
'fr': '🛒 Mode courses activé !',
|
||||
'es': '🛒 ¡Modo compras activado!',
|
||||
},
|
||||
'settings.scale.discover_scanning': {
|
||||
'it': '🔍 Scansione rete locale per gateway bilancia…',
|
||||
'en': '🔍 Scanning local network for scale gateway…',
|
||||
'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…',
|
||||
'fr': '🔍 Recherche du gateway balance sur le réseau local…',
|
||||
'es': '🔍 Buscando pasarela de báscula en la red local…',
|
||||
},
|
||||
'settings.scale.discover_found': {
|
||||
'it': '✅ Gateway trovato: {url}{more}',
|
||||
'en': '✅ Gateway found: {url}{more}',
|
||||
'de': '✅ Gateway gefunden: {url}{more}',
|
||||
'fr': '✅ Gateway trouvé : {url}{more}',
|
||||
'es': '✅ Pasarela encontrada: {url}{more}',
|
||||
},
|
||||
'settings.scale.discover_not_found': {
|
||||
'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.',
|
||||
'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.',
|
||||
'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.',
|
||||
'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.',
|
||||
'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.',
|
||||
},
|
||||
'settings.scale.discover_failed': {
|
||||
'it': '❌ Ricerca fallita: {error}',
|
||||
'en': '❌ Discovery failed: {error}',
|
||||
'de': '❌ Suche fehlgeschlagen: {error}',
|
||||
'fr': '❌ Échec de la recherche : {error}',
|
||||
'es': '❌ Búsqueda fallida: {error}',
|
||||
},
|
||||
'settings.scale.discover_auto': {
|
||||
'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto',
|
||||
},
|
||||
'settings.scale.unknown_device': {
|
||||
'it': 'Dispositivo sconosciuto',
|
||||
'en': 'Unknown device',
|
||||
'de': 'Unbekanntes Gerät',
|
||||
'fr': 'Appareil inconnu',
|
||||
'es': 'Dispositivo desconocido',
|
||||
},
|
||||
'product.from_history': {
|
||||
'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)',
|
||||
},
|
||||
'recipes.ing_stock_line': {
|
||||
'it': 'Hai {have} · restano {remain} dopo l\'uso',
|
||||
'en': 'You have {have} · {remain} left after use',
|
||||
'de': 'Du hast {have} · {remain} bleiben nach Gebrauch',
|
||||
'fr': 'Vous avez {have} · il reste {remain} après usage',
|
||||
'es': 'Tienes {have} · quedan {remain} después del uso',
|
||||
},
|
||||
'recipes.ing_use_all_note': {
|
||||
'it': 'uso totale (<5% della confezione intera)',
|
||||
'en': 'use all (<5% of full package left)',
|
||||
'de': 'alles verwenden (<5% der Vollpackung)',
|
||||
'fr': 'tout utiliser (<5% du conditionnement entier)',
|
||||
'es': 'usar todo (<5% del envase completo)',
|
||||
},
|
||||
}
|
||||
|
||||
# fr/es gaps filled with proper translations (flat key -> value)
|
||||
FR_FILL: dict[str, str] = {
|
||||
'action.related_stock_title': 'Aussi à la maison',
|
||||
'dashboard.banner_expired_action_modify': 'Modifier',
|
||||
'dashboard.banner_expired_action_vacuum': 'Mettre sous vide',
|
||||
'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.',
|
||||
'scan.stock_in_pantry': 'Déjà à la maison :',
|
||||
'scanner.expiry_found': 'Date trouvée',
|
||||
'scanner.expiry_raw_label': 'Lu',
|
||||
'scanner.expiry_read_fail': 'Impossible de lire la date.',
|
||||
'settings.info.act_new_products': 'Nouveaux produits',
|
||||
'settings.info.act_restock': 'Réapprovisionnements',
|
||||
'settings.info.act_title': 'Activité mensuelle',
|
||||
'settings.info.act_tx_month': 'Mouvements',
|
||||
'settings.info.act_tx_year': 'Mouvements annuels',
|
||||
'settings.info.act_use': 'Utilisations',
|
||||
'settings.info.ai_calls': 'Appels',
|
||||
'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.',
|
||||
'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système',
|
||||
'settings.info.ai_title': 'Gemini AI — Utilisation des tokens',
|
||||
'settings.info.bring_days': 'jeton expire dans {n} jours',
|
||||
'settings.info.bring_expired': 'jeton expiré',
|
||||
'settings.info.by_action': 'Répartition par fonction',
|
||||
'settings.info.by_model': 'Répartition par modèle',
|
||||
'settings.info.cache_entries': 'produits',
|
||||
'settings.info.calls_unit': 'appels',
|
||||
'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.',
|
||||
'settings.info.currency_title': 'Devise',
|
||||
'settings.info.db_size': 'Base de données',
|
||||
'settings.info.est_cost': 'Coût est.',
|
||||
'settings.info.input_tok': 'Tokens entrée',
|
||||
'settings.info.inv_active': 'Actifs',
|
||||
'settings.info.inv_expired': 'Expirés',
|
||||
'settings.info.inv_expiring': 'Expirent (7j)',
|
||||
'settings.info.inv_finished': 'Terminés',
|
||||
'settings.info.inv_products': 'Produits totaux',
|
||||
'settings.info.inv_title': 'Inventaire',
|
||||
'settings.info.last_backup': 'Dernière sauvegarde',
|
||||
'settings.info.loading': 'Chargement…',
|
||||
'settings.info.log_level': 'Niveau de log',
|
||||
'settings.info.log_size': 'Logs',
|
||||
'settings.info.output_tok': 'Tokens sortie',
|
||||
'settings.info.price_cache': 'Cache prix',
|
||||
'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
|
||||
'settings.info.system_title': 'Système',
|
||||
'settings.info.tab': 'Info',
|
||||
'settings.info.total_tokens': 'Tokens totaux',
|
||||
'settings.info.year_label': 'Année {year}',
|
||||
'settings.tab_general': 'Général',
|
||||
'settings.tts.test_sound_btn': '🔔 Test sonore',
|
||||
'shopping.pantry_hint': 'Déjà à la maison : {qty}',
|
||||
'startup.check_db_legacy': 'Ancienne BD (dispensa.db)',
|
||||
'startup.check_scale': 'Passerelle balance',
|
||||
'startup.check_tts': 'URL synthèse vocale',
|
||||
'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :',
|
||||
'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.',
|
||||
'toast.vacuum_sealed': '{name} enregistré sous vide',
|
||||
}
|
||||
|
||||
ES_FILL = {
|
||||
'action.related_stock_title': 'También en casa',
|
||||
'dashboard.banner_expired_action_modify': 'Editar',
|
||||
'dashboard.banner_expired_action_vacuum': 'Poner al vacío',
|
||||
'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.',
|
||||
'scan.stock_in_pantry': 'Ya en despensa:',
|
||||
'scanner.expiry_found': 'Fecha encontrada',
|
||||
'scanner.expiry_raw_label': 'Leído',
|
||||
'scanner.expiry_read_fail': 'No se puede leer la fecha.',
|
||||
'settings.info.act_new_products': 'Productos nuevos',
|
||||
'settings.info.act_restock': 'Reabastecimientos',
|
||||
'settings.info.act_title': 'Actividad mensual',
|
||||
'settings.info.act_tx_month': 'Movimientos',
|
||||
'settings.info.act_tx_year': 'Movimientos anuales',
|
||||
'settings.info.act_use': 'Usos',
|
||||
'settings.info.ai_calls': 'Llamadas',
|
||||
'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.',
|
||||
'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema',
|
||||
'settings.info.ai_title': 'Gemini AI — Uso de tokens',
|
||||
'settings.info.bring_days': 'token expira en {n} días',
|
||||
'settings.info.bring_expired': 'token expirado',
|
||||
'settings.info.by_action': 'Desglose por función',
|
||||
'settings.info.by_model': 'Desglose por modelo',
|
||||
'settings.info.cache_entries': 'productos',
|
||||
'settings.info.calls_unit': 'llamadas',
|
||||
'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.',
|
||||
'settings.info.currency_title': 'Moneda',
|
||||
'settings.info.db_size': 'Base de datos',
|
||||
'settings.info.est_cost': 'Coste est.',
|
||||
'settings.info.input_tok': 'Tokens de entrada',
|
||||
'settings.info.inv_active': 'Activos',
|
||||
'settings.info.inv_expired': 'Caducados',
|
||||
'settings.info.inv_expiring': 'Caducan (7d)',
|
||||
'settings.info.inv_finished': 'Agotados',
|
||||
'settings.info.inv_products': 'Productos totales',
|
||||
'settings.info.inv_title': 'Inventario',
|
||||
'settings.info.last_backup': 'Última copia',
|
||||
'settings.info.loading': 'Cargando…',
|
||||
'settings.info.log_level': 'Nivel de log',
|
||||
'settings.info.log_size': 'Logs',
|
||||
'settings.info.output_tok': 'Tokens de salida',
|
||||
'settings.info.price_cache': 'Caché de precios',
|
||||
'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
|
||||
'settings.info.system_title': 'Sistema',
|
||||
'settings.info.tab': 'Info',
|
||||
'settings.info.total_tokens': 'Tokens totales',
|
||||
'settings.info.year_label': 'Año {year}',
|
||||
'settings.tab_general': 'General',
|
||||
'settings.tts.test_sound_btn': '🔔 Prueba de sonido',
|
||||
'shopping.pantry_hint': 'Ya en casa: {qty}',
|
||||
'startup.check_db_legacy': 'BD antigua (dispensa.db)',
|
||||
'startup.check_scale': 'Pasarela báscula',
|
||||
'startup.check_tts': 'URL texto a voz',
|
||||
'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:',
|
||||
'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.',
|
||||
'toast.vacuum_sealed': '{name} guardado al vacío',
|
||||
}
|
||||
|
||||
|
||||
def flatten(obj: dict, prefix: str = '') -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for k, v in obj.items():
|
||||
key = f'{prefix}.{k}' if prefix else k
|
||||
if isinstance(v, dict):
|
||||
out.update(flatten(v, key))
|
||||
else:
|
||||
out[key] = v
|
||||
return out
|
||||
|
||||
|
||||
def set_nested(root: dict, dotted: str, value: str) -> None:
|
||||
parts = dotted.split('.')
|
||||
d = root
|
||||
for p in parts[:-1]:
|
||||
d = d.setdefault(p, {})
|
||||
d[parts[-1]] = value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ref = json.loads((ROOT / REF).read_text(encoding='utf-8'))
|
||||
ref_flat = flatten(ref)
|
||||
en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8')))
|
||||
|
||||
for fname in LOCALES:
|
||||
lang = fname.replace('.json', '')
|
||||
path = ROOT / fname
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
flat = flatten(data)
|
||||
|
||||
# Fill missing keys from reference (Italian text as last resort via en)
|
||||
for key, ref_val in ref_flat.items():
|
||||
if key not in flat:
|
||||
if lang == 'fr' and key in FR_FILL:
|
||||
val = FR_FILL[key]
|
||||
elif lang == 'es' and key in ES_FILL:
|
||||
val = ES_FILL[key]
|
||||
elif lang == 'en':
|
||||
val = en_flat.get(key, ref_val)
|
||||
else:
|
||||
val = en_flat.get(key, ref_val)
|
||||
set_nested(data, key, val)
|
||||
flat[key] = val
|
||||
|
||||
# Inject new keys
|
||||
for key, per_lang in NEW_KEYS.items():
|
||||
set_nested(data, key, per_lang[lang if lang in per_lang else 'en'])
|
||||
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(f'Updated {fname}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+88
-13
@@ -143,14 +143,22 @@
|
||||
"banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
|
||||
"banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.",
|
||||
"banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.",
|
||||
"banner_finished_vanished": "Das Produkt erscheint nicht mehr im Bestand, aber die Buchungen deuten an, dass es nicht leer sein sollte.",
|
||||
"banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
|
||||
"banner_finished_check": "Kannst du nachschauen?",
|
||||
"banner_finished_action_restore": "{qty} {unit} wiederherstellen",
|
||||
"banner_anomaly_phantom_title": "mehr Bestand als erwartet",
|
||||
"banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
|
||||
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
||||
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
||||
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
||||
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
||||
"banner_dup_loss_title": "Prüfung Doppelabbuchung: {name}",
|
||||
"banner_dup_loss_detail": "Mögliche doppelte Buchung in {location}: zwei schnelle Abgänge ({qty_pair}) in ~{seconds}s. Bitte prüfen und ggf. korrigieren.",
|
||||
"banner_dup_loss_action_fix": "Menge korrigieren",
|
||||
"banner_dup_loss_action_open": "Produktkarte öffnen",
|
||||
"banner_dup_loss_action_done": "Bereits geprüft",
|
||||
"banner_dup_loss_toast_done": "Prüfung als erledigt markiert",
|
||||
"consumed": "Verbraucht: {n} ({pct}%)",
|
||||
"wasted": "Weggeworfen: {n} ({pct}%)",
|
||||
"more_opened": "und {n} weitere geöffnet...",
|
||||
@@ -158,7 +166,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Gemini um eine Erklärung bitten",
|
||||
"banner_explain_btn": "Erklären",
|
||||
"banner_analyzing": "🤖 Analysiere…"
|
||||
"banner_analyzing": "🤖 Analysiere…",
|
||||
"banner_prediction_confirmed": "✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet",
|
||||
"banner_anomaly_explain_fail": "KI-Erklärung konnte nicht abgerufen werden",
|
||||
"banner_anomaly_dismissed": "Anomalie ignoriert",
|
||||
"banner_finished_restore_prompt": "Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Vorrat",
|
||||
@@ -220,7 +232,25 @@
|
||||
"status_partial": "Erkannt: {code} — prüfe...",
|
||||
"status_invalid": "Ungültig: {code} — versuche erneut",
|
||||
"status_confirmed": "Bestätigt!",
|
||||
"status_parallel": "Kombinierter Scan aktiv..."
|
||||
"status_parallel": "Kombinierter Scan aktiv...",
|
||||
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
|
||||
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"ai_fallback_searching": "KI identifiziert Produkt...",
|
||||
"ai_fallback_found": "Produkt von KI erkannt",
|
||||
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
|
||||
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
|
||||
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
|
||||
"ai_retry_btn": "Mit KI erneut versuchen",
|
||||
"ai_match_title": "Produkt von KI erkannt",
|
||||
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
|
||||
"ai_match_existing": "Mogliche Treffer in der Vorratskammer",
|
||||
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
|
||||
"ai_match_use_btn": "Dieses nutzen",
|
||||
"ai_match_add_btn": "\"{name}\" hinzufugen",
|
||||
"ai_detected_label": "KI erkannt",
|
||||
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
@@ -293,14 +323,17 @@
|
||||
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
||||
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
|
||||
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
|
||||
"disambiguation_one_conf": "<strong>1 Packung</strong> aufgebraucht ({qty})",
|
||||
"disambiguation_all": "🗑️ ALLES verbraucht ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 Packung von {name} verbraucht!",
|
||||
"error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!",
|
||||
"use_all_confirm_title": "✅ Alles aufbrauchen",
|
||||
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
|
||||
"use_all_confirm_btn": "✅ Ja, aufgebraucht",
|
||||
"throw_all_confirm_title": "🗑️ Alles entsorgen",
|
||||
"throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?",
|
||||
"throw_all_confirm_btn": "🗑️ Ja, entsorgen"
|
||||
"throw_all_confirm_btn": "🗑️ Ja, entsorgen",
|
||||
"locations_short": "Orte"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Neues Produkt",
|
||||
@@ -340,7 +373,9 @@
|
||||
"weight_label": "Gewicht",
|
||||
"origin_label": "Herkunft",
|
||||
"labels_label": "Etiketten",
|
||||
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
|
||||
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:",
|
||||
"history_badge": "📊 Verlauf",
|
||||
"from_history": " (aus Verlauf)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Alle Produkte",
|
||||
@@ -393,7 +428,18 @@
|
||||
"load_error": "Fehler beim Laden",
|
||||
"favorite": "Zu Favoriten hinzufügen",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"adjust_persons": "Personen"
|
||||
"adjust_persons": "Personen",
|
||||
"nutrition_title": "Nährwerte (pro Portion)",
|
||||
"nutrition_kcal": "Kalorien",
|
||||
"nutrition_protein": "Protein",
|
||||
"nutrition_carbs": "Kohlenhydrate",
|
||||
"nutrition_fat": "Fett",
|
||||
"nutrition_per_serving": "Geschätzte Werte pro Portion",
|
||||
"storage_title": "Aufbewahrung von Resten",
|
||||
"storage_days": "{n} Tage",
|
||||
"storage_immediately": "Am besten sofort verzehren",
|
||||
"ing_stock_line": "Du hast {have} · {remain} bleiben nach Gebrauch",
|
||||
"ing_use_all_note": "alles verwenden (<5% der Vollpackung)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
@@ -480,6 +526,7 @@
|
||||
"remove_error": "Fehler beim Entfernen",
|
||||
"btn_fetch_prices": "Preise suchen",
|
||||
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
||||
"price_total_short": "geschätzte Ausgaben",
|
||||
"price_loading": "Preise werden gesucht…",
|
||||
"price_not_found": "Preis n/v",
|
||||
"suggest_loading": "Analyse läuft...",
|
||||
@@ -489,7 +536,8 @@
|
||||
"priority_low": "Niedrig",
|
||||
"smart_last_update": "Aktualisiert {time}",
|
||||
"names_already_updated": "Alle Namen sind bereits aktuell",
|
||||
"pantry_hint": "Bereits zuhause: {qty}"
|
||||
"pantry_hint": "Bereits zuhause: {qty}",
|
||||
"bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 KI-Identifikation",
|
||||
@@ -500,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
|
||||
"fields_filled": "✅ Felder von KI ausgefüllt",
|
||||
"use_data": "✅ KI-Daten verwenden",
|
||||
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)"
|
||||
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)",
|
||||
"conservation_hint": "🤖 KI: lagere in {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Verlauf",
|
||||
@@ -664,7 +713,9 @@
|
||||
"back": "📱 Rückkamera (Standard)",
|
||||
"front": "🤳 Frontkamera",
|
||||
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
||||
"detect_btn": "🔄 Kameras erkennen"
|
||||
"detect_btn": "🔄 Kameras erkennen",
|
||||
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
|
||||
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 HTTPS-Zertifikat",
|
||||
@@ -753,7 +804,13 @@
|
||||
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
|
||||
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
|
||||
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — automatische Heuristik für 100+ Modelle</li></ul>"
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — automatische Heuristik für 100+ Modelle</li></ul>",
|
||||
"discover_scanning": "🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…",
|
||||
"discover_found": "✅ Gateway gefunden: {url}{more}",
|
||||
"discover_not_found": "❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.",
|
||||
"discover_failed": "❌ Suche fehlgeschlagen: {error}",
|
||||
"discover_auto": "🔍 Auto",
|
||||
"unknown_device": "Unbekanntes Gerät"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
||||
@@ -940,7 +997,8 @@
|
||||
"sensor_copied": "YAML in die Zwischenablage kopiert!",
|
||||
"save_btn": "HA-Einstellungen speichern",
|
||||
"ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren."
|
||||
}
|
||||
},
|
||||
"kiosk_update_required": "⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HEUTE",
|
||||
@@ -1014,6 +1072,7 @@
|
||||
"finished_all": "📤 {name} aufgebraucht!",
|
||||
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
|
||||
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
|
||||
"ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt",
|
||||
"appliance_added": "Gerät hinzugefügt",
|
||||
"item_added": "{name} hinzugefügt"
|
||||
},
|
||||
@@ -1063,6 +1122,7 @@
|
||||
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
||||
"barcode_empty": "Barcode eingeben",
|
||||
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
||||
"barcode_checksum": "Ungültiger EAN-Prüfziffer — bitte die Barcode-Ziffern prüfen",
|
||||
"min_chars": "Mindestens 2 Zeichen eingeben",
|
||||
"not_in_inventory": "Produkt nicht im Bestand",
|
||||
"appliance_exists": "Gerät bereits vorhanden",
|
||||
@@ -1084,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} Aktionen ausstehend",
|
||||
"offline_synced": "{n} Aktionen synchronisiert",
|
||||
"offline_ai_disabled": "Offline nicht verfügbar",
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache"
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache",
|
||||
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
|
||||
"invalid_quantity": "Ungültige Menge"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1206,7 +1268,8 @@
|
||||
"stay_btn": "Nein, bleibt in {location}",
|
||||
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
||||
"vacuum_restore": "Vakuum wiederherstellen",
|
||||
"vacuum_seal_rest": "Rest vakuumieren"
|
||||
"vacuum_seal_rest": "Rest vakuumieren",
|
||||
"moved_simple": "📦 Nach {location} verschoben"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unverarbeitet",
|
||||
@@ -1441,7 +1504,12 @@
|
||||
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
|
||||
"retry": "Erneut versuchen",
|
||||
"syncing_local": "Lokale Daten synchronisieren...",
|
||||
"sync_done": "Lokale Daten aktualisiert"
|
||||
"sync_done": "Lokale Daten aktualisiert",
|
||||
"token_required": "API-Token erforderlich",
|
||||
"token_autoconfig": "Zugriff wird konfiguriert...",
|
||||
"token_prompt_title": "🔒 API-Token",
|
||||
"token_prompt_hint": "Geben Sie den API_TOKEN-Wert aus der .env-Datei des Servers ein.",
|
||||
"token_prompt_btn": "Weiter"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Monatsstatistik",
|
||||
@@ -1454,5 +1522,12 @@
|
||||
"top_used": "meistbenutzt",
|
||||
"top_cats": "Hauptkategorien",
|
||||
"source": "Transaktionsverlauf · aktueller Monat"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "gerade eben",
|
||||
"seconds_ago": "vor {n}s",
|
||||
"minutes_ago": "vor {n} min",
|
||||
"hours_ago": "vor {n} h",
|
||||
"days_ago": "vor {n} T"
|
||||
}
|
||||
}
|
||||
+88
-13
@@ -143,14 +143,22 @@
|
||||
"banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
|
||||
"banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.",
|
||||
"banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.",
|
||||
"banner_finished_vanished": "This product no longer appears in inventory, but recorded movements suggest it shouldn't be empty.",
|
||||
"banner_finished_expected": "According to records you should still have {qty} {unit}.",
|
||||
"banner_finished_check": "Can you check?",
|
||||
"banner_finished_action_restore": "Restore {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "you have more stock than expected",
|
||||
"banner_anomaly_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?",
|
||||
"banner_anomaly_untracked_title": "stock not recorded as an entry",
|
||||
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
||||
"banner_anomaly_ghost_title": "you have less stock than expected",
|
||||
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
||||
"banner_dup_loss_title": "Double-consume check: {name}",
|
||||
"banner_dup_loss_detail": "Possible duplicate entry in {location}: two close out events ({qty_pair}) in ~{seconds}s. Please verify and fix if needed.",
|
||||
"banner_dup_loss_action_fix": "Fix quantity",
|
||||
"banner_dup_loss_action_open": "Open product card",
|
||||
"banner_dup_loss_action_done": "Already checked",
|
||||
"banner_dup_loss_toast_done": "Check marked as reviewed",
|
||||
"consumed": "Consumed: {n} ({pct}%)",
|
||||
"wasted": "Wasted: {n} ({pct}%)",
|
||||
"more_opened": "and {n} more opened...",
|
||||
@@ -158,7 +166,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Ask Gemini for an explanation",
|
||||
"banner_explain_btn": "Explain",
|
||||
"banner_analyzing": "🤖 Analyzing…"
|
||||
"banner_analyzing": "🤖 Analyzing…",
|
||||
"banner_prediction_confirmed": "✅ Confirmed — forecasts will recalculate from your next entries",
|
||||
"banner_anomaly_explain_fail": "Could not get AI explanation",
|
||||
"banner_anomaly_dismissed": "Anomaly dismissed",
|
||||
"banner_finished_restore_prompt": "How many {unit} of {name} do you still have? (system estimate: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Pantry",
|
||||
@@ -220,7 +232,25 @@
|
||||
"status_partial": "Detected: {code} — verifying...",
|
||||
"status_invalid": "Invalid: {code} — retrying",
|
||||
"status_confirmed": "Confirmed!",
|
||||
"status_parallel": "Using combined scan methods..."
|
||||
"status_parallel": "Using combined scan methods...",
|
||||
"status_ocr_searching": "Reading the barcode digits...",
|
||||
"status_ai_visual_searching": "Now trying to recognize the product...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"ai_fallback_searching": "AI identifying product...",
|
||||
"ai_fallback_found": "Product identified by AI",
|
||||
"ai_fallback_not_found": "AI: product not recognized",
|
||||
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
|
||||
"ai_overlay_msg": "Gemini Vision is analyzing the product...",
|
||||
"ai_retry_btn": "Retry with AI",
|
||||
"ai_match_title": "Product recognized by AI",
|
||||
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
|
||||
"ai_match_existing": "Possible pantry matches",
|
||||
"ai_match_none": "No similar pantry products found.",
|
||||
"ai_match_use_btn": "Use this",
|
||||
"ai_match_add_btn": "Add \"{name}\"",
|
||||
"ai_detected_label": "AI detected",
|
||||
"mode_shopping_activated": "🛒 Shopping mode activated!"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
@@ -293,14 +323,17 @@
|
||||
"toast_bring": "🛒 Product finished → added to Bring!",
|
||||
"toast_opened_finished": "🔓 Opened package of {name} finished!",
|
||||
"disambiguation_hint": "What do you mean by \"all done\"?",
|
||||
"disambiguation_one_conf": "Finished <strong>1 package</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Finish EVERYTHING ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 package of {name} finished!",
|
||||
"error_exceeds_stock": "⚠️ You cannot use more than you have available!",
|
||||
"use_all_confirm_title": "✅ Finish everything",
|
||||
"use_all_confirm_msg": "Confirm that you have finished the product:",
|
||||
"use_all_confirm_btn": "✅ Yes, finished",
|
||||
"throw_all_confirm_title": "🗑️ Discard everything",
|
||||
"throw_all_confirm_msg": "Do you really want to throw away the whole product?",
|
||||
"throw_all_confirm_btn": "🗑️ Yes, discard"
|
||||
"throw_all_confirm_btn": "🗑️ Yes, discard",
|
||||
"locations_short": "places"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "New Product",
|
||||
@@ -340,7 +373,9 @@
|
||||
"weight_label": "Weight",
|
||||
"origin_label": "Origin",
|
||||
"labels_label": "Labels",
|
||||
"select_variant": "Select the exact variant or use AI data:"
|
||||
"select_variant": "Select the exact variant or use AI data:",
|
||||
"history_badge": "📊 history",
|
||||
"from_history": " (from history)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 All Products",
|
||||
@@ -393,7 +428,18 @@
|
||||
"load_error": "Loading error",
|
||||
"favorite": "Add to favourites",
|
||||
"unfavorite": "Remove from favourites",
|
||||
"adjust_persons": "Persons"
|
||||
"adjust_persons": "Persons",
|
||||
"nutrition_title": "Nutritional values (per serving)",
|
||||
"nutrition_kcal": "Calories",
|
||||
"nutrition_protein": "Protein",
|
||||
"nutrition_carbs": "Carbs",
|
||||
"nutrition_fat": "Fat",
|
||||
"nutrition_per_serving": "Estimated values per serving",
|
||||
"storage_title": "How to store leftovers",
|
||||
"storage_days": "{n} days",
|
||||
"storage_immediately": "Best eaten immediately",
|
||||
"ing_stock_line": "You have {have} · {remain} left after use",
|
||||
"ing_use_all_note": "use all (<5% of full package left)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Shopping List",
|
||||
@@ -480,6 +526,7 @@
|
||||
"remove_error": "Removal error",
|
||||
"btn_fetch_prices": "Find prices",
|
||||
"price_total_label": "💰 Estimated total:",
|
||||
"price_total_short": "estimated total",
|
||||
"price_loading": "Looking up prices…",
|
||||
"price_not_found": "price n/a",
|
||||
"suggest_loading": "Analyzing...",
|
||||
@@ -489,7 +536,8 @@
|
||||
"priority_low": "Low",
|
||||
"smart_last_update": "Updated {time}",
|
||||
"names_already_updated": "All names are already up to date",
|
||||
"pantry_hint": "Already at home: {qty}"
|
||||
"pantry_hint": "Already at home: {qty}",
|
||||
"bring_names_migrated": "🔄 {n} names generalized in Bring!"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 AI Identification",
|
||||
@@ -500,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
|
||||
"fields_filled": "✅ Fields filled by AI",
|
||||
"use_data": "✅ Use AI data",
|
||||
"use_data_no_barcode": "✅ Use AI data (no barcode)"
|
||||
"use_data_no_barcode": "✅ Use AI data (no barcode)",
|
||||
"conservation_hint": "🤖 AI: store in {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Operations Log",
|
||||
@@ -664,7 +713,9 @@
|
||||
"back": "📱 Rear (default)",
|
||||
"front": "🤳 Front",
|
||||
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
||||
"detect_btn": "🔄 Detect cameras"
|
||||
"detect_btn": "🔄 Detect cameras",
|
||||
"ai_fallback_label": "AI visual identification (5s fallback)",
|
||||
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 HTTPS Certificate",
|
||||
@@ -753,7 +804,13 @@
|
||||
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
|
||||
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — automatic heuristic for 100+ models</li></ul>"
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — automatic heuristic for 100+ models</li></ul>",
|
||||
"discover_scanning": "🔍 Scanning local network for scale gateway…",
|
||||
"discover_found": "✅ Gateway found: {url}{more}",
|
||||
"discover_not_found": "❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.",
|
||||
"discover_failed": "❌ Discovery failed: {error}",
|
||||
"discover_auto": "🔍 Auto",
|
||||
"unknown_device": "Unknown device"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
||||
@@ -940,7 +997,8 @@
|
||||
"sensor_copied": "YAML copied to clipboard!",
|
||||
"save_btn": "Save HA settings",
|
||||
"ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors."
|
||||
}
|
||||
},
|
||||
"kiosk_update_required": "⚠️ Update the kiosk app to use this feature"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "TODAY",
|
||||
@@ -1014,6 +1072,7 @@
|
||||
"finished_all": "📤 {name} finished!",
|
||||
"vacuum_sealed": "{name} saved as vacuum sealed",
|
||||
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
||||
"ghost_restored": "✅ {name}: restored {qty} {unit} to inventory",
|
||||
"appliance_added": "Appliance added",
|
||||
"item_added": "{name} added"
|
||||
},
|
||||
@@ -1063,6 +1122,7 @@
|
||||
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
||||
"barcode_empty": "Enter a barcode",
|
||||
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
||||
"barcode_checksum": "Invalid EAN checksum — please check the barcode digits",
|
||||
"min_chars": "Type at least 2 characters",
|
||||
"not_in_inventory": "Product not in inventory",
|
||||
"appliance_exists": "Appliance already exists",
|
||||
@@ -1084,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} operations pending",
|
||||
"offline_synced": "{n} operations synced",
|
||||
"offline_ai_disabled": "Not available offline",
|
||||
"offline_cache_ready": "Offline — {n} items cached"
|
||||
"offline_cache_ready": "Offline — {n} items cached",
|
||||
"copy_failed": "Copy to clipboard failed",
|
||||
"invalid_quantity": "Invalid quantity"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1206,7 +1268,8 @@
|
||||
"stay_btn": "No, stay in {location}",
|
||||
"moved_toast": "📦 Opened package moved to {location}",
|
||||
"vacuum_restore": "Restore vacuum sealed",
|
||||
"vacuum_seal_rest": "Vacuum seal the rest"
|
||||
"vacuum_seal_rest": "Vacuum seal the rest",
|
||||
"moved_simple": "📦 Moved to {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unprocessed",
|
||||
@@ -1441,7 +1504,12 @@
|
||||
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
|
||||
"retry": "Retry",
|
||||
"syncing_local": "Syncing local data...",
|
||||
"sync_done": "Local data synced"
|
||||
"sync_done": "Local data synced",
|
||||
"token_required": "API token required",
|
||||
"token_autoconfig": "Configuring access...",
|
||||
"token_prompt_title": "🔒 API Token",
|
||||
"token_prompt_hint": "Enter the API_TOKEN value from the server .env file.",
|
||||
"token_prompt_btn": "Continue"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Monthly Stats",
|
||||
@@ -1454,5 +1522,12 @@
|
||||
"top_used": "top used",
|
||||
"top_cats": "Top categories",
|
||||
"source": "Transaction history · current month"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "just now",
|
||||
"seconds_ago": "{n}s ago",
|
||||
"minutes_ago": "{n} min ago",
|
||||
"hours_ago": "{n} h ago",
|
||||
"days_ago": "{n} d ago"
|
||||
}
|
||||
}
|
||||
+149
-17
@@ -141,14 +141,22 @@
|
||||
"banner_prediction_more": "estimación anterior: {expected} {unit}{time}; cantidad actual: {actual} {unit}.",
|
||||
"banner_prediction_less": "estimación: {expected} {unit}{time}; cantidad actual: {actual} {unit}. Si tu ritmo de uso cambió, la previsión se actualiza automáticamente.",
|
||||
"banner_finished_zero": "El inventario muestra cero, pero los movimientos registrados sugieren que no debería estar vacío.",
|
||||
"banner_finished_vanished": "Este producto ya no aparece en el inventario, pero los movimientos registrados sugieren que no debería estar vacío.",
|
||||
"banner_finished_expected": "Según los registros deberías tener todavía {qty} {unit}.",
|
||||
"banner_finished_check": "¿Puedes comprobarlo?",
|
||||
"banner_finished_action_restore": "Restaurar {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "tienes más stock del esperado",
|
||||
"banner_anomaly_phantom_detail": "El inventario indica {inv_qty} {unit}, pero según los registros solo deberías tener {expected_qty} {unit}. ¿Añadiste stock sin registrarlo?",
|
||||
"banner_anomaly_untracked_title": "stock no registrado como entrada",
|
||||
"banner_anomaly_untracked_detail": "Tienes <strong>{inv_qty} {unit}</strong> en inventario, pero las salidas registradas superan las entradas — el stock inicial probablemente nunca se añadió como transacción «entrada». Puedes corregir la cantidad o registrar las entradas faltantes.",
|
||||
"banner_anomaly_ghost_title": "tienes menos stock del esperado",
|
||||
"banner_anomaly_ghost_detail": "Según las operaciones registradas deberías tener {expected_qty} {unit} de {name}, pero el inventario solo muestra {inv_qty} {unit}. ¿Tomaste stock sin registrarlo?",
|
||||
"banner_dup_loss_title": "Control de doble salida: {name}",
|
||||
"banner_dup_loss_detail": "Posible registro duplicado en {location}: dos salidas seguidas ({qty_pair}) en ~{seconds}s. Revisa y corrige si hace falta.",
|
||||
"banner_dup_loss_action_fix": "Corregir cantidad",
|
||||
"banner_dup_loss_action_open": "Abrir ficha del producto",
|
||||
"banner_dup_loss_action_done": "Ya revisado",
|
||||
"banner_dup_loss_toast_done": "Control marcado como revisado",
|
||||
"consumed": "Consumido: {n} ({pct}%)",
|
||||
"wasted": "Desperdiciado: {n} ({pct}%)",
|
||||
"more_opened": "y {n} más abiertos...",
|
||||
@@ -156,7 +164,13 @@
|
||||
"banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Pedir explicación a Gemini",
|
||||
"banner_explain_btn": "Explicar",
|
||||
"banner_analyzing": "🤖 Analizando…"
|
||||
"banner_analyzing": "🤖 Analizando…",
|
||||
"banner_expired_action_modify": "Editar",
|
||||
"banner_expired_action_vacuum": "Poner al vacío",
|
||||
"banner_prediction_confirmed": "✅ Confirmado — las previsiones se recalcularán con tus próximos registros",
|
||||
"banner_anomaly_explain_fail": "No se pudo obtener la explicación de IA",
|
||||
"banner_anomaly_dismissed": "Anomalía descartada",
|
||||
"banner_finished_restore_prompt": "¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Despensa",
|
||||
@@ -217,7 +231,26 @@
|
||||
"status_partial": "Detectado: {code} — verificando...",
|
||||
"status_invalid": "Inválido: {code} — reintentando",
|
||||
"status_confirmed": "Confirmado!",
|
||||
"status_parallel": "Escaneo combinado activo..."
|
||||
"status_parallel": "Escaneo combinado activo...",
|
||||
"status_ocr_searching": "Estoy leyendo los números del código de barras...",
|
||||
"status_ai_visual_searching": "Ahora intento reconocer el producto...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"ai_fallback_searching": "Identificación de IA en curso...",
|
||||
"ai_fallback_found": "Producto identificado por IA",
|
||||
"ai_fallback_not_found": "IA: producto no reconocido",
|
||||
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código",
|
||||
"ai_overlay_msg": "Gemini Vision está analizando el producto...",
|
||||
"ai_retry_btn": "Reintentar con IA",
|
||||
"ai_match_title": "Producto reconocido por IA",
|
||||
"ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.",
|
||||
"ai_match_existing": "Posibles coincidencias en despensa",
|
||||
"ai_match_none": "No se encontraron productos similares en despensa.",
|
||||
"ai_match_use_btn": "Usar este",
|
||||
"ai_match_add_btn": "Agregar \"{name}\"",
|
||||
"ai_detected_label": "IA detecto",
|
||||
"stock_in_pantry": "Ya en despensa:",
|
||||
"mode_shopping_activated": "🛒 ¡Modo compras activado!"
|
||||
},
|
||||
"action": {
|
||||
"title": "¿Qué quieres hacer?",
|
||||
@@ -231,7 +264,8 @@
|
||||
"throw_btn": "🗑️ DESECHAR",
|
||||
"throw_sub": "tirar",
|
||||
"edit_sub": "caducidad, ubicación…",
|
||||
"create_recipe_btn": "Receta"
|
||||
"create_recipe_btn": "Receta",
|
||||
"related_stock_title": "También en casa"
|
||||
},
|
||||
"add": {
|
||||
"title": "Añadir a la despensa",
|
||||
@@ -289,14 +323,17 @@
|
||||
"toast_bring": "🛒 Producto terminado → añadido a Bring!",
|
||||
"toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!",
|
||||
"disambiguation_hint": "¿Qué quieres decir con «todo terminado»?",
|
||||
"disambiguation_one_conf": "Terminado <strong>1 envase</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Terminar TODO ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 envase de {name} terminado!",
|
||||
"error_exceeds_stock": "⚠️ ¡No puedes usar más de lo que tienes disponible!",
|
||||
"use_all_confirm_title": "✅ Terminar todo",
|
||||
"use_all_confirm_msg": "Confirma que has terminado el producto:",
|
||||
"use_all_confirm_btn": "✅ Sí, terminado",
|
||||
"throw_all_confirm_title": "🗑️ Desechar todo",
|
||||
"throw_all_confirm_msg": "¿Realmente quieres tirar todo el producto?",
|
||||
"throw_all_confirm_btn": "🗑️ Sí, desechar"
|
||||
"throw_all_confirm_btn": "🗑️ Sí, desechar",
|
||||
"locations_short": "ubicaciones"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuevo producto",
|
||||
@@ -336,7 +373,9 @@
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origen",
|
||||
"labels_label": "Etiquetas",
|
||||
"select_variant": "Selecciona la variante exacta o usa los datos de IA:"
|
||||
"select_variant": "Selecciona la variante exacta o usa los datos de IA:",
|
||||
"history_badge": "📊 historial",
|
||||
"from_history": " (del historial)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Todos los productos",
|
||||
@@ -388,7 +427,19 @@
|
||||
"load_error": "Error de carga",
|
||||
"favorite": "Añadir a favoritos",
|
||||
"unfavorite": "Quitar de favoritos",
|
||||
"adjust_persons": "Personas"
|
||||
"adjust_persons": "Personas",
|
||||
"nutrition_title": "Valores nutricionales (por ración)",
|
||||
"nutrition_kcal": "Calorías",
|
||||
"nutrition_protein": "Proteínas",
|
||||
"nutrition_carbs": "Carbohidratos",
|
||||
"nutrition_fat": "Grasas",
|
||||
"nutrition_per_serving": "Valores estimados por ración",
|
||||
"storage_title": "Cómo conservar las sobras",
|
||||
"storage_days": "{n} días",
|
||||
"storage_immediately": "Mejor consumir de inmediato",
|
||||
"stream_interrupted": "Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.",
|
||||
"ing_stock_line": "Tienes {have} · quedan {remain} después del uso",
|
||||
"ing_use_all_note": "usar todo (<5% del envase completo)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista de la compra",
|
||||
@@ -475,6 +526,7 @@
|
||||
"remove_error": "Error al eliminar",
|
||||
"btn_fetch_prices": "Buscar precios",
|
||||
"price_total_label": "💰 Total estimado:",
|
||||
"price_total_short": "total estimado",
|
||||
"price_loading": "Buscando precios…",
|
||||
"price_not_found": "precio n/d",
|
||||
"suggest_loading": "Analizando...",
|
||||
@@ -483,7 +535,9 @@
|
||||
"priority_medium": "Media",
|
||||
"priority_low": "Baja",
|
||||
"smart_last_update": "Actualizado {time}",
|
||||
"names_already_updated": "Todos los nombres ya están actualizados"
|
||||
"names_already_updated": "Todos los nombres ya están actualizados",
|
||||
"pantry_hint": "Ya en casa: {qty}",
|
||||
"bring_names_migrated": "🔄 {n} nombres generalizados en Bring!"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identificación IA",
|
||||
@@ -494,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>",
|
||||
"fields_filled": "✅ Campos rellenados por IA",
|
||||
"use_data": "✅ Usar datos de IA",
|
||||
"use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)"
|
||||
"use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)",
|
||||
"conservation_hint": "🤖 IA: conserva en {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Registro de operaciones",
|
||||
@@ -658,7 +713,9 @@
|
||||
"back": "📱 Trasera (por defecto)",
|
||||
"front": "🤳 Frontal",
|
||||
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
|
||||
"detect_btn": "🔄 Detectar cámaras"
|
||||
"detect_btn": "🔄 Detectar cámaras",
|
||||
"ai_fallback_label": "Identificación visual IA (repuesto 5s)",
|
||||
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificado HTTPS",
|
||||
@@ -708,7 +765,8 @@
|
||||
"heard_yes": "Sí, la escuché",
|
||||
"heard_no": "No, no escuché nada",
|
||||
"test_ok_kiosk": "TTS funcionando.",
|
||||
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android."
|
||||
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android.",
|
||||
"test_sound_btn": "🔔 Prueba de sonido"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Idioma",
|
||||
@@ -746,7 +804,13 @@
|
||||
"kiosk_title": "📡 Báscula BLE integrada en el kiosco",
|
||||
"kiosk_hint": "La báscula está gestionada directamente por la pasarela BLE interna del kiosco. Para vincular un nuevo dispositivo, usa el asistente de configuración.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigurar báscula BLE",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico — heurística automática para 100+ modelos</li></ul>"
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico — heurística automática para 100+ modelos</li></ul>",
|
||||
"discover_scanning": "🔍 Buscando pasarela de báscula en la red local…",
|
||||
"discover_found": "✅ Pasarela encontrada: {url}{more}",
|
||||
"discover_not_found": "❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.",
|
||||
"discover_failed": "❌ Búsqueda fallida: {error}",
|
||||
"discover_auto": "🔍 Auto",
|
||||
"unknown_device": "Dispositivo desconocido"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
|
||||
@@ -892,7 +956,49 @@
|
||||
"sensor_copied": "¡YAML copiado al portapapeles!",
|
||||
"save_btn": "Guardar ajustes HA",
|
||||
"ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores."
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Uso de tokens",
|
||||
"ai_hint": "Consumo mensual y coste estimado para la clave API actual.",
|
||||
"loading": "Cargando…",
|
||||
"total_tokens": "Tokens totales",
|
||||
"est_cost": "Coste est.",
|
||||
"input_tok": "Tokens de entrada",
|
||||
"output_tok": "Tokens de salida",
|
||||
"ai_calls": "Llamadas",
|
||||
"by_action": "Desglose por función",
|
||||
"by_model": "Desglose por modelo",
|
||||
"pricing_note": "Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "Sistema",
|
||||
"db_size": "Base de datos",
|
||||
"log_size": "Logs",
|
||||
"log_level": "Nivel de log",
|
||||
"ai_overview": "Resumen de IA, inventario y estado del sistema",
|
||||
"calls_unit": "llamadas",
|
||||
"inv_title": "Inventario",
|
||||
"inv_active": "Activos",
|
||||
"inv_products": "Productos totales",
|
||||
"inv_expiring": "Caducan (7d)",
|
||||
"inv_expired": "Caducados",
|
||||
"inv_finished": "Agotados",
|
||||
"act_title": "Actividad mensual",
|
||||
"act_tx_month": "Movimientos",
|
||||
"act_restock": "Reabastecimientos",
|
||||
"act_use": "Usos",
|
||||
"act_new_products": "Productos nuevos",
|
||||
"act_tx_year": "Movimientos anuales",
|
||||
"price_cache": "Caché de precios",
|
||||
"cache_entries": "productos",
|
||||
"last_backup": "Última copia",
|
||||
"bring_days": "token expira en {n} días",
|
||||
"bring_expired": "token expirado",
|
||||
"year_label": "Año {year}",
|
||||
"currency_title": "Moneda",
|
||||
"currency_hint": "Moneda usada para todos los costes y precios en la app."
|
||||
},
|
||||
"tab_general": "General",
|
||||
"kiosk_update_required": "⚠️ Actualiza la app kiosk para usar esta función"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HOY",
|
||||
@@ -965,8 +1071,10 @@
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)",
|
||||
"finished_all": "📤 ¡{name} terminado!",
|
||||
"product_finished_confirmed": "✅ Eliminado — añádelo de nuevo cuando reabastezcas",
|
||||
"ghost_restored": "✅ {name}: restaurados {qty} {unit} en el inventario",
|
||||
"appliance_added": "Electrodoméstico añadido",
|
||||
"item_added": "{name} añadido"
|
||||
"item_added": "{name} añadido",
|
||||
"vacuum_sealed": "{name} guardado al vacío"
|
||||
},
|
||||
"antiwaste": {
|
||||
"title": "🌱 Informe anti-desperdicio",
|
||||
@@ -1014,6 +1122,7 @@
|
||||
"ai_quota": "Cuota de IA agotada. Inténtalo de nuevo en unos minutos.",
|
||||
"barcode_empty": "Introduce un código de barras",
|
||||
"barcode_format": "El código de barras solo puede contener números (4-14 dígitos)",
|
||||
"barcode_checksum": "Suma de comprobación EAN inválida — verifica los dígitos del código",
|
||||
"min_chars": "Escribe al menos 2 caracteres",
|
||||
"not_in_inventory": "Producto no en inventario",
|
||||
"appliance_exists": "El electrodoméstico ya existe",
|
||||
@@ -1035,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} operaciones pendientes",
|
||||
"offline_synced": "{n} operaciones sincronizadas",
|
||||
"offline_ai_disabled": "No disponible sin conexión",
|
||||
"offline_cache_ready": "Offline — {n} productos en caché"
|
||||
"offline_cache_ready": "Offline — {n} productos en caché",
|
||||
"copy_failed": "Error al copiar al portapapeles",
|
||||
"invalid_quantity": "Cantidad no válida"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1136,7 +1247,10 @@
|
||||
"retake_btn": "🔄 Repetir",
|
||||
"camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.<br>Puedes introducir el código de barras manualmente o usar la identificación IA.",
|
||||
"no_barcode": "Sin código de barras",
|
||||
"save_new_btn": "🆕 Ninguno de estos — guardar como nuevo"
|
||||
"save_new_btn": "🆕 Ninguno de estos — guardar como nuevo",
|
||||
"expiry_found": "Fecha encontrada",
|
||||
"expiry_read_fail": "No se puede leer la fecha.",
|
||||
"expiry_raw_label": "Leído"
|
||||
},
|
||||
"lowstock": {
|
||||
"title": "⚠️ ¡Stock bajo!",
|
||||
@@ -1154,7 +1268,8 @@
|
||||
"stay_btn": "No, quedarse en {location}",
|
||||
"moved_toast": "📦 Paquete abierto movido a {location}",
|
||||
"vacuum_restore": "🫙 Restaurar al vacío",
|
||||
"vacuum_seal_rest": "🔒 Sellar el resto al vacío"
|
||||
"vacuum_seal_rest": "🔒 Sellar el resto al vacío",
|
||||
"moved_simple": "📦 Movido a {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Sin procesar",
|
||||
@@ -1384,7 +1499,17 @@
|
||||
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
|
||||
"retry": "Reintentar",
|
||||
"syncing_local": "Sincronizando datos locales...",
|
||||
"sync_done": "Datos locales sincronizados"
|
||||
"sync_done": "Datos locales sincronizados",
|
||||
"token_required": "Token API requerido",
|
||||
"token_autoconfig": "Configurando acceso...",
|
||||
"token_prompt_title": "🔒 Token API",
|
||||
"token_prompt_hint": "Introduce el valor API_TOKEN del archivo .env del servidor.",
|
||||
"token_prompt_btn": "Continuar",
|
||||
"check_db_legacy": "BD antigua (dispensa.db)",
|
||||
"check_tts": "URL texto a voz",
|
||||
"check_scale": "Pasarela báscula",
|
||||
"critical_error_intro": "La app no puede iniciarse por los siguientes problemas:",
|
||||
"error_network_detail": "El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo."
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Estadísticas Mensuales",
|
||||
@@ -1397,5 +1522,12 @@
|
||||
"top_used": "más usado",
|
||||
"top_cats": "Categorías principales",
|
||||
"source": "Historial de transacciones · mes actual"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "ahora",
|
||||
"seconds_ago": "hace {n}s",
|
||||
"minutes_ago": "hace {n} min",
|
||||
"hours_ago": "hace {n} h",
|
||||
"days_ago": "hace {n} d"
|
||||
}
|
||||
}
|
||||
+149
-17
@@ -141,14 +141,22 @@
|
||||
"banner_prediction_more": "estimation précédente : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}.",
|
||||
"banner_prediction_less": "estimation : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}. Si votre rythme d'utilisation a changé, la prévision se met à jour automatiquement.",
|
||||
"banner_finished_zero": "L'inventaire indique zéro, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.",
|
||||
"banner_finished_vanished": "Ce produit n'apparaît plus dans l'inventaire, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.",
|
||||
"banner_finished_expected": "D'après les enregistrements vous devriez avoir encore {qty} {unit}.",
|
||||
"banner_finished_check": "Pouvez-vous vérifier ?",
|
||||
"banner_finished_action_restore": "Restaurer {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "vous avez plus de stock que prévu",
|
||||
"banner_anomaly_phantom_detail": "L'inventaire indique {inv_qty} {unit}, mais selon les enregistrements vous ne devriez avoir que {expected_qty} {unit}. Avez-vous ajouté du stock sans l'enregistrer ?",
|
||||
"banner_anomaly_untracked_title": "stock non enregistré comme entrée",
|
||||
"banner_anomaly_untracked_detail": "Vous avez <strong>{inv_qty} {unit}</strong> en inventaire, mais les sorties enregistrées dépassent les entrées — le stock initial n'a probablement jamais été ajouté comme transaction « entrée ». Vous pouvez corriger la quantité ou saisir les entrées manquantes.",
|
||||
"banner_anomaly_ghost_title": "vous avez moins de stock que prévu",
|
||||
"banner_anomaly_ghost_detail": "D'après les opérations enregistrées vous devriez avoir {expected_qty} {unit} de {name}, mais l'inventaire n'en montre que {inv_qty} {unit}. Avez-vous pris du stock sans l'enregistrer ?",
|
||||
"banner_dup_loss_title": "Vérification double sortie : {name}",
|
||||
"banner_dup_loss_detail": "Doublon possible dans {location} : deux sorties rapprochées ({qty_pair}) en ~{seconds}s. Vérifiez et corrigez si besoin.",
|
||||
"banner_dup_loss_action_fix": "Corriger la quantité",
|
||||
"banner_dup_loss_action_open": "Ouvrir la fiche produit",
|
||||
"banner_dup_loss_action_done": "Déjà vérifié",
|
||||
"banner_dup_loss_toast_done": "Contrôle marqué comme vérifié",
|
||||
"consumed": "Consommé : {n} ({pct}%)",
|
||||
"wasted": "Gaspillé : {n} ({pct}%)",
|
||||
"more_opened": "et {n} autres ouverts...",
|
||||
@@ -156,7 +164,13 @@
|
||||
"banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Demander une explication à Gemini",
|
||||
"banner_explain_btn": "Expliquer",
|
||||
"banner_analyzing": "🤖 Analyse en cours…"
|
||||
"banner_analyzing": "🤖 Analyse en cours…",
|
||||
"banner_expired_action_modify": "Modifier",
|
||||
"banner_expired_action_vacuum": "Mettre sous vide",
|
||||
"banner_prediction_confirmed": "✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements",
|
||||
"banner_anomaly_explain_fail": "Impossible d'obtenir l'explication IA",
|
||||
"banner_anomaly_dismissed": "Anomalie ignorée",
|
||||
"banner_finished_restore_prompt": "Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Garde-manger",
|
||||
@@ -217,7 +231,26 @@
|
||||
"status_partial": "Lu : {code} — vérification...",
|
||||
"status_invalid": "Invalide : {code} — nouvel essai",
|
||||
"status_confirmed": "Confirmé !",
|
||||
"status_parallel": "Scan combiné actif..."
|
||||
"status_parallel": "Scan combiné actif...",
|
||||
"status_ocr_searching": "Je lis les chiffres du code-barres...",
|
||||
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"ai_fallback_searching": "Identification IA en cours...",
|
||||
"ai_fallback_found": "Produit identifié par l'IA",
|
||||
"ai_fallback_not_found": "IA : produit non reconnu",
|
||||
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres",
|
||||
"ai_overlay_msg": "Gemini Vision analyse le produit...",
|
||||
"ai_retry_btn": "Reessayer avec l'IA",
|
||||
"ai_match_title": "Produit reconnu par l'IA",
|
||||
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
|
||||
"ai_match_existing": "Correspondances possibles dans le stock",
|
||||
"ai_match_none": "Aucun produit similaire trouve dans le stock.",
|
||||
"ai_match_use_btn": "Utiliser celui-ci",
|
||||
"ai_match_add_btn": "Ajouter \"{name}\"",
|
||||
"ai_detected_label": "IA a detecte",
|
||||
"stock_in_pantry": "Déjà à la maison :",
|
||||
"mode_shopping_activated": "🛒 Mode courses activé !"
|
||||
},
|
||||
"action": {
|
||||
"title": "Que voulez-vous faire ?",
|
||||
@@ -231,7 +264,8 @@
|
||||
"throw_btn": "🗑️ JETER",
|
||||
"throw_sub": "jeter",
|
||||
"edit_sub": "péremption, emplacement…",
|
||||
"create_recipe_btn": "Recette"
|
||||
"create_recipe_btn": "Recette",
|
||||
"related_stock_title": "Aussi à la maison"
|
||||
},
|
||||
"add": {
|
||||
"title": "Ajouter au garde-manger",
|
||||
@@ -289,14 +323,17 @@
|
||||
"toast_bring": "🛒 Produit terminé → ajouté à Bring !",
|
||||
"toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !",
|
||||
"disambiguation_hint": "Que voulez-vous dire par « tout fini » ?",
|
||||
"disambiguation_one_conf": "Terminer <strong>1 emballage</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Tout finir ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 emballage de {name} terminé !",
|
||||
"error_exceeds_stock": "⚠️ Vous ne pouvez pas utiliser plus que ce que vous avez disponible !",
|
||||
"use_all_confirm_title": "✅ Tout terminer",
|
||||
"use_all_confirm_msg": "Confirmez que vous avez terminé le produit :",
|
||||
"use_all_confirm_btn": "✅ Oui, terminé",
|
||||
"throw_all_confirm_title": "🗑️ Tout jeter",
|
||||
"throw_all_confirm_msg": "Voulez-vous vraiment jeter tout le produit ?",
|
||||
"throw_all_confirm_btn": "🗑️ Oui, jeter"
|
||||
"throw_all_confirm_btn": "🗑️ Oui, jeter",
|
||||
"locations_short": "emplacements"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nouveau produit",
|
||||
@@ -336,7 +373,9 @@
|
||||
"weight_label": "Poids",
|
||||
"origin_label": "Origine",
|
||||
"labels_label": "Labels",
|
||||
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :"
|
||||
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :",
|
||||
"history_badge": "📊 historique",
|
||||
"from_history": " (historique)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tous les produits",
|
||||
@@ -388,7 +427,19 @@
|
||||
"load_error": "Erreur de chargement",
|
||||
"favorite": "Ajouter aux favoris",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"adjust_persons": "Personnes"
|
||||
"adjust_persons": "Personnes",
|
||||
"nutrition_title": "Valeurs nutritionnelles (par portion)",
|
||||
"nutrition_kcal": "Calories",
|
||||
"nutrition_protein": "Protéines",
|
||||
"nutrition_carbs": "Glucides",
|
||||
"nutrition_fat": "Lipides",
|
||||
"nutrition_per_serving": "Valeurs estimées par portion",
|
||||
"storage_title": "Comment conserver les restes",
|
||||
"storage_days": "{n} jours",
|
||||
"storage_immediately": "À consommer immédiatement",
|
||||
"stream_interrupted": "Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.",
|
||||
"ing_stock_line": "Vous avez {have} · il reste {remain} après usage",
|
||||
"ing_use_all_note": "tout utiliser (<5% du conditionnement entier)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Liste de courses",
|
||||
@@ -475,6 +526,7 @@
|
||||
"remove_error": "Erreur de suppression",
|
||||
"btn_fetch_prices": "Trouver les prix",
|
||||
"price_total_label": "💰 Total estimé :",
|
||||
"price_total_short": "total estimé",
|
||||
"price_loading": "Recherche des prix…",
|
||||
"price_not_found": "prix n/d",
|
||||
"suggest_loading": "Analyse en cours...",
|
||||
@@ -483,7 +535,9 @@
|
||||
"priority_medium": "Moyenne",
|
||||
"priority_low": "Faible",
|
||||
"smart_last_update": "Mis à jour {time}",
|
||||
"names_already_updated": "Tous les noms sont déjà à jour"
|
||||
"names_already_updated": "Tous les noms sont déjà à jour",
|
||||
"pantry_hint": "Déjà à la maison : {qty}",
|
||||
"bring_names_migrated": "🔄 {n} noms généralisés dans Bring !"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identification IA",
|
||||
@@ -494,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>",
|
||||
"fields_filled": "✅ Champs remplis par l'IA",
|
||||
"use_data": "✅ Utiliser les données IA",
|
||||
"use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)"
|
||||
"use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)",
|
||||
"conservation_hint": "🤖 IA : conserve dans {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Journal des opérations",
|
||||
@@ -658,7 +713,9 @@
|
||||
"back": "📱 Arrière (par défaut)",
|
||||
"front": "🤳 Frontale",
|
||||
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
|
||||
"detect_btn": "🔄 Détecter les caméras"
|
||||
"detect_btn": "🔄 Détecter les caméras",
|
||||
"ai_fallback_label": "Identification visuelle IA (repli 5s)",
|
||||
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificat HTTPS",
|
||||
@@ -708,7 +765,8 @@
|
||||
"heard_yes": "Oui, je l'ai entendu",
|
||||
"heard_no": "Non, je n'ai rien entendu",
|
||||
"test_ok_kiosk": "TTS fonctionne.",
|
||||
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android."
|
||||
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android.",
|
||||
"test_sound_btn": "🔔 Test sonore"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Langue",
|
||||
@@ -746,7 +804,13 @@
|
||||
"kiosk_title": "📡 Balance BLE intégrée dans le kiosque",
|
||||
"kiosk_hint": "La balance est directement gérée par la passerelle BLE interne du kiosque. Pour associer un nouvel appareil, utilisez l'assistant de configuration.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigurer la balance BLE",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique — heuristique automatique pour 100+ modèles</li></ul>"
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique — heuristique automatique pour 100+ modèles</li></ul>",
|
||||
"discover_scanning": "🔍 Recherche du gateway balance sur le réseau local…",
|
||||
"discover_found": "✅ Gateway trouvé : {url}{more}",
|
||||
"discover_not_found": "❌ Aucun gateway sur {subnet}. Lancez l'app Android sur le même Wi-Fi.",
|
||||
"discover_failed": "❌ Échec de la recherche : {error}",
|
||||
"discover_auto": "🔍 Auto",
|
||||
"unknown_device": "Appareil inconnu"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.",
|
||||
@@ -892,7 +956,49 @@
|
||||
"sensor_copied": "YAML copié dans le presse-papiers !",
|
||||
"save_btn": "Enregistrer les paramètres HA",
|
||||
"ha_hint": "Si vous utilisez Home Assistant, utilisez l'onglet Home Assistant pour configurer TTS, webhooks et capteurs."
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Utilisation des tokens",
|
||||
"ai_hint": "Consommation mensuelle et coût estimé pour la clé API actuelle.",
|
||||
"loading": "Chargement…",
|
||||
"total_tokens": "Tokens totaux",
|
||||
"est_cost": "Coût est.",
|
||||
"input_tok": "Tokens entrée",
|
||||
"output_tok": "Tokens sortie",
|
||||
"ai_calls": "Appels",
|
||||
"by_action": "Répartition par fonction",
|
||||
"by_model": "Répartition par modèle",
|
||||
"pricing_note": "Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "Système",
|
||||
"db_size": "Base de données",
|
||||
"log_size": "Logs",
|
||||
"log_level": "Niveau de log",
|
||||
"ai_overview": "Aperçu IA, inventaire et état du système",
|
||||
"calls_unit": "appels",
|
||||
"inv_title": "Inventaire",
|
||||
"inv_active": "Actifs",
|
||||
"inv_products": "Produits totaux",
|
||||
"inv_expiring": "Expirent (7j)",
|
||||
"inv_expired": "Expirés",
|
||||
"inv_finished": "Terminés",
|
||||
"act_title": "Activité mensuelle",
|
||||
"act_tx_month": "Mouvements",
|
||||
"act_restock": "Réapprovisionnements",
|
||||
"act_use": "Utilisations",
|
||||
"act_new_products": "Nouveaux produits",
|
||||
"act_tx_year": "Mouvements annuels",
|
||||
"price_cache": "Cache prix",
|
||||
"cache_entries": "produits",
|
||||
"last_backup": "Dernière sauvegarde",
|
||||
"bring_days": "jeton expire dans {n} jours",
|
||||
"bring_expired": "jeton expiré",
|
||||
"year_label": "Année {year}",
|
||||
"currency_title": "Devise",
|
||||
"currency_hint": "Devise utilisée pour tous les coûts et prix dans l'app."
|
||||
},
|
||||
"tab_general": "Général",
|
||||
"kiosk_update_required": "⚠️ Mettez à jour l'application kiosk pour utiliser cette fonction"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "AUJOURD'HUI",
|
||||
@@ -965,8 +1071,10 @@
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)",
|
||||
"finished_all": "📤 {name} terminé !",
|
||||
"product_finished_confirmed": "✅ Supprimé — ajoutez-le à nouveau lors du réapprovisionnement",
|
||||
"ghost_restored": "✅ {name} : {qty} {unit} restaurés dans l'inventaire",
|
||||
"appliance_added": "Appareil ajouté",
|
||||
"item_added": "{name} ajouté"
|
||||
"item_added": "{name} ajouté",
|
||||
"vacuum_sealed": "{name} enregistré sous vide"
|
||||
},
|
||||
"antiwaste": {
|
||||
"title": "🌱 Rapport anti-gaspi",
|
||||
@@ -1014,6 +1122,7 @@
|
||||
"ai_quota": "Quota IA épuisé. Réessayez dans quelques minutes.",
|
||||
"barcode_empty": "Entrez un code-barres",
|
||||
"barcode_format": "Le code-barres ne doit contenir que des chiffres (4-14 chiffres)",
|
||||
"barcode_checksum": "Somme de contrôle EAN invalide — vérifiez les chiffres du code-barres",
|
||||
"min_chars": "Tapez au moins 2 caractères",
|
||||
"not_in_inventory": "Produit absent de l'inventaire",
|
||||
"appliance_exists": "L'appareil existe déjà",
|
||||
@@ -1035,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} opérations en attente",
|
||||
"offline_synced": "{n} opérations synchronisées",
|
||||
"offline_ai_disabled": "Indisponible hors ligne",
|
||||
"offline_cache_ready": "Offline — {n} produits en cache"
|
||||
"offline_cache_ready": "Offline — {n} produits en cache",
|
||||
"copy_failed": "Échec de la copie dans le presse-papiers",
|
||||
"invalid_quantity": "Quantité invalide"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1136,7 +1247,10 @@
|
||||
"retake_btn": "🔄 Reprendre",
|
||||
"camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.<br>Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.",
|
||||
"no_barcode": "Pas de code-barres",
|
||||
"save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau"
|
||||
"save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau",
|
||||
"expiry_found": "Date trouvée",
|
||||
"expiry_read_fail": "Impossible de lire la date.",
|
||||
"expiry_raw_label": "Lu"
|
||||
},
|
||||
"lowstock": {
|
||||
"title": "⚠️ Stock faible !",
|
||||
@@ -1154,7 +1268,8 @@
|
||||
"stay_btn": "Non, rester dans {location}",
|
||||
"moved_toast": "📦 Emballage ouvert déplacé vers {location}",
|
||||
"vacuum_restore": "🫙 Restaurer sous vide",
|
||||
"vacuum_seal_rest": "🔒 Mettre le reste sous vide"
|
||||
"vacuum_seal_rest": "🔒 Mettre le reste sous vide",
|
||||
"moved_simple": "📦 Déplacé vers {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Non transformé",
|
||||
@@ -1384,7 +1499,17 @@
|
||||
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
|
||||
"retry": "Réessayer",
|
||||
"syncing_local": "Synchronisation des données locales...",
|
||||
"sync_done": "Données locales synchronisées"
|
||||
"sync_done": "Données locales synchronisées",
|
||||
"token_required": "Jeton API requis",
|
||||
"token_autoconfig": "Configuration de l'accès...",
|
||||
"token_prompt_title": "🔒 Jeton API",
|
||||
"token_prompt_hint": "Saisissez la valeur API_TOKEN du fichier .env du serveur.",
|
||||
"token_prompt_btn": "Continuer",
|
||||
"check_db_legacy": "Ancienne BD (dispensa.db)",
|
||||
"check_tts": "URL synthèse vocale",
|
||||
"check_scale": "Passerelle balance",
|
||||
"critical_error_intro": "L'application ne peut pas démarrer en raison des problèmes suivants :",
|
||||
"error_network_detail": "Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez."
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Statistiques Mensuelles",
|
||||
@@ -1397,5 +1522,12 @@
|
||||
"top_used": "le plus utilisé",
|
||||
"top_cats": "Catégories principales",
|
||||
"source": "Historique des transactions · mois en cours"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "à l'instant",
|
||||
"seconds_ago": "il y a {n}s",
|
||||
"minutes_ago": "il y a {n} min",
|
||||
"hours_ago": "il y a {n} h",
|
||||
"days_ago": "il y a {n} j"
|
||||
}
|
||||
}
|
||||
+88
-13
@@ -143,14 +143,22 @@
|
||||
"banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
|
||||
"banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.",
|
||||
"banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.",
|
||||
"banner_finished_vanished": "Il prodotto non compare più in inventario, ma i movimenti registrati dicono che non dovrebbe essere finito.",
|
||||
"banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
|
||||
"banner_finished_check": "Puoi controllare?",
|
||||
"banner_finished_action_restore": "Ripristina {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "hai più scorte del previsto",
|
||||
"banner_anomaly_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?",
|
||||
"banner_anomaly_untracked_title": "scorte non registrate come entrata",
|
||||
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
||||
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
||||
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
||||
"banner_dup_loss_title": "Controllo doppio scarico: {name}",
|
||||
"banner_dup_loss_detail": "Possibile doppia registrazione in {location}: due uscite ravvicinate ({qty_pair}) in ~{seconds}s. Verifica se va corretta.",
|
||||
"banner_dup_loss_action_fix": "Correggi quantità",
|
||||
"banner_dup_loss_action_open": "Apri scheda prodotto",
|
||||
"banner_dup_loss_action_done": "Già verificato",
|
||||
"banner_dup_loss_toast_done": "Controllo segnato come verificato",
|
||||
"consumed": "Consumati: {n} ({pct}%)",
|
||||
"wasted": "Buttati: {n} ({pct}%)",
|
||||
"more_opened": "e altri {n} prodotti aperti...",
|
||||
@@ -158,7 +166,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Chiedi a Gemini una spiegazione",
|
||||
"banner_explain_btn": "Spiega",
|
||||
"banner_analyzing": "🤖 Analizzo…"
|
||||
"banner_analyzing": "🤖 Analizzo…",
|
||||
"banner_prediction_confirmed": "✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni",
|
||||
"banner_anomaly_explain_fail": "Impossibile ottenere spiegazione AI",
|
||||
"banner_anomaly_dismissed": "Anomalia ignorata",
|
||||
"banner_finished_restore_prompt": "Quante {unit} di {name} hai ancora? (stima sistema: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Dispensa",
|
||||
@@ -220,7 +232,25 @@
|
||||
"status_partial": "Letto: {code} — verifico...",
|
||||
"status_invalid": "Non valido: {code} — riprovo",
|
||||
"status_confirmed": "Confermato!",
|
||||
"status_parallel": "Doppia scansione attiva..."
|
||||
"status_parallel": "Doppia scansione attiva...",
|
||||
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
|
||||
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
|
||||
"method_ai_ocr": "Gemini OCR",
|
||||
"method_ai_vision": "Gemini Vision",
|
||||
"ai_fallback_searching": "Identificazione AI in corso...",
|
||||
"ai_fallback_found": "Prodotto identificato dall'AI",
|
||||
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
|
||||
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
|
||||
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
|
||||
"ai_retry_btn": "Riprova con AI",
|
||||
"ai_match_title": "Prodotto riconosciuto con AI",
|
||||
"ai_match_subtitle": "Scegli se usare un prodotto gia presente oppure aggiungere quello rilevato.",
|
||||
"ai_match_existing": "Possibili corrispondenze in dispensa",
|
||||
"ai_match_none": "Nessun prodotto simile trovato in dispensa.",
|
||||
"ai_match_use_btn": "Usa questo",
|
||||
"ai_match_add_btn": "Aggiungi \"{name}\"",
|
||||
"ai_detected_label": "AI ha trovato",
|
||||
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
@@ -293,14 +323,17 @@
|
||||
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
|
||||
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
|
||||
"disambiguation_one_conf": "Finita <strong>1 confezione</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Finito TUTTO ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 confezione di {name} terminata!",
|
||||
"error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!",
|
||||
"use_all_confirm_title": "✅ Finisci tutto",
|
||||
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
|
||||
"use_all_confirm_btn": "✅ Sì, finito",
|
||||
"throw_all_confirm_title": "🗑️ Butta tutto",
|
||||
"throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?",
|
||||
"throw_all_confirm_btn": "🗑️ Sì, butta"
|
||||
"throw_all_confirm_btn": "🗑️ Sì, butta",
|
||||
"locations_short": "posti"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuovo Prodotto",
|
||||
@@ -340,7 +373,9 @@
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origine",
|
||||
"labels_label": "Etichette",
|
||||
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
|
||||
"select_variant": "Seleziona la variante esatta o usa i dati AI:",
|
||||
"history_badge": "📊 storico",
|
||||
"from_history": " (da storico)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
@@ -393,7 +428,18 @@
|
||||
"load_error": "Errore nel caricamento",
|
||||
"favorite": "Aggiungi ai preferiti",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"adjust_persons": "Persone"
|
||||
"adjust_persons": "Persone",
|
||||
"nutrition_title": "Valori nutrizionali (per porzione)",
|
||||
"nutrition_kcal": "Calorie",
|
||||
"nutrition_protein": "Proteine",
|
||||
"nutrition_carbs": "Carboidrati",
|
||||
"nutrition_fat": "Grassi",
|
||||
"nutrition_per_serving": "Valori stimati per porzione",
|
||||
"storage_title": "Come conservare gli avanzi",
|
||||
"storage_days": "{n} giorni",
|
||||
"storage_immediately": "Da consumare subito",
|
||||
"ing_stock_line": "Hai {have} · restano {remain} dopo l'uso",
|
||||
"ing_use_all_note": "uso totale (<5% della confezione intera)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
@@ -480,6 +526,7 @@
|
||||
"remove_error": "Errore nella rimozione",
|
||||
"btn_fetch_prices": "Cerca i prezzi",
|
||||
"price_total_label": "💰 Spesa stimata:",
|
||||
"price_total_short": "spesa stimata",
|
||||
"price_loading": "Ricerca prezzi…",
|
||||
"price_not_found": "prezzo n/d",
|
||||
"suggest_loading": "Analisi in corso...",
|
||||
@@ -489,7 +536,8 @@
|
||||
"priority_low": "Bassa",
|
||||
"smart_last_update": "Aggiornato {time}",
|
||||
"names_already_updated": "Tutti i nomi sono già aggiornati",
|
||||
"pantry_hint": "Hai gia {qty} in dispensa"
|
||||
"pantry_hint": "Hai gia {qty} in dispensa",
|
||||
"bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identificazione AI",
|
||||
@@ -500,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
|
||||
"fields_filled": "✅ Campi compilati dall'AI",
|
||||
"use_data": "✅ Usa dati AI",
|
||||
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)"
|
||||
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)",
|
||||
"conservation_hint": "🤖 AI: conserva in {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Storico",
|
||||
@@ -664,7 +713,9 @@
|
||||
"back": "📱 Posteriore (default)",
|
||||
"front": "🤳 Anteriore",
|
||||
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
||||
"detect_btn": "🔄 Rileva fotocamere"
|
||||
"detect_btn": "🔄 Rileva fotocamere",
|
||||
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
|
||||
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato."
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificato HTTPS",
|
||||
@@ -753,7 +804,13 @@
|
||||
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
|
||||
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
|
||||
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — heuristica automatica su 100+ modelli</li></ul>"
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — heuristica automatica su 100+ modelli</li></ul>",
|
||||
"discover_scanning": "🔍 Scansione rete locale per gateway bilancia…",
|
||||
"discover_found": "✅ Gateway trovato: {url}{more}",
|
||||
"discover_not_found": "❌ Nessun gateway su {subnet}. Avvia l'app Android sulla stessa Wi-Fi.",
|
||||
"discover_failed": "❌ Ricerca fallita: {error}",
|
||||
"discover_auto": "🔍 Auto",
|
||||
"unknown_device": "Dispositivo sconosciuto"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
||||
@@ -940,7 +997,8 @@
|
||||
"sensor_copied": "YAML copiato negli appunti!",
|
||||
"save_btn": "Salva impostazioni HA",
|
||||
"ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori."
|
||||
}
|
||||
},
|
||||
"kiosk_update_required": "⚠️ Aggiorna il kiosk per usare questa funzione"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "OGGI",
|
||||
@@ -1014,6 +1072,7 @@
|
||||
"finished_all": "📤 {name} terminato!",
|
||||
"vacuum_sealed": "{name} salvato come sottovuoto",
|
||||
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
||||
"ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario",
|
||||
"appliance_added": "Elettrodomestico aggiunto",
|
||||
"item_added": "{name} aggiunto"
|
||||
},
|
||||
@@ -1063,6 +1122,7 @@
|
||||
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
||||
"barcode_empty": "Inserisci un codice a barre",
|
||||
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
||||
"barcode_checksum": "Checksum EAN non valido — verifica le cifre del codice",
|
||||
"min_chars": "Scrivi almeno 2 caratteri",
|
||||
"not_in_inventory": "Prodotto non nell'inventario",
|
||||
"appliance_exists": "Elettrodomestico già presente",
|
||||
@@ -1084,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} operazioni in attesa",
|
||||
"offline_synced": "{n} operazioni sincronizzate",
|
||||
"offline_ai_disabled": "Non disponibile offline",
|
||||
"offline_cache_ready": "Offline — {n} prodotti in cache"
|
||||
"offline_cache_ready": "Offline — {n} prodotti in cache",
|
||||
"copy_failed": "Copia negli appunti non riuscita",
|
||||
"invalid_quantity": "Quantità non valida"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
@@ -1205,7 +1267,8 @@
|
||||
"stay_btn": "No, resta in {location}",
|
||||
"moved_toast": "📦 Confezione aperta spostata in {location}",
|
||||
"vacuum_restore": "Torna sotto vuoto",
|
||||
"vacuum_seal_rest": "Metti sotto vuoto il resto"
|
||||
"vacuum_seal_rest": "Metti sotto vuoto il resto",
|
||||
"moved_simple": "📦 Spostato in {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Non trasformato",
|
||||
@@ -1440,7 +1503,12 @@
|
||||
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
|
||||
"retry": "Riprova",
|
||||
"syncing_local": "Sincronizzazione dati locali...",
|
||||
"sync_done": "Dati locali aggiornati"
|
||||
"sync_done": "Dati locali aggiornati",
|
||||
"token_required": "Token API richiesto",
|
||||
"token_autoconfig": "Configurazione accesso...",
|
||||
"token_prompt_title": "🔒 Token API",
|
||||
"token_prompt_hint": "Inserisci il valore API_TOKEN dal file .env del server.",
|
||||
"token_prompt_btn": "Continua"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Statistiche Mensili",
|
||||
@@ -1453,5 +1521,12 @@
|
||||
"top_used": "più usato",
|
||||
"top_cats": "Categorie principali",
|
||||
"source": "Storico transazioni · mese corrente"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "adesso",
|
||||
"seconds_ago": "{n}s fa",
|
||||
"minutes_ago": "{n} min fa",
|
||||
"hours_ago": "{n} h fa",
|
||||
"days_ago": "{n} gg fa"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user