Compare commits

..

1 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 00:23:13 +00:00
40 changed files with 1450 additions and 4173 deletions
+4 -18
View File
@@ -125,24 +125,10 @@ GDRIVE_FOLDER_ID=
GDRIVE_RETENTION_DAYS=30
# ── Security ─────────────────────────────────────────────────────────────────
# 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: if set, the Settings screen requires this token to save changes.
# Leave empty to allow anyone with access to the server to change settings.
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.
@@ -174,5 +160,5 @@ HA_EXPIRY_DAYS=3
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
CRON_LOG_MAX_BYTES=524288
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
# To rotate it, update the GH_ISSUE_TOKEN constant there.
+1 -1
View File
@@ -206,7 +206,7 @@ jobs:
- name: Create release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.version }}
name: "EverShelf ${{ steps.version.outputs.version }}"
-14
View File
@@ -1,19 +1,5 @@
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]
-40
View File
@@ -11,46 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.38] - 2026-06-04
### Fixed
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
### Changed
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
## [1.7.37] - 2026-06-04
### Fixed
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
### Changed
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
## [1.7.36] - 2026-06-04
### Added
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
### Fixed
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
## [1.7.35] - 2026-06-02
### Fixed
+12 -13
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.38-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.33-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
@@ -86,7 +86,6 @@ 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
@@ -237,7 +236,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=90 # delete stock transactions older than N days (min 30 enforced)
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
# Optional: Vacuum-sealed expiry grace period
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
@@ -248,11 +247,8 @@ GEMINI_COST_25F_OUT=0.60
GEMINI_COST_20F_IN=0.10
GEMINI_COST_20F_OUT=0.40
# 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)
# Optional: Security — protect the save_settings endpoint
# Set a strong random string; the Settings UI will ask for it before saving
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
@@ -420,11 +416,8 @@ 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
- **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`
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
@@ -479,6 +472,12 @@ 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.
-11
View File
@@ -1,11 +0,0 @@
<?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';
+2 -4
View File
@@ -11,16 +11,14 @@ if (PHP_SAPI !== 'cli') {
exit('Forbidden');
}
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
// Define CRON_MODE before loading index.php so the router is skipped
define('CRON_MODE', true);
require_once __DIR__ . '/bootstrap.php';
// Load all API functions without running the HTTP router
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
try {
$db = getDB();
+1017 -1273
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
<?php
/**
* EverShelf — shared path constants.
*/
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
-28
View File
@@ -1,28 +0,0 @@
<?php
/**
* Rotate data/cron.log — keep last N MB / lines.
*/
require_once __DIR__ . '/constants.php';
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
$path = CRON_LOG_PATH;
if (!file_exists($path)) {
return;
}
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
$size = filesize($path);
if ($size === false || $size <= $maxBytes) {
return;
}
for ($i = $keepRotated; $i >= 1; $i--) {
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
$to = $path . '.' . $i;
if ($i === $keepRotated && file_exists($to)) {
@unlink($to);
}
if (file_exists($from)) {
@rename($from, $to);
}
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
/**
* EverShelf — environment variable loader (.env).
*/
function loadEnv(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$envFile = dirname(__DIR__, 2) . '/.env';
$cache = [];
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$key, $val] = explode('=', $line, 2);
$cache[trim($key)] = trim($val);
}
}
return $cache;
}
function env(string $key, string $default = ''): string {
$vars = loadEnv();
return $vars[$key] ?? $default;
}
/** Push a single key into the in-memory env cache (after .env write). */
function envCacheSet(string $key, string $value): void {
loadEnv();
// Force reload on next call — callers should use loadEnv() return for batch updates
}
-69
View File
@@ -1,69 +0,0 @@
<?php
/**
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
*
* Configure ONE of:
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
*
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
*/
require_once __DIR__ . '/env.php';
function evershelfDecryptGhToken(string $encB64, string $key): string {
$raw = base64_decode($encB64, true);
if ($raw === false || strlen($raw) < 28) {
return '';
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt(
$cipher,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return ($plain !== false) ? $plain : '';
}
function evershelfEncryptGhToken(string $plain, string $key): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(
$plain,
'aes-256-gcm',
hash('sha256', $key, true),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return base64_encode($iv . $tag . $cipher);
}
/** Decode GitHub Issues token at runtime — never stored in source code. */
function _ghToken(): string {
static $token = null;
if ($token !== null) {
return $token;
}
$plain = env('GH_ISSUE_TOKEN');
if ($plain !== '') {
$token = $plain;
return $token;
}
$enc = env('GH_ISSUE_TOKEN_ENC');
$key = env('GH_ISSUE_TOKEN_KEY');
if ($enc !== '' && $key !== '') {
$token = evershelfDecryptGhToken($enc, $key);
return $token;
}
$token = '';
return $token;
}
-293
View File
@@ -1,293 +0,0 @@
<?php
/**
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
*/
require_once __DIR__ . '/env.php';
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
function evershelfEffectiveApiToken(): string {
$api = env('API_TOKEN');
if ($api !== '') {
return $api;
}
return env('SETTINGS_TOKEN', '');
}
function evershelfApiTokenRequired(): bool {
return evershelfEffectiveApiToken() !== '';
}
function evershelfGetProvidedApiToken(): string {
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
return (string)$_SERVER['HTTP_X_API_TOKEN'];
}
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
}
if (isset($_GET['api_token'])) {
return (string)$_GET['api_token'];
}
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
return $m[1];
}
return evershelfGetProvidedApiTokenFromHeaders();
}
function evershelfApiTokenValid(): bool {
$required = evershelfEffectiveApiToken();
if ($required === '') {
return true;
}
$provided = evershelfGetProvidedApiToken();
return $provided !== '' && hash_equals($required, $provided);
}
function evershelfGetProvidedApiTokenFromHeaders(): string {
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
}
/** Actions reachable without API token (telemetry + public probes). */
function evershelfPublicActions(): array {
return [
'ping',
'app_bootstrap',
'check_update',
'report_error',
'report_bug',
'client_log',
'gdrive_oauth_callback',
];
}
/** GET actions that mutate state — require auth when token is configured. */
function evershelfMutatingGetActions(): array {
return ['db_cleanup', 'export_inventory'];
}
function evershelfDestructiveActions(): array {
return [
'save_settings', 'db_cleanup',
'backup_now', 'backup_delete', 'backup_restore',
'gdrive_push', 'gdrive_oauth_exchange',
'migrate_units',
];
}
function evershelfActionNeedsAuth(string $action, string $method): bool {
if (!evershelfApiTokenRequired()) {
return false;
}
if (in_array($action, evershelfPublicActions(), true)) {
return false;
}
if ($method === 'POST') {
return true;
}
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
return true;
}
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
return true;
}
if (in_array($action, evershelfDestructiveActions(), true)) {
return true;
}
// Protect all data reads when API token is set
return true;
}
function evershelfRequireApiAuth(string $action, string $method): void {
if (!evershelfActionNeedsAuth($action, $method)) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => 'unauthorized',
'api_token_required' => true,
]);
exit;
}
function evershelfRequireAuthForSensitive(string $action): void {
if (!evershelfApiTokenRequired()) {
return;
}
if (evershelfApiTokenValid()) {
return;
}
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
function evershelfSendCorsHeaders(): void {
$configured = env('CORS_ORIGIN', '');
if ($configured === '') {
// Same-origin SPA — do not emit wildcard CORS
return;
}
if ($configured === '*') {
header('Access-Control-Allow-Origin: *');
} else {
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_filter(array_map('trim', explode(',', $configured)));
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $reqOrigin);
header('Vary: Origin');
}
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
}
/** Read-only actions allowed in DEMO_MODE. */
function evershelfDemoReadOnlyActions(): array {
return [
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
'search_barcode', 'lookup_barcode', '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;
}
+50 -55
View File
@@ -1,53 +1,57 @@
<?php
/**
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
* EverShelf Scale Gateway — Auto-discovery
*
* Scans the server's local /24 subnet for any host responding on the gateway
* port (default 8765) and confirms it with a WebSocket handshake.
*
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('Cache-Control: no-cache');
evershelfSendCorsHeaders();
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
exit;
}
// Simple rate limit: max 6 scans per minute per IP
$rlDir = dirname(__DIR__) . '/data/rate_limits';
if (!is_dir($rlDir)) {
@mkdir($rlDir, 0755, true);
}
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
$now = time();
$hits = [];
if (file_exists($rlFile)) {
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
}
if (count($hits) >= 6) {
http_response_code(429);
echo json_encode(['error' => 'Too many discovery scans']);
exit;
}
$hits[] = $now;
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) {
$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 '';
}
$serverIp = evershelfLocalLanIp();
$serverIp = localLanIp();
$parts = explode('.', $serverIp);
if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
exit;
}
$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;
@@ -70,28 +74,25 @@ 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]);
@@ -99,16 +100,13 @@ while (!empty($candidates) && microtime(true) < $deadline) {
}
}
}
foreach ($candidates as $s) {
@fclose($s);
}
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
// ── 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));
@@ -126,13 +124,9 @@ 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);
@@ -144,4 +138,5 @@ foreach ($found_tcp as $ip) {
echo json_encode([
'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]);
+7 -16
View File
@@ -1,20 +1,16 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
require_once __DIR__ . '/lib/env.php';
require_once __DIR__ . '/lib/security.php';
header('Content-Type: application/json');
header('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)) {
@@ -23,7 +19,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$host = strtolower($parsed['host'] ?? '');
$host = $parsed['host'] ?? '';
$port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/';
@@ -32,11 +28,6 @@ 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) {
+1 -17
View File
@@ -8,16 +8,6 @@
* 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'] ?? '';
@@ -29,7 +19,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
}
$parsed = parse_url($rawUrl);
$wsHost = strtolower($parsed['host'] ?? '');
$wsHost = $parsed['host'] ?? '';
$wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/';
@@ -39,12 +29,6 @@ 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');
-287
View File
@@ -2009,59 +2009,6 @@ body.server-offline .bottom-nav {
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
.scan-status-msg.state-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;
@@ -2112,118 +2059,6 @@ 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;
@@ -3098,14 +2933,6 @@ body.server-offline .bottom-nav {
font-style: italic;
}
.shopping-item-specific {
font-size: 0.73rem;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.3;
font-style: italic;
}
.smart-brand {
font-weight: 400;
color: var(--text-muted);
@@ -4468,93 +4295,6 @@ 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;
@@ -4679,13 +4419,6 @@ 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;
@@ -6206,12 +5939,6 @@ 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;
@@ -8111,8 +7838,6 @@ 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; }
@@ -8183,18 +7908,6 @@ 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; }
+234 -727
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
/**
* EverShelf core API token storage and auth headers.
*/
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
function getApiToken() {
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
}
function setApiToken(token) {
const t = (token || '').trim();
if (t) {
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
} else {
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
}
}
function apiAuthHeaders() {
const fromStorage = getApiToken();
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
const token = fromSettingsField || fromStorage;
if (!token) return {};
return { 'X-API-Token': token };
}
/** Fetch API token from server when loading the UI from the same origin. */
async function ensureApiToken() {
if (getApiToken()) return true;
try {
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
if (!res.ok) return false;
const data = await res.json();
window._apiTokenRequired = !!data.api_token_required;
if (data.api_token) {
setApiToken(data.api_token);
return true;
}
} catch (_) { /* offline / network */ }
return !!getApiToken();
}
function _promptApiTokenIfNeeded() {
if (!window._apiTokenRequired) return;
if (getApiToken()) return;
const existing = document.getElementById('api-token-overlay');
if (existing) return;
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
const overlay = document.createElement('div');
overlay.id = 'api-token-overlay';
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal-content" style="max-width:420px;padding:20px">
<h3>${title}</h3>
<p class="settings-hint">${hint}</p>
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('api-token-save').onclick = () => {
const v = document.getElementById('api-token-input').value.trim();
if (v) {
setApiToken(v);
overlay.remove();
location.reload();
}
};
}
window.getApiToken = getApiToken;
window.setApiToken = setApiToken;
window.apiAuthHeaders = apiAuthHeaders;
window.ensureApiToken = ensureApiToken;
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
-11
View File
@@ -1,11 +0,0 @@
/**
* EverShelf core safe HTML escaping (loaded before app.js).
*/
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
File diff suppressed because one or more lines are too long
+8 -14
View File
@@ -1,19 +1,13 @@
#!/bin/bash
# Daily backup of EverShelf database (local only)
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
# The database is NOT pushed to remote repositories.
# Runs via cron: creates a local timestamped backup copy
#
# Example crontab entry:
# 0 3 * * * /var/www/html/evershelf/backup.sh
set -euo pipefail
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKUP_DIR="${INSTALL_DIR}/data/backups"
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"
@@ -25,5 +19,5 @@ fi
DATE=$(date '+%Y-%m-%d_%H%M')
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
# Keep only the newest N backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
# Keep only the last 7 backups
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
-2
View File
@@ -1,2 +0,0 @@
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
Require all denied
-38
View File
@@ -1,38 +0,0 @@
# EverShelf — Architecture (modular layout)
```
dispensa/
├── api/
│ ├── bootstrap.php # Shared init: env, security, DB, logger
│ ├── index.php # HTTP handlers + router (split planned per domain)
│ ├── database.php # SQLite schema & migrations
│ ├── logger.php # Rotating file logger (logs/)
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
│ ├── lib/
│ │ ├── env.php # .env loader
│ │ ├── constants.php # Paths & pricing constants
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
│ │ ├── github.php # Encrypted GitHub Issues token
│ │ └── cron_log.php # data/cron.log rotation
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
├── assets/
│ ├── js/
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
│ │ └── app.js # SPA logic (domain modules: future split)
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
├── data/ # Runtime data (.htaccess: deny all)
├── logs/ # Application logs (.htaccess: deny all)
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
```
## Security model
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
## Planned refactors
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
@@ -101,20 +101,6 @@ 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
@@ -164,18 +150,18 @@ class KioskActivity : AppCompatActivity() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
runOnUiThread {
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
}
}
@Deprecated("Deprecated in API 21")
override fun onError(utteranceId: String?) {
runOnUiThread {
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
runOnUiThread {
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
}
}
})
+13 -21
View File
@@ -11,13 +11,9 @@
<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=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>
<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>
<!-- @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.
@@ -29,15 +25,11 @@
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
window._categoryPipelinePromise = (async () => {
try {
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;
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.
env.allowRemoteModels = true;
env.useBrowserCache = true;
const pipe = await pipeline(
@@ -72,7 +64,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.38</span>
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
</div>
</div>
@@ -85,7 +77,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.38</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.35</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -1221,10 +1213,10 @@
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<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="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder">
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token API (API_TOKEN nel file .env). Il token viene salvato nel browser.</p>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
</div>
<div class="settings-card">
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
@@ -1970,6 +1962,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260604f"></script>
<script src="assets/js/app.js?v=20260518c"></script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
Require all denied
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.38",
"version": "1.7.35",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
-9
View File
@@ -1,9 +0,0 @@
{
"name": "evershelf",
"private": true,
"scripts": {
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
"build": "npm run build:js && npm run build:css"
}
}
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
*
* Usage:
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
*/
if ($argc < 3) {
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
exit(1);
}
require_once __DIR__ . '/../api/lib/github.php';
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
# Fix ownership and permissions for EverShelf runtime directories.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WEB_USER="${WEB_USER:-www-data}"
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
chmod 750 "${ROOT}/data" "${ROOT}/logs"
chmod 640 "${ROOT}/.env" 2>/dev/null || true
find "${ROOT}/data" -type f -exec chmod 660 {} \;
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
echo "Permissions updated for ${WEB_USER}"
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env php
<?php
/**
* One-time merge of duplicate product records (same normalized name + compatible brand).
* Opened-package splits remain as separate inventory rows on the canonical product.
*
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
*/
declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv, true);
$dbPath = __DIR__ . '/../data/evershelf.db';
if (!file_exists($dbPath)) {
fwrite(STDERR, "Database not found: $dbPath\n");
exit(1);
}
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
function normName(string $name): string {
return mb_strtolower(trim($name));
}
function normBrand(string $brand): string {
return mb_strtolower(trim($brand));
}
function brandsCompatible(string $a, string $b): bool {
$na = normBrand($a);
$nb = normBrand($b);
return $na === $nb || $na === '' || $nb === '';
}
function productScore(PDO $db, int $id): float {
$tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn();
$inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn();
return $tx * 10 + $inv;
}
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
$db->beginTransaction();
try {
$db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
$db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
$db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$byName = [];
foreach ($products as $p) {
$key = normName($p['name']);
if ($key === '') {
continue;
}
$byName[$key][] = $p;
}
$merged = 0;
foreach ($byName as $nameKey => $group) {
if (count($group) < 2) {
continue;
}
// Split into compatible-brand clusters
$clusters = [];
foreach ($group as $p) {
$placed = false;
foreach ($clusters as &$cluster) {
$ref = $cluster[0];
if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) {
$cluster[] = $p;
$placed = true;
break;
}
}
unset($cluster);
if (!$placed) {
$clusters[] = [$p];
}
}
foreach ($clusters as $cluster) {
if (count($cluster) < 2) {
continue;
}
usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id']));
$keep = (int)$cluster[0]['id'];
$keepName = $cluster[0]['name'];
for ($i = 1; $i < count($cluster); $i++) {
$drop = (int)$cluster[$i]['id'];
echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n";
if (!$dryRun) {
mergeProducts($db, $keep, $drop);
}
$merged++;
}
}
}
echo $dryRun
? "Dry run: $merged merge(s) would be performed.\n"
: "Done: $merged duplicate product(s) merged.\n";
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env php
<?php
/**
* One-time security migration: GitHub token encrypted .env, optional API_TOKEN.
*/
require_once __DIR__ . '/../api/lib/env.php';
require_once __DIR__ . '/../api/lib/github.php';
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
fwrite(STDERR, ".env not found\n");
exit(1);
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
$vars = loadEnv();
$changed = false;
// Migrate legacy XOR token from previous index.php if still in git history
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
$legacyKey = 'D1sp3ns4!Ev3r#26';
$encBin = hex2bin($legacyEnc);
$plain = '';
if ($encBin) {
for ($i = 0; $i < strlen($encBin); $i++) {
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
}
}
if ($plain !== '' && str_starts_with($plain, 'github_')) {
$newKey = bin2hex(random_bytes(16));
$enc = evershelfEncryptGhToken($plain, $newKey);
$lines[] = '';
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
$changed = true;
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
}
}
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
$token = bin2hex(random_bytes(24));
$lines[] = '';
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
$lines[] = 'API_TOKEN=' . $token;
$changed = true;
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
}
if ($changed) {
file_put_contents($envFile, implode("\n", $lines) . "\n");
chmod($envFile, 0640);
echo "Updated .env\n";
} else {
echo "No migration needed\n";
}
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Re-apply stock hints and 5% use-all rule to an archived recipe.
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
*/
define('CRON_MODE', true);
require __DIR__ . '/../api/index.php';
$id = (int)($argv[1] ?? 0);
if ($id <= 0) {
fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php <recipe_id>\n");
exit(1);
}
$db = getDB();
$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
fwrite(STDERR, "Recipe {$id} not found\n");
exit(1);
}
$recipe = json_decode($row['recipe_json'], true);
if (!is_array($recipe)) {
fwrite(STDERR, "Invalid recipe JSON for id {$id}\n");
exit(1);
}
$stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC, p.name ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
recipeApplyStockHintsToRecipe($db, $recipe);
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
$upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
foreach ($recipe['ingredients'] ?? [] as $ing) {
if (empty($ing['from_pantry'])) {
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
continue;
}
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
echo sprintf(
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
$ing['name'] ?? '?',
$ing['qty'] ?? '?',
$ing['stock_have'] ?? 0,
$ing['stock_unit'] ?? '',
$ing['stock_remain'] ?? 0,
$ing['stock_unit'] ?? '',
$useAll
);
}
-341
View File
@@ -1,341 +0,0 @@
#!/usr/bin/env python3
"""Sync translation files: ensure all locales have the same keys as it.json (reference)."""
from __future__ import annotations
import copy
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent / 'translations'
REF = 'it.json'
LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json']
# New keys added across all locales (nested path -> value per locale)
NEW_KEYS: dict[str, dict[str, str]] = {
'dashboard.banner_prediction_confirmed': {
'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni',
'en': '✅ Confirmed — forecasts will recalculate from your next entries',
'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet',
'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements',
'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros',
},
'dashboard.banner_anomaly_explain_fail': {
'it': 'Impossibile ottenere spiegazione AI',
'en': 'Could not get AI explanation',
'de': 'KI-Erklärung konnte nicht abgerufen werden',
'fr': 'Impossible d\'obtenir l\'explication IA',
'es': 'No se pudo obtener la explicación de IA',
},
'dashboard.banner_anomaly_dismissed': {
'it': 'Anomalia ignorata',
'en': 'Anomaly dismissed',
'de': 'Anomalie ignoriert',
'fr': 'Anomalie ignorée',
'es': 'Anomalía descartada',
},
'error.copy_failed': {
'it': 'Copia negli appunti non riuscita',
'en': 'Copy to clipboard failed',
'de': 'Kopieren in die Zwischenablage fehlgeschlagen',
'fr': 'Échec de la copie dans le presse-papiers',
'es': 'Error al copiar al portapapeles',
},
'error.invalid_quantity': {
'it': 'Quantità non valida',
'en': 'Invalid quantity',
'de': 'Ungültige Menge',
'fr': 'Quantité invalide',
'es': 'Cantidad no válida',
},
'dashboard.banner_finished_restore_prompt': {
'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})',
'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})',
'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})',
'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})',
'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})',
},
'time.just_now': {
'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora',
},
'time.seconds_ago': {
'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s',
},
'time.minutes_ago': {
'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min',
},
'time.hours_ago': {
'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h',
},
'time.days_ago': {
'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d',
},
'use.locations_short': {
'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones',
},
'move.moved_simple': {
'it': '📦 Spostato in {location}',
'en': '📦 Moved to {location}',
'de': '📦 Nach {location} verschoben',
'fr': '📦 Déplacé vers {location}',
'es': '📦 Movido a {location}',
},
'product.history_badge': {
'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial',
},
'ai.conservation_hint': {
'it': '🤖 AI: conserva in {location}',
'en': '🤖 AI: store in {location}',
'de': '🤖 KI: lagere in {location}',
'fr': '🤖 IA : conserve dans {location}',
'es': '🤖 IA: conserva en {location}',
},
'settings.kiosk_update_required': {
'it': '⚠️ Aggiorna il kiosk per usare questa funzione',
'en': '⚠️ Update the kiosk app to use this feature',
'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen',
'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction',
'es': '⚠️ Actualiza la app kiosk para usar esta función',
},
'shopping.bring_names_migrated': {
'it': '🔄 {n} nomi generalizzati in Bring!',
'en': '🔄 {n} names generalized in Bring!',
'de': '🔄 {n} Namen in Bring! verallgemeinert',
'fr': '🔄 {n} noms généralisés dans Bring !',
'es': '🔄 {n} nombres generalizados en Bring!',
},
'scan.mode_shopping_activated': {
'it': '🛒 Modalità Spesa attivata!',
'en': '🛒 Shopping mode activated!',
'de': '🛒 Einkaufsmodus aktiviert!',
'fr': '🛒 Mode courses activé !',
'es': '🛒 ¡Modo compras activado!',
},
'settings.scale.discover_scanning': {
'it': '🔍 Scansione rete locale per gateway bilancia…',
'en': '🔍 Scanning local network for scale gateway…',
'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…',
'fr': '🔍 Recherche du gateway balance sur le réseau local…',
'es': '🔍 Buscando pasarela de báscula en la red local…',
},
'settings.scale.discover_found': {
'it': '✅ Gateway trovato: {url}{more}',
'en': '✅ Gateway found: {url}{more}',
'de': '✅ Gateway gefunden: {url}{more}',
'fr': '✅ Gateway trouvé : {url}{more}',
'es': '✅ Pasarela encontrada: {url}{more}',
},
'settings.scale.discover_not_found': {
'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.',
'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.',
'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.',
'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.',
'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.',
},
'settings.scale.discover_failed': {
'it': '❌ Ricerca fallita: {error}',
'en': '❌ Discovery failed: {error}',
'de': '❌ Suche fehlgeschlagen: {error}',
'fr': '❌ Échec de la recherche : {error}',
'es': '❌ Búsqueda fallida: {error}',
},
'settings.scale.discover_auto': {
'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto',
},
'settings.scale.unknown_device': {
'it': 'Dispositivo sconosciuto',
'en': 'Unknown device',
'de': 'Unbekanntes Gerät',
'fr': 'Appareil inconnu',
'es': 'Dispositivo desconocido',
},
'product.from_history': {
'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)',
},
'recipes.ing_stock_line': {
'it': 'Hai {have} · restano {remain} dopo l\'uso',
'en': 'You have {have} · {remain} left after use',
'de': 'Du hast {have} · {remain} bleiben nach Gebrauch',
'fr': 'Vous avez {have} · il reste {remain} après usage',
'es': 'Tienes {have} · quedan {remain} después del uso',
},
'recipes.ing_use_all_note': {
'it': 'uso totale (<5% della confezione intera)',
'en': 'use all (<5% of full package left)',
'de': 'alles verwenden (<5% der Vollpackung)',
'fr': 'tout utiliser (<5% du conditionnement entier)',
'es': 'usar todo (<5% del envase completo)',
},
}
# fr/es gaps filled with proper translations (flat key -> value)
FR_FILL: dict[str, str] = {
'action.related_stock_title': 'Aussi à la maison',
'dashboard.banner_expired_action_modify': 'Modifier',
'dashboard.banner_expired_action_vacuum': 'Mettre sous vide',
'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.',
'scan.stock_in_pantry': 'Déjà à la maison :',
'scanner.expiry_found': 'Date trouvée',
'scanner.expiry_raw_label': 'Lu',
'scanner.expiry_read_fail': 'Impossible de lire la date.',
'settings.info.act_new_products': 'Nouveaux produits',
'settings.info.act_restock': 'Réapprovisionnements',
'settings.info.act_title': 'Activité mensuelle',
'settings.info.act_tx_month': 'Mouvements',
'settings.info.act_tx_year': 'Mouvements annuels',
'settings.info.act_use': 'Utilisations',
'settings.info.ai_calls': 'Appels',
'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.',
'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système',
'settings.info.ai_title': 'Gemini AI — Utilisation des tokens',
'settings.info.bring_days': 'jeton expire dans {n} jours',
'settings.info.bring_expired': 'jeton expiré',
'settings.info.by_action': 'Répartition par fonction',
'settings.info.by_model': 'Répartition par modèle',
'settings.info.cache_entries': 'produits',
'settings.info.calls_unit': 'appels',
'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.',
'settings.info.currency_title': 'Devise',
'settings.info.db_size': 'Base de données',
'settings.info.est_cost': 'Coût est.',
'settings.info.input_tok': 'Tokens entrée',
'settings.info.inv_active': 'Actifs',
'settings.info.inv_expired': 'Expirés',
'settings.info.inv_expiring': 'Expirent (7j)',
'settings.info.inv_finished': 'Terminés',
'settings.info.inv_products': 'Produits totaux',
'settings.info.inv_title': 'Inventaire',
'settings.info.last_backup': 'Dernière sauvegarde',
'settings.info.loading': 'Chargement…',
'settings.info.log_level': 'Niveau de log',
'settings.info.log_size': 'Logs',
'settings.info.output_tok': 'Tokens sortie',
'settings.info.price_cache': 'Cache prix',
'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
'settings.info.system_title': 'Système',
'settings.info.tab': 'Info',
'settings.info.total_tokens': 'Tokens totaux',
'settings.info.year_label': 'Année {year}',
'settings.tab_general': 'Général',
'settings.tts.test_sound_btn': '🔔 Test sonore',
'shopping.pantry_hint': 'Déjà à la maison : {qty}',
'startup.check_db_legacy': 'Ancienne BD (dispensa.db)',
'startup.check_scale': 'Passerelle balance',
'startup.check_tts': 'URL synthèse vocale',
'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :',
'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.',
'toast.vacuum_sealed': '{name} enregistré sous vide',
}
ES_FILL = {
'action.related_stock_title': 'También en casa',
'dashboard.banner_expired_action_modify': 'Editar',
'dashboard.banner_expired_action_vacuum': 'Poner al vacío',
'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.',
'scan.stock_in_pantry': 'Ya en despensa:',
'scanner.expiry_found': 'Fecha encontrada',
'scanner.expiry_raw_label': 'Leído',
'scanner.expiry_read_fail': 'No se puede leer la fecha.',
'settings.info.act_new_products': 'Productos nuevos',
'settings.info.act_restock': 'Reabastecimientos',
'settings.info.act_title': 'Actividad mensual',
'settings.info.act_tx_month': 'Movimientos',
'settings.info.act_tx_year': 'Movimientos anuales',
'settings.info.act_use': 'Usos',
'settings.info.ai_calls': 'Llamadas',
'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.',
'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema',
'settings.info.ai_title': 'Gemini AI — Uso de tokens',
'settings.info.bring_days': 'token expira en {n} días',
'settings.info.bring_expired': 'token expirado',
'settings.info.by_action': 'Desglose por función',
'settings.info.by_model': 'Desglose por modelo',
'settings.info.cache_entries': 'productos',
'settings.info.calls_unit': 'llamadas',
'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.',
'settings.info.currency_title': 'Moneda',
'settings.info.db_size': 'Base de datos',
'settings.info.est_cost': 'Coste est.',
'settings.info.input_tok': 'Tokens de entrada',
'settings.info.inv_active': 'Activos',
'settings.info.inv_expired': 'Caducados',
'settings.info.inv_expiring': 'Caducan (7d)',
'settings.info.inv_finished': 'Agotados',
'settings.info.inv_products': 'Productos totales',
'settings.info.inv_title': 'Inventario',
'settings.info.last_backup': 'Última copia',
'settings.info.loading': 'Cargando…',
'settings.info.log_level': 'Nivel de log',
'settings.info.log_size': 'Logs',
'settings.info.output_tok': 'Tokens de salida',
'settings.info.price_cache': 'Caché de precios',
'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
'settings.info.system_title': 'Sistema',
'settings.info.tab': 'Info',
'settings.info.total_tokens': 'Tokens totales',
'settings.info.year_label': 'Año {year}',
'settings.tab_general': 'General',
'settings.tts.test_sound_btn': '🔔 Prueba de sonido',
'shopping.pantry_hint': 'Ya en casa: {qty}',
'startup.check_db_legacy': 'BD antigua (dispensa.db)',
'startup.check_scale': 'Pasarela báscula',
'startup.check_tts': 'URL texto a voz',
'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:',
'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.',
'toast.vacuum_sealed': '{name} guardado al vacío',
}
def flatten(obj: dict, prefix: str = '') -> dict[str, str]:
out: dict[str, str] = {}
for k, v in obj.items():
key = f'{prefix}.{k}' if prefix else k
if isinstance(v, dict):
out.update(flatten(v, key))
else:
out[key] = v
return out
def set_nested(root: dict, dotted: str, value: str) -> None:
parts = dotted.split('.')
d = root
for p in parts[:-1]:
d = d.setdefault(p, {})
d[parts[-1]] = value
def main() -> None:
ref = json.loads((ROOT / REF).read_text(encoding='utf-8'))
ref_flat = flatten(ref)
en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8')))
for fname in LOCALES:
lang = fname.replace('.json', '')
path = ROOT / fname
data = json.loads(path.read_text(encoding='utf-8'))
flat = flatten(data)
# Fill missing keys from reference (Italian text as last resort via en)
for key, ref_val in ref_flat.items():
if key not in flat:
if lang == 'fr' and key in FR_FILL:
val = FR_FILL[key]
elif lang == 'es' and key in ES_FILL:
val = ES_FILL[key]
elif lang == 'en':
val = en_flat.get(key, ref_val)
else:
val = en_flat.get(key, ref_val)
set_nested(data, key, val)
flat[key] = val
# Inject new keys
for key, per_lang in NEW_KEYS.items():
set_nested(data, key, per_lang[lang if lang in per_lang else 'en'])
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(f'Updated {fname}')
if __name__ == '__main__':
main()
+12 -61
View File
@@ -143,10 +143,8 @@
"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",
@@ -166,11 +164,7 @@
"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_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})"
"banner_analyzing": "🤖 Analysiere…"
},
"inventory": {
"title": "Vorrat",
@@ -249,8 +243,7 @@
"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!"
"ai_detected_label": "KI erkannt"
},
"action": {
"title": "Was möchtest du tun?",
@@ -323,17 +316,14 @@
"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",
"locations_short": "Orte"
"throw_all_confirm_btn": "🗑️ Ja, entsorgen"
},
"product": {
"title_new": "Neues Produkt",
@@ -373,9 +363,7 @@
"weight_label": "Gewicht",
"origin_label": "Herkunft",
"labels_label": "Etiketten",
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:",
"history_badge": "📊 Verlauf",
"from_history": " (aus Verlauf)"
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
},
"products": {
"title": "📦 Alle Produkte",
@@ -428,18 +416,7 @@
"load_error": "Fehler beim Laden",
"favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen",
"adjust_persons": "Personen",
"nutrition_title": "Nährwerte (pro Portion)",
"nutrition_kcal": "Kalorien",
"nutrition_protein": "Protein",
"nutrition_carbs": "Kohlenhydrate",
"nutrition_fat": "Fett",
"nutrition_per_serving": "Geschätzte Werte pro Portion",
"storage_title": "Aufbewahrung von Resten",
"storage_days": "{n} Tage",
"storage_immediately": "Am besten sofort verzehren",
"ing_stock_line": "Du hast {have} · {remain} bleiben nach Gebrauch",
"ing_use_all_note": "alles verwenden (<5% der Vollpackung)"
"adjust_persons": "Personen"
},
"shopping": {
"title": "🛒 Einkaufsliste",
@@ -526,7 +503,6 @@
"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...",
@@ -536,8 +512,7 @@
"priority_low": "Niedrig",
"smart_last_update": "Aktualisiert {time}",
"names_already_updated": "Alle Namen sind bereits aktuell",
"pantry_hint": "Bereits zuhause: {qty}",
"bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert"
"pantry_hint": "Bereits zuhause: {qty}"
},
"ai": {
"title": "🤖 KI-Identifikation",
@@ -548,8 +523,7 @@
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
"fields_filled": "✅ Felder von KI ausgefüllt",
"use_data": "✅ KI-Daten verwenden",
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)",
"conservation_hint": "🤖 KI: lagere in {location}"
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)"
},
"log": {
"title": "📒 Verlauf",
@@ -804,13 +778,7 @@
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>",
"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"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>"
},
"kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
@@ -997,8 +965,7 @@
"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",
@@ -1072,7 +1039,6 @@
"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"
},
@@ -1144,9 +1110,7 @@
"offline_ops_pending": "{n} Aktionen ausstehend",
"offline_synced": "{n} Aktionen synchronisiert",
"offline_ai_disabled": "Offline nicht verfügbar",
"offline_cache_ready": "Offline — {n} Produkte im Cache",
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
"invalid_quantity": "Ungültige Menge"
"offline_cache_ready": "Offline — {n} Produkte im Cache"
},
"confirm_placeholder_search": null,
"confirm": {
@@ -1268,8 +1232,7 @@
"stay_btn": "Nein, bleibt in {location}",
"moved_toast": "📦 Offene Packung bewegt nach {location}",
"vacuum_restore": "Vakuum wiederherstellen",
"vacuum_seal_rest": "Rest vakuumieren",
"moved_simple": "📦 Nach {location} verschoben"
"vacuum_seal_rest": "Rest vakuumieren"
},
"nova": {
"1": "Unverarbeitet",
@@ -1504,12 +1467,7 @@
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
"retry": "Erneut versuchen",
"syncing_local": "Lokale Daten synchronisieren...",
"sync_done": "Lokale Daten aktualisiert",
"token_required": "API-Token erforderlich",
"token_autoconfig": "Zugriff wird konfiguriert...",
"token_prompt_title": "🔒 API-Token",
"token_prompt_hint": "Geben Sie den API_TOKEN-Wert aus der .env-Datei des Servers ein.",
"token_prompt_btn": "Weiter"
"sync_done": "Lokale Daten aktualisiert"
},
"stats_monthly": {
"title": "Monatsstatistik",
@@ -1522,12 +1480,5 @@
"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"
}
}
+12 -61
View File
@@ -143,10 +143,8 @@
"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",
@@ -166,11 +164,7 @@
"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_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})"
"banner_analyzing": "🤖 Analyzing…"
},
"inventory": {
"title": "Pantry",
@@ -249,8 +243,7 @@
"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!"
"ai_detected_label": "AI detected"
},
"action": {
"title": "What do you want to do?",
@@ -323,17 +316,14 @@
"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",
"locations_short": "places"
"throw_all_confirm_btn": "🗑️ Yes, discard"
},
"product": {
"title_new": "New Product",
@@ -373,9 +363,7 @@
"weight_label": "Weight",
"origin_label": "Origin",
"labels_label": "Labels",
"select_variant": "Select the exact variant or use AI data:",
"history_badge": "📊 history",
"from_history": " (from history)"
"select_variant": "Select the exact variant or use AI data:"
},
"products": {
"title": "📦 All Products",
@@ -428,18 +416,7 @@
"load_error": "Loading error",
"favorite": "Add to favourites",
"unfavorite": "Remove from favourites",
"adjust_persons": "Persons",
"nutrition_title": "Nutritional values (per serving)",
"nutrition_kcal": "Calories",
"nutrition_protein": "Protein",
"nutrition_carbs": "Carbs",
"nutrition_fat": "Fat",
"nutrition_per_serving": "Estimated values per serving",
"storage_title": "How to store leftovers",
"storage_days": "{n} days",
"storage_immediately": "Best eaten immediately",
"ing_stock_line": "You have {have} · {remain} left after use",
"ing_use_all_note": "use all (<5% of full package left)"
"adjust_persons": "Persons"
},
"shopping": {
"title": "🛒 Shopping List",
@@ -526,7 +503,6 @@
"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...",
@@ -536,8 +512,7 @@
"priority_low": "Low",
"smart_last_update": "Updated {time}",
"names_already_updated": "All names are already up to date",
"pantry_hint": "Already at home: {qty}",
"bring_names_migrated": "🔄 {n} names generalized in Bring!"
"pantry_hint": "Already at home: {qty}"
},
"ai": {
"title": "🤖 AI Identification",
@@ -548,8 +523,7 @@
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
"fields_filled": "✅ Fields filled by AI",
"use_data": "✅ Use AI data",
"use_data_no_barcode": "✅ Use AI data (no barcode)",
"conservation_hint": "🤖 AI: store in {location}"
"use_data_no_barcode": "✅ Use AI data (no barcode)"
},
"log": {
"title": "📒 Operations Log",
@@ -804,13 +778,7 @@
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>",
"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"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>"
},
"kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
@@ -997,8 +965,7 @@
"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",
@@ -1072,7 +1039,6 @@
"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"
},
@@ -1144,9 +1110,7 @@
"offline_ops_pending": "{n} operations pending",
"offline_synced": "{n} operations synced",
"offline_ai_disabled": "Not available offline",
"offline_cache_ready": "Offline — {n} items cached",
"copy_failed": "Copy to clipboard failed",
"invalid_quantity": "Invalid quantity"
"offline_cache_ready": "Offline — {n} items cached"
},
"confirm_placeholder_search": null,
"confirm": {
@@ -1268,8 +1232,7 @@
"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",
"moved_simple": "📦 Moved to {location}"
"vacuum_seal_rest": "Vacuum seal the rest"
},
"nova": {
"1": "Unprocessed",
@@ -1504,12 +1467,7 @@
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
"retry": "Retry",
"syncing_local": "Syncing local data...",
"sync_done": "Local data synced",
"token_required": "API token required",
"token_autoconfig": "Configuring access...",
"token_prompt_title": "🔒 API Token",
"token_prompt_hint": "Enter the API_TOKEN value from the server .env file.",
"token_prompt_btn": "Continue"
"sync_done": "Local data synced"
},
"stats_monthly": {
"title": "Monthly Stats",
@@ -1522,12 +1480,5 @@
"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"
}
}
+16 -122
View File
@@ -141,10 +141,8 @@
"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",
@@ -164,13 +162,7 @@
"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_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})"
"banner_analyzing": "🤖 Analizando…"
},
"inventory": {
"title": "Despensa",
@@ -248,9 +240,7 @@
"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!"
"ai_detected_label": "IA detecto"
},
"action": {
"title": "¿Qué quieres hacer?",
@@ -264,8 +254,7 @@
"throw_btn": "🗑️ DESECHAR",
"throw_sub": "tirar",
"edit_sub": "caducidad, ubicación…",
"create_recipe_btn": "Receta",
"related_stock_title": "También en casa"
"create_recipe_btn": "Receta"
},
"add": {
"title": "Añadir a la despensa",
@@ -323,17 +312,14 @@
"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",
"locations_short": "ubicaciones"
"throw_all_confirm_btn": "🗑️ Sí, desechar"
},
"product": {
"title_new": "Nuevo producto",
@@ -373,9 +359,7 @@
"weight_label": "Peso",
"origin_label": "Origen",
"labels_label": "Etiquetas",
"select_variant": "Selecciona la variante exacta o usa los datos de IA:",
"history_badge": "📊 historial",
"from_history": " (del historial)"
"select_variant": "Selecciona la variante exacta o usa los datos de IA:"
},
"products": {
"title": "📦 Todos los productos",
@@ -427,19 +411,7 @@
"load_error": "Error de carga",
"favorite": "Añadir a favoritos",
"unfavorite": "Quitar de favoritos",
"adjust_persons": "Personas",
"nutrition_title": "Valores nutricionales (por ración)",
"nutrition_kcal": "Calorías",
"nutrition_protein": "Proteínas",
"nutrition_carbs": "Carbohidratos",
"nutrition_fat": "Grasas",
"nutrition_per_serving": "Valores estimados por ración",
"storage_title": "Cómo conservar las sobras",
"storage_days": "{n} días",
"storage_immediately": "Mejor consumir de inmediato",
"stream_interrupted": "Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.",
"ing_stock_line": "Tienes {have} · quedan {remain} después del uso",
"ing_use_all_note": "usar todo (<5% del envase completo)"
"adjust_persons": "Personas"
},
"shopping": {
"title": "🛒 Lista de la compra",
@@ -526,7 +498,6 @@
"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...",
@@ -535,9 +506,7 @@
"priority_medium": "Media",
"priority_low": "Baja",
"smart_last_update": "Actualizado {time}",
"names_already_updated": "Todos los nombres ya están actualizados",
"pantry_hint": "Ya en casa: {qty}",
"bring_names_migrated": "🔄 {n} nombres generalizados en Bring!"
"names_already_updated": "Todos los nombres ya están actualizados"
},
"ai": {
"title": "🤖 Identificación IA",
@@ -548,8 +517,7 @@
"no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>",
"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)",
"conservation_hint": "🤖 IA: conserva en {location}"
"use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)"
},
"log": {
"title": "📒 Registro de operaciones",
@@ -765,8 +733,7 @@
"heard_yes": "Sí, la escuché",
"heard_no": "No, no escuché nada",
"test_ok_kiosk": "TTS funcionando.",
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android.",
"test_sound_btn": "🔔 Prueba de sonido"
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android."
},
"language": {
"title": "🌐 Idioma",
@@ -804,13 +771,7 @@
"kiosk_title": "📡 Báscula BLE integrada en el kiosco",
"kiosk_hint": "La báscula está gestionada directamente por la pasarela BLE interna del kiosco. Para vincular un nuevo dispositivo, usa el asistente de configuración.",
"kiosk_reconfigure": "🔄 Reconfigurar báscula BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>",
"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"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>"
},
"kiosk": {
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
@@ -956,49 +917,7 @@
"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",
@@ -1071,10 +990,8 @@
"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",
"vacuum_sealed": "{name} guardado al vacío"
"item_added": "{name} añadido"
},
"antiwaste": {
"title": "🌱 Informe anti-desperdicio",
@@ -1144,9 +1061,7 @@
"offline_ops_pending": "{n} operaciones pendientes",
"offline_synced": "{n} operaciones sincronizadas",
"offline_ai_disabled": "No disponible sin conexión",
"offline_cache_ready": "Offline — {n} productos en caché",
"copy_failed": "Error al copiar al portapapeles",
"invalid_quantity": "Cantidad no válida"
"offline_cache_ready": "Offline — {n} productos en caché"
},
"confirm_placeholder_search": null,
"confirm": {
@@ -1247,10 +1162,7 @@
"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",
"expiry_found": "Fecha encontrada",
"expiry_read_fail": "No se puede leer la fecha.",
"expiry_raw_label": "Leído"
"save_new_btn": "🆕 Ninguno de estos — guardar como nuevo"
},
"lowstock": {
"title": "⚠️ ¡Stock bajo!",
@@ -1268,8 +1180,7 @@
"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",
"moved_simple": "📦 Movido a {location}"
"vacuum_seal_rest": "🔒 Sellar el resto al vacío"
},
"nova": {
"1": "Sin procesar",
@@ -1499,17 +1410,7 @@
"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",
"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."
"sync_done": "Datos locales sincronizados"
},
"stats_monthly": {
"title": "Estadísticas Mensuales",
@@ -1522,12 +1423,5 @@
"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"
}
}
+16 -122
View File
@@ -141,10 +141,8 @@
"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",
@@ -164,13 +162,7 @@
"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_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})"
"banner_analyzing": "🤖 Analyse en cours…"
},
"inventory": {
"title": "Garde-manger",
@@ -248,9 +240,7 @@
"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é !"
"ai_detected_label": "IA a detecte"
},
"action": {
"title": "Que voulez-vous faire ?",
@@ -264,8 +254,7 @@
"throw_btn": "🗑️ JETER",
"throw_sub": "jeter",
"edit_sub": "péremption, emplacement…",
"create_recipe_btn": "Recette",
"related_stock_title": "Aussi à la maison"
"create_recipe_btn": "Recette"
},
"add": {
"title": "Ajouter au garde-manger",
@@ -323,17 +312,14 @@
"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",
"locations_short": "emplacements"
"throw_all_confirm_btn": "🗑️ Oui, jeter"
},
"product": {
"title_new": "Nouveau produit",
@@ -373,9 +359,7 @@
"weight_label": "Poids",
"origin_label": "Origine",
"labels_label": "Labels",
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :",
"history_badge": "📊 historique",
"from_history": " (historique)"
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :"
},
"products": {
"title": "📦 Tous les produits",
@@ -427,19 +411,7 @@
"load_error": "Erreur de chargement",
"favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris",
"adjust_persons": "Personnes",
"nutrition_title": "Valeurs nutritionnelles (par portion)",
"nutrition_kcal": "Calories",
"nutrition_protein": "Protéines",
"nutrition_carbs": "Glucides",
"nutrition_fat": "Lipides",
"nutrition_per_serving": "Valeurs estimées par portion",
"storage_title": "Comment conserver les restes",
"storage_days": "{n} jours",
"storage_immediately": "À consommer immédiatement",
"stream_interrupted": "Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.",
"ing_stock_line": "Vous avez {have} · il reste {remain} après usage",
"ing_use_all_note": "tout utiliser (<5% du conditionnement entier)"
"adjust_persons": "Personnes"
},
"shopping": {
"title": "🛒 Liste de courses",
@@ -526,7 +498,6 @@
"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...",
@@ -535,9 +506,7 @@
"priority_medium": "Moyenne",
"priority_low": "Faible",
"smart_last_update": "Mis à jour {time}",
"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 !"
"names_already_updated": "Tous les noms sont déjà à jour"
},
"ai": {
"title": "🤖 Identification IA",
@@ -548,8 +517,7 @@
"no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>",
"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)",
"conservation_hint": "🤖 IA : conserve dans {location}"
"use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)"
},
"log": {
"title": "📒 Journal des opérations",
@@ -765,8 +733,7 @@
"heard_yes": "Oui, je l'ai entendu",
"heard_no": "Non, je n'ai rien entendu",
"test_ok_kiosk": "TTS fonctionne.",
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android.",
"test_sound_btn": "🔔 Test sonore"
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android."
},
"language": {
"title": "🌐 Langue",
@@ -804,13 +771,7 @@
"kiosk_title": "📡 Balance BLE intégrée dans le kiosque",
"kiosk_hint": "La balance est directement gérée par la passerelle BLE interne du kiosque. Pour associer un nouvel appareil, utilisez l'assistant de configuration.",
"kiosk_reconfigure": "🔄 Reconfigurer la balance BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>",
"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"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>"
},
"kiosk": {
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.",
@@ -956,49 +917,7 @@
"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",
@@ -1071,10 +990,8 @@
"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é",
"vacuum_sealed": "{name} enregistré sous vide"
"item_added": "{name} ajouté"
},
"antiwaste": {
"title": "🌱 Rapport anti-gaspi",
@@ -1144,9 +1061,7 @@
"offline_ops_pending": "{n} opérations en attente",
"offline_synced": "{n} opérations synchronisées",
"offline_ai_disabled": "Indisponible hors ligne",
"offline_cache_ready": "Offline — {n} produits en cache",
"copy_failed": "Échec de la copie dans le presse-papiers",
"invalid_quantity": "Quantité invalide"
"offline_cache_ready": "Offline — {n} produits en cache"
},
"confirm_placeholder_search": null,
"confirm": {
@@ -1247,10 +1162,7 @@
"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",
"expiry_found": "Date trouvée",
"expiry_read_fail": "Impossible de lire la date.",
"expiry_raw_label": "Lu"
"save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau"
},
"lowstock": {
"title": "⚠️ Stock faible !",
@@ -1268,8 +1180,7 @@
"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",
"moved_simple": "📦 Déplacé vers {location}"
"vacuum_seal_rest": "🔒 Mettre le reste sous vide"
},
"nova": {
"1": "Non transformé",
@@ -1499,17 +1410,7 @@
"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",
"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."
"sync_done": "Données locales synchronisées"
},
"stats_monthly": {
"title": "Statistiques Mensuelles",
@@ -1522,12 +1423,5 @@
"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"
}
}
+12 -61
View File
@@ -143,10 +143,8 @@
"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",
@@ -166,11 +164,7 @@
"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_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})"
"banner_analyzing": "🤖 Analizzo…"
},
"inventory": {
"title": "Dispensa",
@@ -249,8 +243,7 @@
"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!"
"ai_detected_label": "AI ha trovato"
},
"action": {
"title": "Cosa vuoi fare?",
@@ -323,17 +316,14 @@
"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",
"locations_short": "posti"
"throw_all_confirm_btn": "🗑️ Sì, butta"
},
"product": {
"title_new": "Nuovo Prodotto",
@@ -373,9 +363,7 @@
"weight_label": "Peso",
"origin_label": "Origine",
"labels_label": "Etichette",
"select_variant": "Seleziona la variante esatta o usa i dati AI:",
"history_badge": "📊 storico",
"from_history": " (da storico)"
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
},
"products": {
"title": "📦 Tutti i Prodotti",
@@ -428,18 +416,7 @@
"load_error": "Errore nel caricamento",
"favorite": "Aggiungi ai preferiti",
"unfavorite": "Rimuovi dai preferiti",
"adjust_persons": "Persone",
"nutrition_title": "Valori nutrizionali (per porzione)",
"nutrition_kcal": "Calorie",
"nutrition_protein": "Proteine",
"nutrition_carbs": "Carboidrati",
"nutrition_fat": "Grassi",
"nutrition_per_serving": "Valori stimati per porzione",
"storage_title": "Come conservare gli avanzi",
"storage_days": "{n} giorni",
"storage_immediately": "Da consumare subito",
"ing_stock_line": "Hai {have} · restano {remain} dopo l'uso",
"ing_use_all_note": "uso totale (<5% della confezione intera)"
"adjust_persons": "Persone"
},
"shopping": {
"title": "🛒 Lista della Spesa",
@@ -526,7 +503,6 @@
"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...",
@@ -536,8 +512,7 @@
"priority_low": "Bassa",
"smart_last_update": "Aggiornato {time}",
"names_already_updated": "Tutti i nomi sono già aggiornati",
"pantry_hint": "Hai gia {qty} in dispensa",
"bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!"
"pantry_hint": "Hai gia {qty} in dispensa"
},
"ai": {
"title": "🤖 Identificazione AI",
@@ -548,8 +523,7 @@
"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)",
"conservation_hint": "🤖 AI: conserva in {location}"
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)"
},
"log": {
"title": "📒 Storico",
@@ -804,13 +778,7 @@
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>",
"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"
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>"
},
"kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
@@ -997,8 +965,7 @@
"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",
@@ -1072,7 +1039,6 @@
"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"
},
@@ -1144,9 +1110,7 @@
"offline_ops_pending": "{n} operazioni in attesa",
"offline_synced": "{n} operazioni sincronizzate",
"offline_ai_disabled": "Non disponibile offline",
"offline_cache_ready": "Offline — {n} prodotti in cache",
"copy_failed": "Copia negli appunti non riuscita",
"invalid_quantity": "Quantità non valida"
"offline_cache_ready": "Offline — {n} prodotti in cache"
},
"confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
@@ -1267,8 +1231,7 @@
"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",
"moved_simple": "📦 Spostato in {location}"
"vacuum_seal_rest": "Metti sotto vuoto il resto"
},
"nova": {
"1": "Non trasformato",
@@ -1503,12 +1466,7 @@
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
"retry": "Riprova",
"syncing_local": "Sincronizzazione dati locali...",
"sync_done": "Dati locali aggiornati",
"token_required": "Token API richiesto",
"token_autoconfig": "Configurazione accesso...",
"token_prompt_title": "🔒 Token API",
"token_prompt_hint": "Inserisci il valore API_TOKEN dal file .env del server.",
"token_prompt_btn": "Continua"
"sync_done": "Dati locali aggiornati"
},
"stats_monthly": {
"title": "Statistiche Mensili",
@@ -1521,12 +1479,5 @@
"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"
}
}