Compare commits
244 Commits
v1.7.15
...
kiosk-latest
| Author | SHA1 | Date | |
|---|---|---|---|
| eb19265586 | |||
| 8a69e6d941 | |||
| c5b0dbcf42 | |||
| 338bd7ff66 | |||
| c7532f90cd | |||
| 5831e3bcea | |||
| ec1aae2a25 | |||
| 4f9f44e230 | |||
| 9be8fb5cf3 | |||
| 00b1c35665 | |||
| 5dd3baea5d | |||
| 9d3cf05496 | |||
| 34dcb05c05 | |||
| dea1223faf | |||
| 7eda4a5eb9 | |||
| e72e57edf6 | |||
| b63deca795 | |||
| 217626ca2a | |||
| cf65e79010 | |||
| 46bbe0f8d3 | |||
| a0385cfb9b | |||
| 3a938dd7fb | |||
| 0d006625fd | |||
| d5b4a6c4da | |||
| d33b0ca2fe | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 | |||
| d80199e4f1 | |||
| 1637cc1020 | |||
| 904a398009 | |||
| bc39361246 | |||
| 7f173770fc | |||
| b83db76a8d | |||
| cfd089a0a3 | |||
| ade121f43f | |||
| 2f665f777b | |||
| f46b12e3ad | |||
| a932d3de11 | |||
| 6120fad40b | |||
| 8ac6fec5a2 | |||
| fe7587e9e4 | |||
| 4f68925a7c | |||
| f4ea9e74e6 | |||
| 8f217fd166 | |||
| b985247b95 | |||
| efbed479df | |||
| 695c23fc21 | |||
| 7a34406b07 | |||
| 50660f634f | |||
| fb06b42107 | |||
| c16067d9e5 | |||
| 605d8590f6 | |||
| 149cff3ca5 | |||
| ec7d172ed9 | |||
| 0479e34c7f | |||
| 730efe4d87 | |||
| be3dceeebb | |||
| 875250626d | |||
| 245d007e29 | |||
| 63a9f70f86 | |||
| 1a6e0c87ce | |||
| 73f43cb296 | |||
| baed815a48 | |||
| 8aa934f5ca | |||
| 83b5eb3063 | |||
| 59c6f9d76c | |||
| bac9485e4e | |||
| 11178af001 | |||
| 4e4a736dba | |||
| 52afdd6bfa | |||
| 3d27433eb3 | |||
| eddb622c85 | |||
| 95c20adbbd | |||
| 6fa2e4d830 | |||
| 6ff1dfe0cc | |||
| 43e0ac9da3 | |||
| 1ce32cb5f0 | |||
| d75cde7eb6 | |||
| 43fe1c7bb5 | |||
| b2c87ae343 | |||
| fbdae35516 | |||
| d9ebc51e71 | |||
| 56ca58bc18 | |||
| b2e0f6d683 | |||
| ddb9bd9f75 | |||
| 965a672abe | |||
| 7249daa8eb | |||
| ec53f7529c | |||
| 1074dff87d | |||
| 3989d11094 | |||
| b010ced1a6 | |||
| cc0fa09219 | |||
| c0a076749e | |||
| 6a41b53174 | |||
| 1d04236bc0 | |||
| 561c6e9809 | |||
| 6857c20893 | |||
| 964de98203 | |||
| e28a6e4e39 | |||
| fd9e2471e0 | |||
| 3c8a9693b2 | |||
| b38bdc45f5 | |||
| 83a0df272a | |||
| 6320b575e0 | |||
| 8ccd218c5a | |||
| 5c1afaaaf5 | |||
| 6245b15420 | |||
| 02f673a164 | |||
| 61bb1b5552 | |||
| cbf4bd54da | |||
| 1cdbdb3b25 | |||
| 837d62c335 | |||
| fa36ba83bf | |||
| 1efeaf9236 | |||
| 573bcd1102 | |||
| 426cc9df7e | |||
| 6f2d6d9944 | |||
| d3eb82eee2 | |||
| 98426bf861 | |||
| 264b1f648e | |||
| b89df961a6 | |||
| 5e34bc90b3 | |||
| 3b100df26c | |||
| 2ecb3cbac6 | |||
| c2004fd0f8 | |||
| fba0947945 | |||
| 3a1f6cfd1e | |||
| 37fb522e8b | |||
| 66f5a03503 | |||
| a37d97dfcd | |||
| 47197d0d66 | |||
| 149621651d | |||
| b5a6daa557 | |||
| ccc2f8907d | |||
| 9e80915a61 | |||
| 7b60f1dbe3 | |||
| 7019160704 | |||
| ac8b5acc0c | |||
| 34df755ba3 | |||
| 87eac171bf | |||
| ef15f3536c | |||
| f77b3259ad | |||
| 5ad24ed73b | |||
| 84934c1908 | |||
| dd0625b253 | |||
| fa0442e2f6 | |||
| a85414d790 | |||
| c07439fea4 | |||
| 8f6934485a | |||
| d7aadff598 | |||
| d8aff8ac04 | |||
| 7364e75881 | |||
| ff25307662 | |||
| 4515ff7246 | |||
| 0f0acd0dfa | |||
| ba0c4c3d88 | |||
| a58ef241e9 | |||
| bd5d4bcac6 | |||
| c9a859463c | |||
| b3454062bf | |||
| 56e68b72f8 | |||
| b91203f151 | |||
| cc0d9763ed | |||
| d8c7d1545a | |||
| 9f554c6e22 | |||
| 4f715730ec | |||
| dc3cefefd0 | |||
| a2eaf695bb | |||
| db2e32322b | |||
| 36821bde7a | |||
| 9d49609e4b | |||
| de897cc0f9 | |||
| 30f4bf4a1b | |||
| 1379cfc388 | |||
| 2806cb0903 | |||
| 56b6eb5f0d | |||
| 83d1868309 | |||
| 788d4fe848 | |||
| 91616b3a6d | |||
| 844fe3ba1e | |||
| da4aa5a1ae | |||
| 9541e3a385 | |||
| 47ce849311 | |||
| ea2dae2be9 | |||
| 8360f5a0a0 | |||
| f5b1913ffa | |||
| d26dce283d | |||
| e67e490162 | |||
| 92048c9eba | |||
| ce504d5d41 | |||
| a690d2e7cf | |||
| e858b3cc85 | |||
| 78f499205c | |||
| b3a0e83dde | |||
| d3b119c7fe | |||
| 9b8164b141 | |||
| 8750e44687 | |||
| 57f66c17df | |||
| 2630905146 | |||
| a602726531 | |||
| 3f55f07220 | |||
| 06f6d58fb5 | |||
| c1ef4c5e13 | |||
| 0a6e653692 | |||
| a99b35225a | |||
| 3ba4f7eaad | |||
| fdfd5cd0ec | |||
| b973284aeb | |||
| 0a5629e881 | |||
| d901939da1 | |||
| 245e14cc3b | |||
| aaf9323ba5 | |||
| 78c3306d9e | |||
| 0f567c4ba0 | |||
| 169e32bff3 | |||
| d28055a512 | |||
| 68f7756e2c | |||
| b82b4d9d94 | |||
| 91b4ecd670 | |||
| 380fa8ee99 | |||
| 89b8686f4f | |||
| b6aa07a1fd | |||
| 47c26ffdc8 | |||
| 12357db933 | |||
| 6def94948b |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
+170
-17
@@ -1,25 +1,178 @@
|
|||||||
# EverShelf - Configuration
|
# EverShelf — Configuration
|
||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env and fill in your values:
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# All settings here can also be changed from the in-app Settings screen and
|
||||||
|
# will be written back to this file automatically.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Google Gemini AI API Key (required for AI features)
|
# ── AI ────────────────────────────────────────────────────────────────────────
|
||||||
# Get one at: https://aistudio.google.com/app/apikey
|
# Google Gemini API key (required for AI features: expiry reading, recipe gen, …)
|
||||||
|
# Get one free at: https://aistudio.google.com/app/apikey
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
# Bring! Shopping List credentials (optional)
|
# ── Shopping list (Bring!) ────────────────────────────────────────────────────
|
||||||
# Sign up at: https://www.getbring.com/
|
# Credentials for the Bring! app (optional — app works without it)
|
||||||
BRING_EMAIL=
|
BRING_EMAIL=
|
||||||
BRING_PASSWORD=
|
BRING_PASSWORD=
|
||||||
|
|
||||||
# TTS (Text-to-Speech) for cooking mode voice guidance (optional)
|
# ── Text-to-Speech (TTS) ─────────────────────────────────────────────────────
|
||||||
# Works with Home Assistant, or any HTTP endpoint that accepts text
|
# Works with Home Assistant, a local TTS server, or any HTTP endpoint.
|
||||||
TTS_URL=
|
# TTS_ENABLED: master switch (true/false)
|
||||||
TTS_TOKEN=
|
|
||||||
TTS_METHOD=POST
|
|
||||||
TTS_AUTH_TYPE=bearer
|
|
||||||
TTS_CONTENT_TYPE=application/json
|
|
||||||
TTS_PAYLOAD_KEY=message
|
|
||||||
TTS_ENABLED=false
|
TTS_ENABLED=false
|
||||||
|
# TTS_URL: endpoint that receives the text payload
|
||||||
|
TTS_URL=
|
||||||
|
# TTS_TOKEN: Authorization token sent as Bearer header (or empty)
|
||||||
|
TTS_TOKEN=
|
||||||
|
# TTS_METHOD: HTTP method (POST or GET)
|
||||||
|
TTS_METHOD=POST
|
||||||
|
# TTS_AUTH_TYPE: how the token is sent (bearer | basic | none)
|
||||||
|
TTS_AUTH_TYPE=bearer
|
||||||
|
# TTS_CONTENT_TYPE: request Content-Type header
|
||||||
|
TTS_CONTENT_TYPE=application/json
|
||||||
|
# TTS_PAYLOAD_KEY: JSON key that carries the text (e.g. "message", "text")
|
||||||
|
TTS_PAYLOAD_KEY=message
|
||||||
|
# TTS_ENGINE: preferred browser TTS engine ('browser', 'server', 'custom') — optional
|
||||||
|
TTS_ENGINE=
|
||||||
|
# TTS_RATE / TTS_PITCH: speech rate and pitch multipliers (1 = normal)
|
||||||
|
TTS_RATE=1
|
||||||
|
TTS_PITCH=1
|
||||||
|
# TTS_AUTH_HEADER_NAME / VALUE: custom HTTP header for authentication (optional)
|
||||||
|
TTS_AUTH_HEADER_NAME=
|
||||||
|
TTS_AUTH_HEADER_VALUE=
|
||||||
|
# TTS_EXTRA_FIELDS: additional JSON fields as key=value pairs, comma-separated (optional)
|
||||||
|
TTS_EXTRA_FIELDS=
|
||||||
|
|
||||||
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
|
# ── User preferences ─────────────────────────────────────────────────────────
|
||||||
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
|
# These mirror the toggle switches in the Settings screen.
|
||||||
|
DEFAULT_PERSONS=1
|
||||||
|
PREF_VELOCE=false
|
||||||
|
PREF_POCAFAME=false
|
||||||
|
PREF_SCADENZE=true
|
||||||
|
PREF_HEALTHY=false
|
||||||
|
PREF_OPENED=true
|
||||||
|
PREF_ZEROWASTE=false
|
||||||
|
# Dietary restrictions shown to the AI (e.g. "vegetariano,senza glutine")
|
||||||
|
DIETARY=
|
||||||
|
|
||||||
|
# ── Appliances ────────────────────────────────────────────────────────────────
|
||||||
|
# Comma-separated list of appliances available in your kitchen.
|
||||||
|
# Used by the AI when generating recipes.
|
||||||
|
APPLIANCES=Forno,Microonde,Friggitrice ad aria,Pentola a pressione
|
||||||
|
|
||||||
|
# ── Camera ───────────────────────────────────────────────────────────────────
|
||||||
|
# Default camera for barcode scanning ('environment' = rear, 'user' = front)
|
||||||
|
CAMERA_FACING=environment
|
||||||
|
|
||||||
|
# ── Smart Kitchen Scale ───────────────────────────────────────────────────────
|
||||||
|
# SCALE_ENABLED: enables the scale integration
|
||||||
|
SCALE_ENABLED=false
|
||||||
|
# SCALE_GATEWAY_URL: address of the EverShelf Scale Gateway (Android app)
|
||||||
|
SCALE_GATEWAY_URL=
|
||||||
|
|
||||||
|
# ── Meal Plan ────────────────────────────────────────────────────────────────
|
||||||
|
# MEAL_PLAN_ENABLED: show the weekly meal planner tab in Settings
|
||||||
|
MEAL_PLAN_ENABLED=false
|
||||||
|
|
||||||
|
# ── Screensaver (kiosk / tablet mode) ────────────────────────────────────────
|
||||||
|
SCREENSAVER_ENABLED=false
|
||||||
|
# SCREENSAVER_TIMEOUT: inactivity seconds before screensaver activates (default 5 min)
|
||||||
|
SCREENSAVER_TIMEOUT=300
|
||||||
|
|
||||||
|
# ── Price estimates ───────────────────────────────────────────────────────────
|
||||||
|
# PRICE_ENABLED: show AI-estimated price column on the shopping list
|
||||||
|
PRICE_ENABLED=false
|
||||||
|
# PRICE_COUNTRY: country used for price context (e.g. "Italia", "Germany")
|
||||||
|
PRICE_COUNTRY=Italia
|
||||||
|
# PRICE_CURRENCY: ISO 4217 currency code (e.g. EUR, USD, GBP)
|
||||||
|
PRICE_CURRENCY=EUR
|
||||||
|
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
|
||||||
|
PRICE_UPDATE_MONTHS=3
|
||||||
|
|
||||||
|
# ── Cleanup / retention ──────────────────────────────────────────────────────
|
||||||
|
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
|
||||||
|
RECIPE_RETENTION_DAYS=7
|
||||||
|
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
|
||||||
|
# Smart Shopping uses this history to compute purchase frequencies.
|
||||||
|
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
|
||||||
|
# Minimum enforced at runtime: 30 days.
|
||||||
|
TRANSACTION_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# ── Local Backup ─────────────────────────────────────────────────────────────
|
||||||
|
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
|
||||||
|
BACKUP_ENABLED=true
|
||||||
|
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
|
||||||
|
BACKUP_RETENTION_DAYS=3
|
||||||
|
|
||||||
|
# ── Google Drive Backup ───────────────────────────────────────────────────────
|
||||||
|
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
|
||||||
|
GDRIVE_ENABLED=false
|
||||||
|
#
|
||||||
|
# Setup steps:
|
||||||
|
# 1. Create a Google Cloud project and enable the Drive API
|
||||||
|
# 2. Create a Service Account and download the JSON key
|
||||||
|
# 3. Create a Drive folder and share it with the service account email
|
||||||
|
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
|
||||||
|
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
|
||||||
|
#
|
||||||
|
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
|
||||||
|
GDRIVE_SERVICE_ACCOUNT_JSON=
|
||||||
|
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
|
||||||
|
GDRIVE_SERVICE_ACCOUNT_FILE=
|
||||||
|
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
|
||||||
|
GDRIVE_FOLDER_ID=
|
||||||
|
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
|
||||||
|
GDRIVE_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ── Security ─────────────────────────────────────────────────────────────────
|
||||||
|
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
|
||||||
|
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
|
||||||
|
API_TOKEN=
|
||||||
|
SETTINGS_TOKEN=
|
||||||
|
|
||||||
|
# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard)
|
||||||
|
CORS_ORIGIN=
|
||||||
|
|
||||||
|
# GitHub automatic issue reporting (encrypted storage recommended)
|
||||||
|
# Option A — plain ( .env is gitignored ):
|
||||||
|
# GH_ISSUE_TOKEN=ghp_...
|
||||||
|
# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'):
|
||||||
|
GH_ISSUE_TOKEN=
|
||||||
|
GH_ISSUE_TOKEN_ENC=
|
||||||
|
GH_ISSUE_TOKEN_KEY=
|
||||||
|
|
||||||
|
# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens.
|
||||||
|
|
||||||
|
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||||
|
# for Zeroconf discovery label and device name in Home Assistant).
|
||||||
|
# Defaults to the server hostname if left empty.
|
||||||
|
INSTANCE_NAME=
|
||||||
|
|
||||||
|
# ── Home Assistant Integration ────────────────────────────────────────────────
|
||||||
|
# All HA settings can also be configured from the Settings → 🏠 tab.
|
||||||
|
#
|
||||||
|
# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors)
|
||||||
|
HA_ENABLED=false
|
||||||
|
# HA_URL: base URL of your HA instance — no trailing slash
|
||||||
|
# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123
|
||||||
|
HA_URL=
|
||||||
|
# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens)
|
||||||
|
HA_TOKEN=
|
||||||
|
# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room)
|
||||||
|
HA_TTS_ENTITY=
|
||||||
|
# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger
|
||||||
|
HA_WEBHOOK_ID=
|
||||||
|
# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for
|
||||||
|
# Available: expiry, shopping_add, stock_update, barcode_scan
|
||||||
|
HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update
|
||||||
|
# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone)
|
||||||
|
HA_NOTIFY_SERVICE=
|
||||||
|
# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3)
|
||||||
|
HA_EXPIRY_DAYS=3
|
||||||
|
|
||||||
|
# ── Developer / demo ─────────────────────────────────────────────────────────
|
||||||
|
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
||||||
|
DEMO_MODE=false
|
||||||
|
|
||||||
|
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
|
||||||
|
CRON_LOG_MAX_BYTES=524288
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
name: Build & Release Kiosk APK
|
name: Build & Release Kiosk APK
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -17,7 +20,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -34,8 +37,10 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
||||||
|
VCODE=$(grep 'versionCode' evershelf-kiosk/app/build.gradle.kts | grep -oP '\d+')
|
||||||
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "Kiosk version: $VERSION"
|
echo "code=$VCODE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Kiosk version: $VERSION (versionCode $VCODE)"
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: gradle assembleDebug --no-daemon
|
run: gradle assembleDebug --no-daemon
|
||||||
@@ -72,7 +77,21 @@ jobs:
|
|||||||
sleep 3
|
sleep 3
|
||||||
gh release create kiosk-latest \
|
gh release create kiosk-latest \
|
||||||
--title "EverShelf Kiosk Latest" \
|
--title "EverShelf Kiosk Latest" \
|
||||||
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
|
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
artifacts/evershelf-kiosk.apk
|
artifacts/evershelf-kiosk.apk
|
||||||
|
|
||||||
|
- name: Publish APK to releases/ for LAN OTA
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk
|
||||||
|
printf '{"version":"%s","version_code":%s}\n' \
|
||||||
|
"${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \
|
||||||
|
> releases/kiosk-version.json
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add releases/evershelf-kiosk.apk releases/kiosk-version.json
|
||||||
|
git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA"
|
||||||
|
git push origin HEAD:${{ github.ref_name }}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-php:
|
lint-php:
|
||||||
name: PHP Syntax Check
|
name: PHP Syntax Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -27,7 +30,7 @@ jobs:
|
|||||||
name: JavaScript Lint
|
name: JavaScript Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Check JS syntax
|
- name: Check JS syntax
|
||||||
run: |
|
run: |
|
||||||
@@ -37,10 +40,21 @@ jobs:
|
|||||||
name: Docker Build Test
|
name: Docker Build Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build -t evershelf-test .
|
run: |
|
||||||
|
set -e
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "Docker build attempt $attempt/3..."
|
||||||
|
if docker build -t evershelf-test .; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Attempt $attempt failed — retrying in 20s..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
echo "Docker build failed after 3 attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Test container starts
|
- name: Test container starts
|
||||||
run: |
|
run: |
|
||||||
@@ -53,7 +67,7 @@ jobs:
|
|||||||
name: Validate Translation Files
|
name: Validate Translation Files
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Validate JSON syntax
|
- name: Validate JSON syntax
|
||||||
run: |
|
run: |
|
||||||
@@ -99,10 +113,12 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history)
|
- name: Checkout (full history)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
|
||||||
|
# WORKFLOW_PAT is only needed for the push step below.
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|
||||||
- name: Configure git bot identity
|
- name: Configure git bot identity
|
||||||
run: |
|
run: |
|
||||||
@@ -111,6 +127,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Merge develop → main
|
- name: Merge develop → main
|
||||||
run: |
|
run: |
|
||||||
|
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
|
||||||
|
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
|
||||||
|
# that silently overrides any credentials embedded in git remote URLs.
|
||||||
|
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
|
||||||
|
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
|
||||||
|
# changes are rejected.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
|
||||||
|
|
||||||
LAST=$(git log --oneline -1 origin/develop)
|
LAST=$(git log --oneline -1 origin/develop)
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull --ff-only origin main
|
git pull --ff-only origin main
|
||||||
@@ -118,6 +143,26 @@ jobs:
|
|||||||
-m "chore: auto-merge develop → main
|
-m "chore: auto-merge develop → main
|
||||||
|
|
||||||
Triggered by: $LAST"
|
Triggered by: $LAST"
|
||||||
|
|
||||||
|
# ── PUSH STRATEGY ───────────────────────────────────────────────────
|
||||||
|
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
|
||||||
|
# → can push workflow file changes; set as a repo secret.
|
||||||
|
# Priority 2: GITHUB_TOKEN fallback
|
||||||
|
# → cannot push workflow files; strip them from the merge commit.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
|
||||||
|
if [ -z "$PUSH_TOKEN" ]; then
|
||||||
|
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
|
||||||
|
if [ -n "$WF" ]; then
|
||||||
|
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
|
||||||
|
echo "$WF"
|
||||||
|
git checkout origin/main -- .github/workflows/
|
||||||
|
git diff --cached --quiet || git commit --amend --no-edit
|
||||||
|
fi
|
||||||
|
PUSH_TOKEN="${{ github.token }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||||
@@ -133,7 +178,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
name: Security Scan (Trivy)
|
name: Security Scan (Trivy)
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main, develop]
|
||||||
@@ -22,7 +25,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build -t evershelf:scan .
|
run: docker build -t evershelf:scan .
|
||||||
@@ -51,7 +54,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run Trivy filesystem scanner
|
- name: Run Trivy filesystem scanner
|
||||||
uses: aquasecurity/trivy-action@v0.36.0
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
|
|||||||
@@ -50,3 +50,6 @@ data/error_reports.log
|
|||||||
data/latest_release_cache.json
|
data/latest_release_cache.json
|
||||||
data/food_facts_cache.json
|
data/food_facts_cache.json
|
||||||
data/category_ai_cache.json
|
data/category_ai_cache.json
|
||||||
|
assets/img/logo/*_backup.*
|
||||||
|
logs/*.log
|
||||||
|
assets/vendor/transformers/Xenova/
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
# Force HTTPS
|
# Block sensitive files (Apache 2.4+)
|
||||||
|
<Files ".env">
|
||||||
|
Require all denied
|
||||||
|
</Files>
|
||||||
|
<Files ".env.example">
|
||||||
|
Require all denied
|
||||||
|
</Files>
|
||||||
|
<Files "backup.sh">
|
||||||
|
Require all denied
|
||||||
|
</Files>
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Force HTTPS (skip when terminated TLS is forwarded — Traefik, Caddy, NPM, …)
|
||||||
RewriteCond %{HTTPS} !=on
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
|
||||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
# API routing
|
# API routing
|
||||||
|
|||||||
+281
@@ -11,6 +11,283 @@ 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.
|
- **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.41] - 2026-06-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Docker/Traefik “Impossibile contattare il server”** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production.
|
||||||
|
- **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually.
|
||||||
|
- **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs.
|
||||||
|
|
||||||
|
## [1.7.40] - 2026-06-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”.
|
||||||
|
- **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list.
|
||||||
|
- **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen.
|
||||||
|
- **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue).
|
||||||
|
- **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token.
|
||||||
|
- **Ops CLI scripts** — `audit-finished-shopping.php`, `backfill-finished-shopping.php`, `sync-shopping-bring.php`, `install-transformers-model.sh` (offline Xenova classifier bootstrap).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention.
|
||||||
|
- **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode.
|
||||||
|
- **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron).
|
||||||
|
- **Spesa scan crash** — `currentPage` → `_currentPageId` in `_applySpesaScanUI`.
|
||||||
|
- **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes.
|
||||||
|
- **Smart shopping purchased block** — Server-side blocklist + spesa mode sync prevents cron from re-adding bought items.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops.
|
||||||
|
|
||||||
|
## [1.7.39] - 2026-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans.
|
||||||
|
- **Spesa barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page).
|
||||||
|
- **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip.
|
||||||
|
- **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated.
|
||||||
|
- **Family sibling 24h dedup** — After confirming “Sì, tutto ok” on a similar in-stock product, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings).
|
||||||
|
- **Family sibling stock line** — Spesa prompt shows readable stock (e.g. `4 conf (da 20g)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES.
|
||||||
|
- **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched` → `enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes.
|
||||||
|
- **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included).
|
||||||
|
- **Spesa ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity.
|
||||||
|
- **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned.
|
||||||
|
- **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay.
|
||||||
|
- **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after spesa add.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~1–2s first hit); 30-minute negative cache for unknown codes.
|
||||||
|
- **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes.
|
||||||
|
|
||||||
|
## [1.7.38] - 2026-06-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
|
||||||
|
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
|
||||||
|
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
|
||||||
|
|
||||||
|
## [1.7.37] - 2026-06-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
|
||||||
|
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
|
||||||
|
|
||||||
|
## [1.7.36] - 2026-06-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
|
||||||
|
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
|
||||||
|
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
|
||||||
|
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
|
||||||
|
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
|
||||||
|
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
|
||||||
|
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
|
||||||
|
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
|
||||||
|
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
|
||||||
|
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
|
||||||
|
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
|
||||||
|
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
|
||||||
|
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
|
||||||
|
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
|
||||||
|
|
||||||
|
## [1.7.35] - 2026-06-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||||
|
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||||
|
|
||||||
|
## [1.7.34] - 2026-05-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
|
||||||
|
|
||||||
|
## [1.7.33] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||||
|
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.32] - 2026-05-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.31] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135–#183).
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`.
|
||||||
|
- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count).
|
||||||
|
- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info.
|
||||||
|
- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`.
|
||||||
|
- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos.
|
||||||
|
- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal).
|
||||||
|
- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`.
|
||||||
|
|
||||||
|
## [1.7.26] - 2026-05-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Monthly stats panel** — Third rotating card in the insight banner (anti-waste → nutrition → monthly stats, 1 minute each). Shows products consumed this month with a trend vs. the previous calendar month (↑/↓/→ with % delta), animated horizontal category bars, and badges for items added, wasted, and top-used product. Falls back gracefully when the current month has no transactions. Closes [#100](https://github.com/dadaloop82/EverShelf/issues/100).
|
||||||
|
- **Extended smart-shopping horizon for staples** — Items consumed ≥ 4 times/month now get a 28-day look-ahead window; ≥ 2 times/month get 21 days. Frequently used staples no longer disappear from the smart list between restocks. Closes [#98](https://github.com/dadaloop82/EverShelf/issues/98).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **TTS test interactive confirmation** — Test timeout raised from 4 s to 10 s; instead of an error, the UI shows a YES/NO prompt ("Did you hear it?") so users can confirm or report failure explicitly.
|
||||||
|
- **`end()` PHP 8 reference error** — `_offFetchProduct()` passed the result of `??` directly to `end()`, which requires a variable. Fixed with a temporary variable.
|
||||||
|
- **Database migration crash on fresh installs** — `migrateDB()` tried to rename the `transactions` table before it existed. A `sqlite_master` guard now calls `initializeDB()` and returns early when the schema is absent. Closes [#131](https://github.com/dadaloop82/EverShelf/issues/131), [#133](https://github.com/dadaloop82/EverShelf/issues/133).
|
||||||
|
- **Health-check crash on empty database** — `db_row_count` query was executed even when the `inventory` table was missing, causing a fatal PDO error. The query is now skipped until the schema is fully initialised. Closes [#132](https://github.com/dadaloop82/EverShelf/issues/132).
|
||||||
|
- **Insight banner stuck on one panel** — Rotation interval was 1 hour (effectively invisible); now 60 seconds. `_applyInsightPhase` also now skips empty panels instead of always falling back to the anti-waste card, so the rotation works correctly even when a panel has no data.
|
||||||
|
- **Untranslated OpenFoodFacts category labels** — Categories stored as OFF slugs (`en:plant-based-foods-and-beverages`, `en:dairies`, …) were shown raw. A new `_normalizeCat()` PHP function maps ~60 OFF slugs to Italian app categories; counts are re-aggregated after normalisation so `en:dairies` + `en:milk` both contribute to `latticini`.
|
||||||
|
|
||||||
|
## [1.7.25] - 2026-05-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
|
||||||
|
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
|
||||||
|
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
|
||||||
|
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
|
||||||
|
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
|
||||||
|
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
|
||||||
|
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
|
||||||
|
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
|
||||||
|
|
||||||
|
## [1.7.24] - 2026-05-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE.
|
||||||
|
- **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView.
|
||||||
|
- **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit.
|
||||||
|
- **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page.
|
||||||
|
- **SQLite database locked during inventory update** — `updateInventory()` made 3–4 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110).
|
||||||
|
- **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent.
|
||||||
|
- **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log.
|
||||||
|
- **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection.
|
||||||
|
- **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit.
|
||||||
|
- **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105).
|
||||||
|
- **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically.
|
||||||
|
|
||||||
|
## [1.7.23] - 2026-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **⚙️ Generali tab** — new first tab in Settings groups all global settings: language, currency, theme, screensaver, zero-waste tips, inventory export. Old Language tab removed.
|
||||||
|
- **DB auto-cleanup** — `RECIPE_RETENTION_DAYS` (default 7) and `TRANSACTION_RETENTION_DAYS` (default 7) added to `.env`; old rows are deleted automatically every cron cycle, followed by `VACUUM` to compact the database. Manual trigger: `GET /api/?action=db_cleanup`.
|
||||||
|
- **Vacuum-sealed expiry grace period** — `VACUUM_EXPIRY_EXTENSION_DAYS` (default 30): vacuum-sealed products are only flagged as expired N days *after* the printed date, preventing false alarms on long-lasting items like cured meats.
|
||||||
|
- **Gemini AI usage tracking** — monthly and yearly token/cost stats now shown in Settings → ℹ️ Info tab, using tracked data from `data/ai_usage.json`. Cost rates configurable via `GEMINI_COST_25F_IN/OUT` and `GEMINI_COST_20F_IN/OUT` in `.env`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Auto theme is now time-based** — "Automatico" mode switches to dark at 20:00 and back to light at 07:00, based on server/device clock (not OS preference). Re-evaluates every 5 minutes; ideal for always-on kiosk displays.
|
||||||
|
- **`dispensa.db` auto-deleted** — if the legacy empty `dispensa.db` file appears alongside `evershelf.db`, it is now removed automatically by the health check.
|
||||||
|
- **ZeroWaste tips and screensaver timeout** — these settings were not being persisted to `.env` on save (missing from POST payload); fixed.
|
||||||
|
|
||||||
|
## [1.7.22] - 2026-05-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **DB name corrected** — `health_check` now looks for `evershelf.db` (was wrongly looking for `dispensa.db`). Auto-migration included: if `evershelf.db` is missing but `dispensa.db` exists, it is renamed automatically on startup.
|
||||||
|
- **Removed legacy `data/dispensa.db`** — the old database file has been deleted; only `evershelf.db` is used.
|
||||||
|
- **Conditional checks** — Bring!, TTS, Scale and Internet checks only run when the respective feature is enabled in `.env` (no more false ❌/⚠️ for unconfigured features).
|
||||||
|
- **Backups check** — no longer checks if `data/backups/` is writable by www-data (cron writes as root). Now checks that backup files actually exist and the most recent one is recent.
|
||||||
|
- **Bring! token check** — reads `data/bring_token.json` file instead of looking for a non-existent `BRING_ACCESS_TOKEN` env var.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Warning popup with 5s countdown** — when non-critical checks fail at startup, a styled popup appears showing each warning with its label and a plain-language hint explaining the problem. A countdown bar auto-closes the popup after 5 seconds, then the app starts normally.
|
||||||
|
- **Error blocking popup** — when critical checks fail, a clear blocking panel shows with title "Errore critico", each failed check listed with its explanation hint, and a Retry button. The app does not start.
|
||||||
|
- **`db_legacy` check added** — warns (optional) if the old `dispensa.db` file is still present alongside `evershelf.db`.
|
||||||
|
- **32 total checks** — added `db_legacy`, `tts_url`, `scale_gateway` to the check set (conditional).
|
||||||
|
- **Hint messages** — every check now has an Italian-language `hint` field explaining what is wrong and how to fix it.
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Startup health check** — Complete redesign from a banner checklist to a **real-time progress bar**. The bar fills smoothly as each of 29 diagnostic checks runs, with the current check name shown below in real time. Warnings (⚠️) are displayed as amber badges that remain visible for 2 seconds before the app proceeds. Critical failures turn the bar red and show a detailed error block with a Retry button.
|
||||||
|
- **29 comprehensive checks**: PHP version, 8 PHP extensions (pdo_sqlite, curl, json, mbstring, openssl, fileinfo, zip, intl), PHP memory/timeout/upload config, data directory, rate_limits dir, backups dir, disk write test, free disk space, SQLite connection, required tables, integrity (PRAGMA quick_check), WAL mode, DB size, inventory row count, .env file, Gemini AI key, Bring! credentials, Bring! token, cURL SSL, internet reachability.
|
||||||
|
- Warnings now clearly visible: each non-critical failure shows as a named amber badge (e.g. "⚠️ Bring! token") that cannot be missed.
|
||||||
|
|
||||||
|
## [1.7.20] - 2026-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup.
|
||||||
|
- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth).
|
||||||
|
- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES).
|
||||||
|
|
||||||
|
## [1.7.19] - 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76).
|
||||||
|
- New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES).
|
||||||
|
|
||||||
|
## [1.7.18] - 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78).
|
||||||
|
- **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64).
|
||||||
|
- `translations/de.json`: fixed missing `log.recipe_prefix` key.
|
||||||
|
|
||||||
|
## [1.7.17] - 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
|
||||||
|
- **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
|
||||||
|
- Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español.
|
||||||
|
- Default fallback language changed from Italian to English (for users with unsupported browser locale).
|
||||||
|
- Setup wizard "Done" screen and navigation buttons localised for French and Spanish.
|
||||||
|
|
||||||
|
## [1.7.16] - 2026-05-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
|
||||||
|
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
|
||||||
|
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
|
||||||
|
|
||||||
## [1.7.15] - 2026-05-16
|
## [1.7.15] - 2026-05-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -20,11 +297,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
|
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16.
|
||||||
|
- **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS.
|
||||||
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
|
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
|
||||||
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
|
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
|
||||||
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
|
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
|
||||||
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
|
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
|
||||||
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
|
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
|
||||||
|
- **Appliance chips translated** — `renderAppliances()` now shows translated names (e.g. "Air fryer" in EN, "Heißluftfritteuse" in DE) for all known canonical Italian appliance names via `_applianceDisplayName()` lookup. `addApplianceQuick` toast no longer hardcoded Italian. Remove-button title translated.
|
||||||
|
- **Gemini API key not preserved on settings save** — `saveSettings()` was overwriting `s.gemini_key = ""` when the Gemini input field was empty (it is intentionally not pre-populated for security). Key is now preserved if the input is blank. `_geminiAvailable` is re-fetched from the server after every settings save so the recipe buttons reflect the real state immediately.
|
||||||
|
|
||||||
## [1.7.14] - 2026-05-16
|
## [1.7.14] - 2026-05-16
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -33,7 +33,9 @@ RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.
|
|||||||
RUN echo '<Directory /var/www/html>\n\
|
RUN echo '<Directory /var/www/html>\n\
|
||||||
AllowOverride All\n\
|
AllowOverride All\n\
|
||||||
Require all granted\n\
|
Require all granted\n\
|
||||||
</Directory>' > /etc/apache2/conf-available/evershelf.conf \
|
</Directory>\n\
|
||||||
|
# Traefik / reverse-proxy: treat forwarded HTTPS as on so .htaccess does not redirect-loop\n\
|
||||||
|
SetEnvIf X-Forwarded-Proto "https" HTTPS=on' > /etc/apache2/conf-available/evershelf.conf \
|
||||||
&& a2enconf evershelf
|
&& a2enconf evershelf
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
[](https://www.php.net/)
|
[](https://www.php.net/)
|
||||||
[](https://www.sqlite.org/)
|
[](https://www.sqlite.org/)
|
||||||
[](Dockerfile)
|
[](Dockerfile)
|
||||||
[](translations/)
|
[](translations/)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||||
@@ -36,15 +36,48 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **⚠️ Name disambiguation:** There is an unrelated iOS app also called **EverShelf**, developed and published by [Joshumi Technologies LLC](https://evershelf.joshumi.com/) on the [Apple App Store](https://apps.apple.com/app/evershelf/id6759439940). That application is a **completely separate, independent product** with no affiliation, association, or collaboration with this open-source project. This repository has no connection to Joshumi Technologies LLC, its products, or its services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
### 🏠 NEW — Home Assistant Integration
|
||||||
|
|
||||||
|
EverShelf has a **native Home Assistant integration** available on HACS.
|
||||||
|
Connect your pantry to your smart home in minutes — no YAML, no manual sensor setup.
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
|
||||||
|
|
||||||
|
**What you get:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
|
||||||
|
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
|
||||||
|
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
|
||||||
|
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
|
||||||
|
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
|
||||||
|
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
|
||||||
|
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
|
||||||
|
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
|
||||||
|
| **5 languages** | English, Italian, German, French, Spanish |
|
||||||
|
|
||||||
|
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
|
||||||
|
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 📦 Inventory Management
|
### 📦 Inventory Management
|
||||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
|
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
|
||||||
|
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS; last 20 scanned products saved as tappable chips so you can re-select them without rescanning
|
||||||
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
|
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
|
||||||
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
||||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items; products sealed under vacuum are only flagged as expired after a configurable grace period past the printed date (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days, configurable in `.env`)
|
||||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
|
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
|
||||||
|
|
||||||
### 🤖 AI-Powered (Google Gemini)
|
### 🤖 AI-Powered (Google Gemini)
|
||||||
@@ -53,6 +86,7 @@
|
|||||||
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
|
- **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
|
- **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 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
|
- **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
|
- **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
|
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||||
@@ -68,6 +102,7 @@
|
|||||||
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
|
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
|
||||||
|
|
||||||
### 🍳 Cooking Mode
|
### 🍳 Cooking Mode
|
||||||
|
- **♻️ Zero-waste tips** — For each cooking step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.), a dismissible ♻️ tip card appears with a practical reuse idea; tips are generated by Gemini as part of the recipe at no extra API cost; opt-in toggle in Settings (default OFF)
|
||||||
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
||||||
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
||||||
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
||||||
@@ -90,11 +125,29 @@
|
|||||||
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
|
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
|
||||||
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
|
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
|
||||||
|
|
||||||
### 📱 Progressive Web App
|
### 🌙 Appearance
|
||||||
|
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
|
||||||
|
- **Global settings tab** — A dedicated **⚙️ General** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
|
||||||
|
|
||||||
|
### �️ Database Maintenance
|
||||||
|
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
|
||||||
|
- **Manual cleanup** — Trigger immediately via `GET /api/?action=db_cleanup`
|
||||||
|
- **Compact by default** — Fresh installs stay small; large accumulated databases shrink back to a few hundred KB within one cron cycle
|
||||||
|
|
||||||
|
### �📱 Progressive Web App
|
||||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||||
- **Installable** — Add to home screen for a native app experience
|
- **Installable** — Add to home screen for a native app experience
|
||||||
- **Multi-device** — Settings and data sync across devices on the same server
|
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
||||||
|
### 📶 Offline Mode
|
||||||
|
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
|
||||||
|
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
|
||||||
|
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
|
||||||
|
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
|
||||||
|
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
|
||||||
|
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
|
||||||
|
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
|
||||||
|
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
|
||||||
|
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
|
||||||
### ⚖️ Smart Scale Integration (Add-on)
|
### ⚖️ Smart Scale Integration (Add-on)
|
||||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||||
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
||||||
@@ -182,12 +235,35 @@ TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
|||||||
TTS_TOKEN=your_long_lived_token
|
TTS_TOKEN=your_long_lived_token
|
||||||
TTS_ENABLED=true
|
TTS_ENABLED=true
|
||||||
|
|
||||||
# Optional: Security — protect the save_settings endpoint
|
# Optional: DB retention and cleanup (applied automatically each cron cycle)
|
||||||
# Set a strong random string; the Settings UI will ask for it before saving
|
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)
|
||||||
|
|
||||||
|
# Optional: Vacuum-sealed expiry grace period
|
||||||
|
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
|
||||||
|
|
||||||
|
# Optional: Gemini cost rates (USD per million tokens, for the Info tab cost estimate)
|
||||||
|
GEMINI_COST_25F_IN=0.15
|
||||||
|
GEMINI_COST_25F_OUT=0.60
|
||||||
|
GEMINI_COST_20F_IN=0.10
|
||||||
|
GEMINI_COST_20F_OUT=0.40
|
||||||
|
|
||||||
|
# Optional: Security — protect all API endpoints
|
||||||
|
# Set a strong random string; clients send it as X-API-Token header (or ?api_token= for HA)
|
||||||
|
API_TOKEN=
|
||||||
|
|
||||||
|
# Optional: Legacy alias for API_TOKEN (settings save only)
|
||||||
SETTINGS_TOKEN=
|
SETTINGS_TOKEN=
|
||||||
|
|
||||||
# Optional: Demo mode — block all write operations at the router level
|
# Optional: Demo mode — block all write operations at the router level
|
||||||
DEMO_MODE=false
|
DEMO_MODE=false
|
||||||
|
|
||||||
|
# Optional: Logging
|
||||||
|
# LOG_LEVEL sets the minimum severity written to disk (DEBUG / INFO / WARN / ERROR)
|
||||||
|
# DEBUG also logs every SQL query executed against the database
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_ROTATE_HOURS=24 # hours before opening a new log file (default: 24)
|
||||||
|
LOG_MAX_FILES=14 # maximum number of rotated files to keep (default: 14)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Web Server Configuration
|
### Web Server Configuration
|
||||||
@@ -259,6 +335,24 @@ The included `backup.sh` creates local daily backups of your database:
|
|||||||
0 3 * * * /path/to/evershelf/backup.sh
|
0 3 * * * /path/to/evershelf/backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Google Drive Backup (Optional)
|
||||||
|
|
||||||
|
EverShelf supports automatic daily backups to Google Drive via OAuth 2.0. This works on any server, including private IP / local network setups (no public domain required).
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select or create a project.
|
||||||
|
2. Enable the **Google Drive API** (`APIs & Services → Enable APIs → Google Drive API`).
|
||||||
|
3. Go to `APIs & Services → Credentials → Create Credentials → OAuth client ID`.
|
||||||
|
4. Application type: **Web application**.
|
||||||
|
5. Add **`http://localhost`** as an Authorized Redirect URI (this is the key — it works even without a real domain).
|
||||||
|
6. Copy **Client ID** and **Client Secret** into EverShelf Settings → Backup.
|
||||||
|
7. Enter your **Google Drive Folder ID** (the last part of the folder URL).
|
||||||
|
8. Click **Authorize with Google** and sign in.
|
||||||
|
9. The browser will redirect to `http://localhost` and may show a connection error — **this is expected**. Copy the full URL from the address bar (e.g. `http://localhost/?code=4%2F0A...`) and paste it into the field that appears in EverShelf, then click **Submit**.
|
||||||
|
|
||||||
|
> **Note:** While the OAuth app is in *Testing* status in Google Cloud Console, you must add your Google account as a test user under `APIs & Services → OAuth consent screen → Test users`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
@@ -326,8 +420,11 @@ evershelf-kiosk/ # 📺 Android kiosk app (add-on)
|
|||||||
|
|
||||||
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||||
- **Database** stays local — never pushed to remote repositories
|
- **Database** stays local — never pushed to remote repositories
|
||||||
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
|
- **Apache/Nginx hardening** — `.env`, `data/`, and `logs/` are blocked from direct HTTP access
|
||||||
- **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
|
- **API token** — set `API_TOKEN` in `.env` to require `X-API-Token` on all API calls (Home Assistant: `?api_token=`)
|
||||||
|
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `ha_token_set`, …)
|
||||||
|
- **GitHub Issues token** — stored encrypted as `GH_ISSUE_TOKEN_ENC` + `GH_ISSUE_TOKEN_KEY` (see `scripts/encrypt-gh-token.php`)
|
||||||
|
- **Settings write protection** — `save_settings` requires the same API token when configured; validated with `hash_equals`
|
||||||
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
|
- **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
|
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
||||||
@@ -352,35 +449,7 @@ The application uses no build tools — edit files directly and refresh.
|
|||||||
|
|
||||||
## 📋 Roadmap
|
## 📋 Roadmap
|
||||||
|
|
||||||
### High Priority
|
Feature requests, bug reports and planned work are tracked in the [**EverShelf Roadmap**](https://github.com/users/dadaloop82/projects/2) GitHub Project.
|
||||||
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
|
|
||||||
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
|
|
||||||
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
|
|
||||||
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
|
|
||||||
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
|
|
||||||
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
|
|
||||||
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
|
|
||||||
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
|
|
||||||
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
|
|
||||||
|
|
||||||
### Low Priority / Nice to Have
|
|
||||||
- [ ] **Dark mode** — CSS custom properties are already structured to support it
|
|
||||||
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
|
|
||||||
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
|
|
||||||
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
|
|
||||||
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
|
|
||||||
|
|
||||||
### Completed ✅
|
|
||||||
- ✅ AI price estimation in shopping list
|
|
||||||
- ✅ Server heartbeat + offline banner
|
|
||||||
- ✅ In-app bug reporter → automatic GitHub issue creation
|
|
||||||
- ✅ Cooking mode (start, steps, 3D wheel CSS)
|
|
||||||
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
|
|
||||||
- ✅ Adaptive consumption anomaly detection
|
|
||||||
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -393,6 +462,8 @@ The app supports multiple languages via JSON translation files in the `translati
|
|||||||
| 🇮🇹 Italian (it) | ✅ Complete (base) |
|
| 🇮🇹 Italian (it) | ✅ Complete (base) |
|
||||||
| 🇬🇧 English (en) | ✅ Complete |
|
| 🇬🇧 English (en) | ✅ Complete |
|
||||||
| 🇩🇪 German (de) | ✅ Complete |
|
| 🇩🇪 German (de) | ✅ Complete |
|
||||||
|
| 🇫🇷 French (fr) | ✅ Complete |
|
||||||
|
| 🇪🇸 Spanish (es) | ✅ Complete |
|
||||||
|
|
||||||
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
|
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
|
||||||
|
|
||||||
@@ -408,6 +479,48 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
|
|||||||
4. Push to the branch (`git push origin feature/my-feature`)
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Easiest way to start — translate EverShelf into your language
|
||||||
|
|
||||||
|
Translations are just JSON files. No coding, no setup — fork → edit → PR.
|
||||||
|
|
||||||
|
```
|
||||||
|
translations/
|
||||||
|
├── it.json ✅ Italian (base)
|
||||||
|
├── en.json ✅ English
|
||||||
|
├── de.json ✅ German
|
||||||
|
├── fr.json ✅ French
|
||||||
|
├── es.json ✅ Spanish
|
||||||
|
├── pt.json ❌ Portuguese — wanted!
|
||||||
|
├── nl.json ❌ Dutch — wanted!
|
||||||
|
└── ... ❌ Your language here!
|
||||||
|
```
|
||||||
|
|
||||||
|
👉 See [issue #93](https://github.com/dadaloop82/EverShelf/issues/93) to claim a language.
|
||||||
|
|
||||||
|
### Other ways to contribute
|
||||||
|
|
||||||
|
| What | Skill needed |
|
||||||
|
|---|---|
|
||||||
|
| 🐛 Report a bug | None |
|
||||||
|
| 📖 Improve the wiki | Markdown |
|
||||||
|
| 🌍 Add a translation | JSON editing |
|
||||||
|
| 🎨 Fix a CSS/UI issue | CSS / HTML |
|
||||||
|
| ⚙️ Implement a feature | PHP / JS |
|
||||||
|
| ⭐ Star the repo | Clicking |
|
||||||
|
|
||||||
|
👉 Browse [`help wanted`](https://github.com/dadaloop82/EverShelf/labels/help%20wanted) issues for good starting points.
|
||||||
|
|
||||||
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide (branch naming, code style, how to run locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Community
|
||||||
|
|
||||||
|
Join the conversation in [GitHub Discussions](https://github.com/dadaloop82/EverShelf/discussions):
|
||||||
|
- **Vote on upcoming features** — tell us what to build next
|
||||||
|
- **Show your setup** — share your kitchen kiosk
|
||||||
|
- **Ask questions** — get help from the community
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EverShelf API bootstrap — shared by HTTP router and cron.
|
||||||
|
*/
|
||||||
|
// Never emit HTML notices before JSON API responses (breaks fetch().json() in the PWA).
|
||||||
|
if (!defined('CRON_MODE') && (getenv('DISPLAY_ERRORS') ?: '') !== '1') {
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('html_errors', '0');
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/lib/env.php';
|
||||||
|
require_once __DIR__ . '/lib/constants.php';
|
||||||
|
require_once __DIR__ . '/lib/github.php';
|
||||||
|
require_once __DIR__ . '/lib/security.php';
|
||||||
|
require_once __DIR__ . '/lib/cron_log.php';
|
||||||
|
require_once __DIR__ . '/logger.php';
|
||||||
|
require_once __DIR__ . '/database.php';
|
||||||
+192
-5
@@ -11,14 +11,16 @@ if (PHP_SAPI !== 'cli') {
|
|||||||
exit('Forbidden');
|
exit('Forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define CRON_MODE before loading index.php so the router is skipped
|
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
|
||||||
define('CRON_MODE', true);
|
define('CRON_MODE', true);
|
||||||
|
|
||||||
// Load all API functions without running the HTTP router
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once __DIR__ . '/index.php';
|
require_once __DIR__ . '/index.php';
|
||||||
|
|
||||||
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||||
|
|
||||||
|
evershelfRotateCronLog();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
|
|
||||||
@@ -42,9 +44,10 @@ try {
|
|||||||
$itemCount = count($decoded['items'] ?? []);
|
$itemCount = count($decoded['items'] ?? []);
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
||||||
|
|
||||||
// ── Bring! server-side cleanup ────────────────────────────────────────
|
// ── Bring! server-side sync ───────────────────────────────────────────
|
||||||
// After computing smart shopping, automatically remove stale Bring! items
|
// After computing smart shopping, remove stale Bring! items and push every
|
||||||
// and add/update critical ones. This runs fully server-side every cron cycle.
|
// product that needs restocking (esauriti, quasi finiti, previsione).
|
||||||
|
// Runs fully server-side every cron cycle (~5 min).
|
||||||
try {
|
try {
|
||||||
$cleanupResult = bringCleanupObsolete($db);
|
$cleanupResult = bringCleanupObsolete($db);
|
||||||
if (isset($cleanupResult['skipped'])) {
|
if (isset($cleanupResult['skipped'])) {
|
||||||
@@ -55,6 +58,21 @@ try {
|
|||||||
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dedupeResult = bringDedupeGenerics($db);
|
||||||
|
if (isset($dedupeResult['skipped'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe skipped: ' . $dedupeResult['skipped'] . "\n";
|
||||||
|
} else {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe — removed: ' . ($dedupeResult['removed'] ?? 0)
|
||||||
|
. ', merged specs: ' . ($dedupeResult['merged'] ?? 0) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$specsResult = bringSyncSpecs($db);
|
||||||
|
if (isset($specsResult['skipped'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs skipped: ' . $specsResult['skipped'] . "\n";
|
||||||
|
} else {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs — updated: ' . ($specsResult['updated'] ?? 0) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
$addResult = bringAutoAddCritical($db);
|
$addResult = bringAutoAddCritical($db);
|
||||||
if (isset($addResult['skipped'])) {
|
if (isset($addResult['skipped'])) {
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
||||||
@@ -62,6 +80,11 @@ try {
|
|||||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
||||||
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dedupeFinal = bringDedupeGenerics($db);
|
||||||
|
if (!isset($dedupeFinal['skipped']) && (($dedupeFinal['removed'] ?? 0) > 0)) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe (final) — removed: ' . ($dedupeFinal['removed'] ?? 0) . "\n";
|
||||||
|
}
|
||||||
} catch (Throwable $be) {
|
} catch (Throwable $be) {
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
||||||
}
|
}
|
||||||
@@ -79,6 +102,53 @@ try {
|
|||||||
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
|
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DB cleanup (retention policy) ────────────────────────────────────
|
||||||
|
// Delete old recipes and transactions based on .env retention settings.
|
||||||
|
try {
|
||||||
|
ob_start();
|
||||||
|
dbCleanup($db);
|
||||||
|
ob_end_clean();
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
||||||
|
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
||||||
|
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
|
||||||
|
} catch (Throwable $ce) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daily incremental backup ──────────────────────────────────────────
|
||||||
|
// Create a local backup at most once every 23 h; also push to Google Drive
|
||||||
|
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
|
||||||
|
// though the cron runs every 5 minutes.
|
||||||
|
if (env('BACKUP_ENABLED', 'true') === 'true') {
|
||||||
|
try {
|
||||||
|
$lastBackupTs = 0;
|
||||||
|
if (file_exists(BACKUP_LAST_TS_PATH)) {
|
||||||
|
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
|
||||||
|
$lastBackupTs = (int)($lastData['ts'] ?? 0);
|
||||||
|
}
|
||||||
|
if (time() - $lastBackupTs >= 82800) { // 23 h
|
||||||
|
$backupResult = createLocalBackup($db);
|
||||||
|
if ($backupResult['success']) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
|
||||||
|
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
|
||||||
|
if (env('GDRIVE_ENABLED', 'false') === 'true') {
|
||||||
|
$gResult = backupToGDrive($db);
|
||||||
|
if ($gResult['success']) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
|
||||||
|
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
|
||||||
|
} else {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $be) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$msg = $e->getMessage();
|
$msg = $e->getMessage();
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||||
@@ -86,3 +156,120 @@ try {
|
|||||||
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
|
||||||
|
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
|
||||||
|
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||||
|
try {
|
||||||
|
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
|
||||||
|
if (!file_exists($haFlagFile)) {
|
||||||
|
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||||
|
$expiringItems = $db->query(
|
||||||
|
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||||
|
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||||
|
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||||
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||||
|
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||||
|
ORDER BY i.expiry_date ASC LIMIT 20"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$expiredItems = $db->query(
|
||||||
|
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||||
|
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||||
|
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||||
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||||
|
AND i.expiry_date < date('now')
|
||||||
|
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Normalise rows to full product format
|
||||||
|
if (!function_exists('_haFormatProduct')) {
|
||||||
|
function _haFormatProduct(array $row): array {
|
||||||
|
$daysRemaining = null;
|
||||||
|
if (!empty($row['expiry_date'])) {
|
||||||
|
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||||
|
$daysRemaining = (int)$diff->format('%r%a');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'product_id' => (int)($row['product_id'] ?? 0),
|
||||||
|
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||||
|
'name' => $row['name'],
|
||||||
|
'brand' => $row['brand'] ?? null,
|
||||||
|
'category' => $row['category'] ?? null,
|
||||||
|
'quantity' => (float)($row['quantity'] ?? 0),
|
||||||
|
'unit' => $row['unit'] ?? '',
|
||||||
|
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||||
|
'package_unit' => $row['package_unit'] ?? null,
|
||||||
|
'location' => $row['location'] ?? null,
|
||||||
|
'expiry_date' => $row['expiry_date'] ?? null,
|
||||||
|
'days_remaining' => $daysRemaining,
|
||||||
|
'opened_at' => $row['opened_at'] ?? null,
|
||||||
|
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$expiringItems = array_map('_haFormatProduct', $expiringItems);
|
||||||
|
$expiredItems = array_map('_haFormatProduct', $expiredItems);
|
||||||
|
|
||||||
|
if (!empty($expiringItems)) {
|
||||||
|
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||||
|
_fireHaWebhook('expiry_alert', [
|
||||||
|
'count' => count($expiringItems),
|
||||||
|
'items' => $expiringItems,
|
||||||
|
'type' => 'expiring_soon',
|
||||||
|
'days' => $expiryDays,
|
||||||
|
'summary' => $names,
|
||||||
|
]);
|
||||||
|
// Also send HA notification if service configured
|
||||||
|
if (env('HA_NOTIFY_SERVICE', '') !== '') {
|
||||||
|
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
|
||||||
|
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
|
||||||
|
}
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($expiredItems)) {
|
||||||
|
$expNames = implode(', ', array_column($expiredItems, 'name'));
|
||||||
|
_fireHaWebhook('expiry_alert', [
|
||||||
|
'count' => count($expiredItems),
|
||||||
|
'items' => $expiredItems,
|
||||||
|
'type' => 'expired',
|
||||||
|
'summary' => $expNames,
|
||||||
|
]);
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as done for today
|
||||||
|
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
|
||||||
|
// Clean up old flag files (keep last 7 days)
|
||||||
|
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
|
||||||
|
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
|
||||||
|
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $haE) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
|
||||||
|
// If avahi-daemon is running on this host, register the _evershelf._tcp service
|
||||||
|
// so that Home Assistant can auto-discover this instance via Zeroconf.
|
||||||
|
if (function_exists('shell_exec')) {
|
||||||
|
try {
|
||||||
|
$avahiService = '/etc/avahi/services/evershelf.xml';
|
||||||
|
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
|
||||||
|
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
|
||||||
|
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
|
||||||
|
if (file_exists($template)) {
|
||||||
|
$xml = file_get_contents($template);
|
||||||
|
@file_put_contents($avahiService, $xml);
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $avahiE) {
|
||||||
|
// Non-fatal: avahi not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+124
-3
@@ -38,11 +38,39 @@ function _ensureDataDir(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensure the SQLite DB and WAL sidecar files are writable (Docker volume first-boot). */
|
||||||
|
function _ensureDbWritable(): void {
|
||||||
|
if (!file_exists(DB_PATH)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!is_writable(DB_PATH)) {
|
||||||
|
@chmod(DB_PATH, 0664);
|
||||||
|
}
|
||||||
|
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $sidecar) {
|
||||||
|
if (file_exists($sidecar) && !is_writable($sidecar)) {
|
||||||
|
@chmod($sidecar, 0664);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDB(): PDO {
|
function getDB(): PDO {
|
||||||
_ensureDataDir();
|
_ensureDataDir();
|
||||||
|
_ensureDbWritable();
|
||||||
|
// logger.php is required by index.php before getDB() is called.
|
||||||
|
// In cron context it may not be loaded yet — guard with class_exists.
|
||||||
|
$useLogging = class_exists('LoggingPDO', false);
|
||||||
$isNew = !file_exists(DB_PATH);
|
$isNew = !file_exists(DB_PATH);
|
||||||
$db = new PDO('sqlite:' . DB_PATH);
|
$db = $useLogging
|
||||||
|
? new LoggingPDO('sqlite:' . DB_PATH)
|
||||||
|
: new PDO('sqlite:' . DB_PATH);
|
||||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
|
||||||
|
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
|
||||||
|
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||||
|
// For SQLite, we use PRAGMA busy_timeout.
|
||||||
|
$db->exec('PRAGMA journal_mode = WAL;');
|
||||||
|
$db->exec('PRAGMA busy_timeout = 10000;'); // 10 s — cron + PWA writes can contend under WAL
|
||||||
|
|
||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec("PRAGMA journal_mode=WAL");
|
$db->exec("PRAGMA journal_mode=WAL");
|
||||||
$db->exec("PRAGMA foreign_keys=ON");
|
$db->exec("PRAGMA foreign_keys=ON");
|
||||||
@@ -60,6 +88,29 @@ function getDB(): PDO {
|
|||||||
return $db;
|
return $db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a DB write when SQLite returns "database is locked" (concurrent cron + API).
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param callable(): T $fn
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
function dbWithRetry(callable $fn, int $maxAttempts = 4): mixed {
|
||||||
|
$attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return $fn();
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
$attempt++;
|
||||||
|
$locked = str_contains($e->getMessage(), 'database is locked');
|
||||||
|
if (!$locked || $attempt >= $maxAttempts) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
usleep(150000 * $attempt); // 150 ms, 300 ms, 450 ms …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initializeDB(PDO $db): void {
|
function initializeDB(PDO $db): void {
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
@@ -114,6 +165,16 @@ function initializeDB(PDO $db): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function migrateDB(PDO $db): void {
|
function migrateDB(PDO $db): void {
|
||||||
|
// Guard: if core tables don't exist yet (e.g. DB file present but empty / partial init),
|
||||||
|
// run initializeDB first so all tables are created, then return — no ALTER TABLE needed.
|
||||||
|
$productsExists = $db->query(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='products'"
|
||||||
|
)->fetchColumn();
|
||||||
|
if (!$productsExists) {
|
||||||
|
initializeDB($db);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add package_unit column if missing
|
// Add package_unit column if missing
|
||||||
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
||||||
$colNames = array_column($cols, 'name');
|
$colNames = array_column($cols, 'name');
|
||||||
@@ -126,6 +187,24 @@ function migrateDB(PDO $db): void {
|
|||||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty barcode strings break UNIQUE (only one '' allowed); normalize to NULL.
|
||||||
|
$db->exec("UPDATE products SET barcode = NULL WHERE barcode IS NOT NULL AND TRIM(barcode) = ''");
|
||||||
|
|
||||||
|
$invCols = $db->query("PRAGMA table_info(inventory)")->fetchAll();
|
||||||
|
$invColNames = array_column($invCols, 'name');
|
||||||
|
if (!in_array('expiry_user_set', $invColNames)) {
|
||||||
|
try { $db->exec("ALTER TABLE inventory ADD COLUMN expiry_user_set INTEGER DEFAULT 0"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS barcode_cache (
|
||||||
|
barcode TEXT PRIMARY KEY,
|
||||||
|
found INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)");
|
||||||
|
|
||||||
// Migrate transactions CHECK constraint to allow 'waste' type
|
// Migrate transactions CHECK constraint to allow 'waste' type
|
||||||
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
|
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
|
||||||
if ($sql && strpos($sql, "'waste'") === false) {
|
if ($sql && strpos($sql, "'waste'") === false) {
|
||||||
@@ -239,6 +318,36 @@ function migrateDB(PDO $db): void {
|
|||||||
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
||||||
|
|
||||||
|
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
|
||||||
|
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
|
||||||
|
if (empty($shopTables)) {
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE shopping_list (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
raw_name TEXT NOT NULL DEFAULT '',
|
||||||
|
specification TEXT NOT NULL DEFAULT '',
|
||||||
|
added_at INTEGER DEFAULT (strftime('%s','now')),
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add is_favorite column to recipes if missing (#124)
|
||||||
|
$recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name');
|
||||||
|
if (!in_array('is_favorite', $recCols)) {
|
||||||
|
try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add nutriments_json column to products if missing (#118)
|
||||||
|
$prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
|
||||||
|
if (!in_array('nutriments_json', $prodCols2)) {
|
||||||
|
try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -379,8 +488,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
|
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
|
||||||
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
||||||
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||||
|
// Specific hard cheeses that contain 'fresco' in their commercial name (e.g. Asiago fresco)
|
||||||
|
// must be matched BEFORE the generic 'formaggio fresco' catch-all
|
||||||
|
if (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) return 28;
|
||||||
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||||
if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21;
|
|
||||||
if (preg_match('/formaggio/', $n)) return 10;
|
if (preg_match('/formaggio/', $n)) return 10;
|
||||||
if (preg_match('/\bburro\b/', $n)) return 30;
|
if (preg_match('/\bburro\b/', $n)) return 30;
|
||||||
if (preg_match('/\bpanna\b/', $n)) return 4;
|
if (preg_match('/\bpanna\b/', $n)) return 4;
|
||||||
@@ -389,6 +500,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
|
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
|
||||||
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
|
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
|
||||||
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
|
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
|
||||||
|
if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) return 7;
|
||||||
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
|
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
|
||||||
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
|
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
|
||||||
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
|
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
|
||||||
@@ -410,6 +522,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||||
|
|
||||||
|
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
|
||||||
|
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||||
|
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
|
||||||
|
// Packaged sliced bread — preservatives help a bit
|
||||||
|
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
|
||||||
|
// Generic bread / sandwich bread in fridge
|
||||||
|
if (preg_match('/\bpane\b/', $cat)) return 3;
|
||||||
|
|
||||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||||
if (preg_match('/\bketchup\b/', $n)) return 90;
|
if (preg_match('/\bketchup\b/', $n)) return 90;
|
||||||
@@ -449,7 +569,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
elseif (preg_match('/yogurt/', $n)) $days = 21;
|
elseif (preg_match('/yogurt/', $n)) $days = 21;
|
||||||
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
|
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
|
||||||
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
|
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
|
||||||
elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60;
|
elseif (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) $days = 60;
|
||||||
elseif (preg_match('/burro/', $n)) $days = 60;
|
elseif (preg_match('/burro/', $n)) $days = 60;
|
||||||
elseif (preg_match('/panna/', $n)) $days = 14;
|
elseif (preg_match('/panna/', $n)) $days = 14;
|
||||||
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
|
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
|
||||||
@@ -458,6 +578,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
elseif (preg_match('/uova/', $n)) $days = 28;
|
elseif (preg_match('/uova/', $n)) $days = 28;
|
||||||
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
|
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
|
||||||
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
|
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
|
||||||
|
elseif (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) $days = 7;
|
||||||
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
|
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
|
||||||
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
|
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
|
||||||
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
|
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
|
||||||
|
|||||||
+6862
-1458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EverShelf — shared path constants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
|
||||||
|
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||||
|
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
|
||||||
|
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
|
||||||
|
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
|
||||||
|
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
|
||||||
|
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
|
||||||
|
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
|
||||||
|
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
|
||||||
|
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
|
||||||
|
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
|
||||||
|
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
|
||||||
|
|
||||||
|
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
|
||||||
|
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
|
||||||
|
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
|
||||||
|
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rotate data/cron.log — keep last N MB / lines.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/constants.php';
|
||||||
|
|
||||||
|
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
|
||||||
|
$path = CRON_LOG_PATH;
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
|
||||||
|
$size = filesize($path);
|
||||||
|
if ($size === false || $size <= $maxBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for ($i = $keepRotated; $i >= 1; $i--) {
|
||||||
|
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
|
||||||
|
$to = $path . '.' . $i;
|
||||||
|
if ($i === $keepRotated && file_exists($to)) {
|
||||||
|
@unlink($to);
|
||||||
|
}
|
||||||
|
if (file_exists($from)) {
|
||||||
|
@rename($from, $to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EverShelf — environment variable loader (.env).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function loadEnv(): array {
|
||||||
|
static $cache = null;
|
||||||
|
if ($cache !== null) {
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
$envFile = dirname(__DIR__, 2) . '/.env';
|
||||||
|
$cache = [];
|
||||||
|
if (file_exists($envFile)) {
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$key, $val] = explode('=', $line, 2);
|
||||||
|
$cache[trim($key)] = trim($val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function env(string $key, string $default = ''): string {
|
||||||
|
$vars = loadEnv();
|
||||||
|
return $vars[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a single key into the in-memory env cache (after .env write). */
|
||||||
|
function envCacheSet(string $key, string $value): void {
|
||||||
|
loadEnv();
|
||||||
|
// Force reload on next call — callers should use loadEnv() return for batch updates
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
|
||||||
|
*
|
||||||
|
* Configure ONE of:
|
||||||
|
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
|
||||||
|
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
|
||||||
|
*
|
||||||
|
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/env.php';
|
||||||
|
|
||||||
|
function evershelfDecryptGhToken(string $encB64, string $key): string {
|
||||||
|
$raw = base64_decode($encB64, true);
|
||||||
|
if ($raw === false || strlen($raw) < 28) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$iv = substr($raw, 0, 12);
|
||||||
|
$tag = substr($raw, 12, 16);
|
||||||
|
$cipher = substr($raw, 28);
|
||||||
|
$plain = openssl_decrypt(
|
||||||
|
$cipher,
|
||||||
|
'aes-256-gcm',
|
||||||
|
hash('sha256', $key, true),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$iv,
|
||||||
|
$tag
|
||||||
|
);
|
||||||
|
return ($plain !== false) ? $plain : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfEncryptGhToken(string $plain, string $key): string {
|
||||||
|
$iv = random_bytes(12);
|
||||||
|
$tag = '';
|
||||||
|
$cipher = openssl_encrypt(
|
||||||
|
$plain,
|
||||||
|
'aes-256-gcm',
|
||||||
|
hash('sha256', $key, true),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$iv,
|
||||||
|
$tag
|
||||||
|
);
|
||||||
|
return base64_encode($iv . $tag . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode GitHub Issues token at runtime — never stored in source code. */
|
||||||
|
function _ghToken(): string {
|
||||||
|
static $token = null;
|
||||||
|
if ($token !== null) {
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = env('GH_ISSUE_TOKEN');
|
||||||
|
if ($plain !== '') {
|
||||||
|
$token = $plain;
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enc = env('GH_ISSUE_TOKEN_ENC');
|
||||||
|
$key = env('GH_ISSUE_TOKEN_KEY');
|
||||||
|
if ($enc !== '' && $key !== '') {
|
||||||
|
$token = evershelfDecryptGhToken($enc, $key);
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = '';
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/env.php';
|
||||||
|
|
||||||
|
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
|
||||||
|
function evershelfEffectiveApiToken(): string {
|
||||||
|
$api = env('API_TOKEN');
|
||||||
|
if ($api !== '') {
|
||||||
|
return $api;
|
||||||
|
}
|
||||||
|
return env('SETTINGS_TOKEN', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfApiTokenRequired(): bool {
|
||||||
|
return evershelfEffectiveApiToken() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfGetProvidedApiToken(): string {
|
||||||
|
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
|
||||||
|
return (string)$_SERVER['HTTP_X_API_TOKEN'];
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
|
||||||
|
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['api_token'])) {
|
||||||
|
return (string)$_GET['api_token'];
|
||||||
|
}
|
||||||
|
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
|
||||||
|
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
|
||||||
|
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||||
|
?? '';
|
||||||
|
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return evershelfGetProvidedApiTokenFromHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfApiTokenValid(): bool {
|
||||||
|
$required = evershelfEffectiveApiToken();
|
||||||
|
if ($required === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$provided = evershelfGetProvidedApiToken();
|
||||||
|
return $provided !== '' && hash_equals($required, $provided);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfGetProvidedApiTokenFromHeaders(): string {
|
||||||
|
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actions reachable without API token (telemetry + public probes). */
|
||||||
|
function evershelfPublicActions(): array {
|
||||||
|
return [
|
||||||
|
'ping',
|
||||||
|
'app_bootstrap',
|
||||||
|
'check_update',
|
||||||
|
'report_error',
|
||||||
|
'report_bug',
|
||||||
|
'client_log',
|
||||||
|
'gdrive_oauth_callback',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET actions that mutate state — require auth when token is configured. */
|
||||||
|
function evershelfMutatingGetActions(): array {
|
||||||
|
return ['db_cleanup', 'export_inventory'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfDestructiveActions(): array {
|
||||||
|
return [
|
||||||
|
'save_settings', 'db_cleanup',
|
||||||
|
'backup_now', 'backup_delete', 'backup_restore',
|
||||||
|
'gdrive_push', 'gdrive_oauth_exchange',
|
||||||
|
'migrate_units',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfActionNeedsAuth(string $action, string $method): bool {
|
||||||
|
if (!evershelfApiTokenRequired()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in_array($action, evershelfPublicActions(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($action, evershelfDestructiveActions(), true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Protect all data reads when API token is set
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfRequireApiAuth(string $action, string $method): void {
|
||||||
|
if (!evershelfActionNeedsAuth($action, $method)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evershelfApiTokenValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'unauthorized',
|
||||||
|
'api_token_required' => true,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfRequireAuthForSensitive(string $action): void {
|
||||||
|
if (!evershelfApiTokenRequired()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evershelfApiTokenValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfSendCorsHeaders(): void {
|
||||||
|
$configured = env('CORS_ORIGIN', '');
|
||||||
|
if ($configured === '') {
|
||||||
|
// Same-origin SPA — do not emit wildcard CORS
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($configured === '*') {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
} else {
|
||||||
|
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
$allowed = array_filter(array_map('trim', explode(',', $configured)));
|
||||||
|
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $reqOrigin);
|
||||||
|
header('Vary: Origin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only actions allowed in DEMO_MODE. */
|
||||||
|
function evershelfDemoReadOnlyActions(): array {
|
||||||
|
return [
|
||||||
|
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
|
||||||
|
'search_barcode', 'lookup_barcode', 'resolve_barcode', 'stock_for_name',
|
||||||
|
'product_get', 'products_list', 'products_search', 'inventory_search', 'ai_product_suggest',
|
||||||
|
'inventory_list', 'inventory_summary', 'inventory_finished_items',
|
||||||
|
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
|
||||||
|
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
|
||||||
|
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
|
||||||
|
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
|
||||||
|
'recipes_list', 'chat_list', 'app_settings_get',
|
||||||
|
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
|
||||||
|
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
|
||||||
|
'backup_list', 'export_inventory',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfDemoBlocksAction(string $action, string $method): bool {
|
||||||
|
if (env('DEMO_MODE') !== 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Block all AI generation in demo (cost + writes)
|
||||||
|
if (str_starts_with($action, 'gemini_') || in_array($action, [
|
||||||
|
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
|
||||||
|
], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($action, evershelfMutatingGetActions(), true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !in_array($action, evershelfDemoReadOnlyActions(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
|
||||||
|
function evershelfAllowedScaleHosts(): array {
|
||||||
|
$hosts = ['127.0.0.1', 'localhost', '::1'];
|
||||||
|
$gw = env('SCALE_GATEWAY_URL', '');
|
||||||
|
if ($gw !== '') {
|
||||||
|
$p = parse_url($gw);
|
||||||
|
if (!empty($p['host'])) {
|
||||||
|
$hosts[] = strtolower($p['host']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Server's own LAN IP — gateway may bind here on kiosk LAN
|
||||||
|
if (function_exists('gethostname')) {
|
||||||
|
$lan = gethostbyname(gethostname());
|
||||||
|
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
|
||||||
|
$hosts[] = $lan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values(array_unique($hosts));
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfScaleHostAllowed(string $host): bool {
|
||||||
|
$host = strtolower(trim($host));
|
||||||
|
if ($host === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (evershelfAllowedScaleHosts() as $allowed) {
|
||||||
|
if ($host === strtolower($allowed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
|
||||||
|
$serverIp = evershelfLocalLanIp();
|
||||||
|
if ($serverIp !== '') {
|
||||||
|
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
|
||||||
|
if (str_starts_with($host, $subnet . '.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evershelfLocalLanIp(): string {
|
||||||
|
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||||
|
if ($sock) {
|
||||||
|
@socket_connect($sock, '8.8.8.8', 53);
|
||||||
|
@socket_getsockname($sock, $ip);
|
||||||
|
socket_close($sock);
|
||||||
|
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the request comes from the EverShelf web UI on the same host.
|
||||||
|
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
|
||||||
|
*/
|
||||||
|
function evershelfIsSameOriginBrowser(): bool {
|
||||||
|
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||||
|
if ($host === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
if ($origin !== '') {
|
||||||
|
$oh = parse_url($origin, PHP_URL_HOST);
|
||||||
|
return $oh && strtolower($oh) === $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||||
|
if ($referer !== '') {
|
||||||
|
$rh = parse_url($referer, PHP_URL_HOST);
|
||||||
|
return $rh && strtolower($rh) === $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
|
||||||
|
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
|
||||||
|
function evershelfRequireScaleAccess(): void {
|
||||||
|
if (!evershelfApiTokenRequired()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evershelfApiTokenValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evershelfIsSameOriginBrowser()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EverShelf Logger — rotating file logger with 4 configurable levels.
|
||||||
|
*
|
||||||
|
* Levels (in order of verbosity):
|
||||||
|
* DEBUG(0) — ogni minima operazione: query, cache, AI payload, function entry/exit
|
||||||
|
* INFO (1) — azioni completate, AI result summary, sync status [default]
|
||||||
|
* WARN (2) — rate limit, cache miss, AI fallback, token renewal, slow op
|
||||||
|
* ERROR(3) — DB failure, AI API error, file write error, exception
|
||||||
|
*
|
||||||
|
* Config via .env (all optional):
|
||||||
|
* LOG_LEVEL = INFO (DEBUG|INFO|WARN|ERROR)
|
||||||
|
* LOG_ROTATE_HOURS = 24 (new file every N hours; 1–168; default 24)
|
||||||
|
* LOG_MAX_FILES = 14 (max rotated files to keep; default 14)
|
||||||
|
*
|
||||||
|
* Log files: logs/evershelf_YYYY-MM-DD_HH.log
|
||||||
|
* Each line: [2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {ctx}
|
||||||
|
*/
|
||||||
|
class EverLog {
|
||||||
|
|
||||||
|
// ── Level constants ────────────────────────────────────────────────────
|
||||||
|
const DEBUG = 0;
|
||||||
|
const INFO = 1;
|
||||||
|
const WARN = 2;
|
||||||
|
const ERROR = 3;
|
||||||
|
|
||||||
|
private static bool $initialized = false;
|
||||||
|
private static int $level = self::INFO;
|
||||||
|
private static string $logFile = '';
|
||||||
|
private static string $logDir = '';
|
||||||
|
private static int $rotateHours = 24;
|
||||||
|
private static int $maxFiles = 14;
|
||||||
|
private static string $requestId = '';
|
||||||
|
private static string $currentAction = '-';
|
||||||
|
|
||||||
|
// ── Init (called lazily on first write) ────────────────────────────────
|
||||||
|
private static function init(): void {
|
||||||
|
if (self::$initialized) return;
|
||||||
|
self::$initialized = true;
|
||||||
|
|
||||||
|
// Read .env values via getenv() (populated by Apache SetEnv or putenv() in index.php)
|
||||||
|
$envLevel = strtoupper((string)(getenv('LOG_LEVEL') ?: 'INFO'));
|
||||||
|
$rotateHours = max(1, min(168, (int)(getenv('LOG_ROTATE_HOURS') ?: 24)));
|
||||||
|
$maxFiles = max(1, min(365, (int)(getenv('LOG_MAX_FILES') ?: 14)));
|
||||||
|
|
||||||
|
self::$level = match($envLevel) {
|
||||||
|
'DEBUG' => self::DEBUG,
|
||||||
|
'WARN' => self::WARN,
|
||||||
|
'ERROR' => self::ERROR,
|
||||||
|
default => self::INFO,
|
||||||
|
};
|
||||||
|
self::$rotateHours = $rotateHours;
|
||||||
|
self::$maxFiles = $maxFiles;
|
||||||
|
self::$requestId = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
|
|
||||||
|
// Ensure log directory exists
|
||||||
|
$base = dirname(__DIR__) . '/logs';
|
||||||
|
self::$logDir = $base;
|
||||||
|
if (!is_dir($base)) {
|
||||||
|
@mkdir($base, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute current log file path (slot by rotate-hours bucket)
|
||||||
|
$slotTs = (int)(floor(time() / ($rotateHours * 3600)) * ($rotateHours * 3600));
|
||||||
|
$slotLabel = gmdate('Y-m-d_H', $slotTs);
|
||||||
|
self::$logFile = "$base/evershelf_{$slotLabel}.log";
|
||||||
|
|
||||||
|
// Rotate (delete oldest files beyond max)
|
||||||
|
self::rotate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rotate old log files ───────────────────────────────────────────────
|
||||||
|
private static function rotate(): void {
|
||||||
|
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||||
|
if (count($files) <= self::$maxFiles) return;
|
||||||
|
sort($files); // oldest first (filenames are lexicographically sortable by date)
|
||||||
|
$toDelete = array_slice($files, 0, count($files) - self::$maxFiles);
|
||||||
|
foreach ($toDelete as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core write ────────────────────────────────────────────────────────
|
||||||
|
private static function write(int $lvl, string $msg, array $ctx, string $action): void {
|
||||||
|
self::init();
|
||||||
|
if ($lvl < self::$level) return;
|
||||||
|
|
||||||
|
$labels = ['DEBUG', 'INFO ', 'WARN ', 'ERROR'];
|
||||||
|
$ts = gmdate('Y-m-d H:i:s');
|
||||||
|
$act = $action !== '-' ? $action : self::$currentAction;
|
||||||
|
$ctxStr = empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$line = "[{$ts}] [{$labels[$lvl]}] [rid=" . self::$requestId . "] [{$act}] {$msg}{$ctxStr}\n";
|
||||||
|
|
||||||
|
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Set the current action name (shown in every subsequent log line for this request). */
|
||||||
|
public static function setAction(string $action): void {
|
||||||
|
self::$currentAction = $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log at DEBUG level — every minor operation, query, cache hit/miss, AI payload. */
|
||||||
|
public static function debug(string $msg, array $ctx = [], string $action = '-'): void {
|
||||||
|
self::write(self::DEBUG, $msg, $ctx, $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log at INFO level — action completed, recipe generated, sync done. */
|
||||||
|
public static function info(string $msg, array $ctx = [], string $action = '-'): void {
|
||||||
|
self::write(self::INFO, $msg, $ctx, $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log at WARN level — rate limit, AI fallback, slow op, token renewal. */
|
||||||
|
public static function warn(string $msg, array $ctx = [], string $action = '-'): void {
|
||||||
|
self::write(self::WARN, $msg, $ctx, $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log at ERROR level — DB failure, AI API error, file write error, exception. */
|
||||||
|
public static function error(string $msg, array $ctx = [], string $action = '-'): void {
|
||||||
|
self::write(self::ERROR, $msg, $ctx, $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: log a Throwable at ERROR level with class + location. */
|
||||||
|
public static function exception(\Throwable $e, string $action = '-', array $extra = []): void {
|
||||||
|
self::write(self::ERROR, $e->getMessage(), array_merge([
|
||||||
|
'class' => get_class($e),
|
||||||
|
'at' => basename($e->getFile()) . ':' . $e->getLine(),
|
||||||
|
'trace' => substr($e->getTraceAsString(), 0, 800),
|
||||||
|
], $extra), $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log the start of an action request (INFO).
|
||||||
|
* Automatically sets the current action name so subsequent lines inherit it.
|
||||||
|
*/
|
||||||
|
public static function request(string $action, string $method, array $params = []): void {
|
||||||
|
self::setAction($action);
|
||||||
|
// At DEBUG: include all params; at INFO just the action+method
|
||||||
|
if (self::$level <= self::DEBUG) {
|
||||||
|
self::write(self::DEBUG, "→ {$method} /{$action}", $params, $action);
|
||||||
|
} else {
|
||||||
|
self::write(self::INFO, "→ {$method} /{$action}", [], $action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a DB query at DEBUG level.
|
||||||
|
* @param string $sql Truncated SQL or a descriptive label
|
||||||
|
* @param mixed $result Number of rows affected/returned (optional)
|
||||||
|
* @param float $elapsed Execution time in seconds (optional)
|
||||||
|
*/
|
||||||
|
public static function query(string $sql, $result = null, float $elapsed = 0.0): void {
|
||||||
|
if (self::$level > self::DEBUG) return; // skip entirely unless DEBUG
|
||||||
|
$ctx = [];
|
||||||
|
if ($result !== null) $ctx['rows'] = $result;
|
||||||
|
if ($elapsed > 0) $ctx['ms'] = round($elapsed * 1000, 1);
|
||||||
|
if ($elapsed > 1.0) $ctx['SLOW'] = true; // highlight slow queries even in context
|
||||||
|
self::write(self::DEBUG, 'DB: ' . substr($sql, 0, 200), $ctx, self::$currentAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a slow operation as WARN regardless of configured level.
|
||||||
|
* Call this after any operation that took more than $thresholdSec.
|
||||||
|
*/
|
||||||
|
public static function slowOp(string $label, float $elapsed, float $thresholdSec = 2.0): void {
|
||||||
|
if ($elapsed < $thresholdSec) return;
|
||||||
|
self::write(self::WARN, "SLOW_OP: {$label}", ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an AI call at INFO level (or DEBUG for full payload).
|
||||||
|
* @param string $model Model name (e.g. 'gemini-2.5-flash')
|
||||||
|
* @param int $promptLen Character length of the prompt
|
||||||
|
* @param bool $isFallback Whether this is the fallback model
|
||||||
|
*/
|
||||||
|
public static function aiCall(string $model, int $promptLen, bool $isFallback = false): void {
|
||||||
|
$ctx = ['model' => $model, 'prompt_chars' => $promptLen];
|
||||||
|
if ($isFallback) $ctx['fallback'] = true;
|
||||||
|
$level = $isFallback ? self::WARN : self::INFO;
|
||||||
|
self::write($level, 'AI call', $ctx, self::$currentAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an AI response at INFO level.
|
||||||
|
* @param string $model Model that responded
|
||||||
|
* @param int $outputLen Character length of output
|
||||||
|
* @param float $elapsed Call duration in seconds
|
||||||
|
* @param bool $ok Whether the call succeeded
|
||||||
|
* @param string $errorMsg Error message if not ok
|
||||||
|
*/
|
||||||
|
public static function aiResponse(string $model, int $outputLen, float $elapsed, bool $ok = true, string $errorMsg = ''): void {
|
||||||
|
$ctx = ['model' => $model, 'output_chars' => $outputLen, 'elapsed_s' => round($elapsed, 2)];
|
||||||
|
if (!$ok) {
|
||||||
|
$ctx['error'] = substr($errorMsg, 0, 200);
|
||||||
|
self::write(self::ERROR, 'AI error', $ctx, self::$currentAction);
|
||||||
|
} else {
|
||||||
|
self::write(self::INFO, 'AI ok', $ctx, self::$currentAction);
|
||||||
|
}
|
||||||
|
// Warn if over 10s
|
||||||
|
if ($ok && $elapsed > 10.0) {
|
||||||
|
self::write(self::WARN, 'AI response slow', ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a cache event at DEBUG level.
|
||||||
|
* @param string $cacheKey The cache key (or a label)
|
||||||
|
* @param bool $hit true = cache hit, false = cache miss
|
||||||
|
* @param string $cacheType 'file', 'session', 'memory'
|
||||||
|
*/
|
||||||
|
public static function cache(string $cacheKey, bool $hit, string $cacheType = 'file'): void {
|
||||||
|
if (self::$level > self::DEBUG) return;
|
||||||
|
self::write(self::DEBUG,
|
||||||
|
($hit ? 'CACHE HIT' : 'CACHE MISS') . " [{$cacheType}]",
|
||||||
|
['key' => substr($cacheKey, 0, 64)],
|
||||||
|
self::$currentAction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the last $lines log lines from all available log files, newest last.
|
||||||
|
* Used by the get_logs API endpoint.
|
||||||
|
*/
|
||||||
|
public static function tail(int $lines = 500): array {
|
||||||
|
self::init();
|
||||||
|
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||||
|
if (empty($files)) return [];
|
||||||
|
rsort($files); // newest file first
|
||||||
|
|
||||||
|
$collected = [];
|
||||||
|
foreach ($files as $f) {
|
||||||
|
if (count($collected) >= $lines) break;
|
||||||
|
$content = @file_get_contents($f);
|
||||||
|
if ($content === false) continue;
|
||||||
|
$fLines = array_filter(explode("\n", $content));
|
||||||
|
// Prepend so we read newest-first → older lines at front
|
||||||
|
$collected = array_merge(array_values($fLines), $collected);
|
||||||
|
}
|
||||||
|
// Return last $lines, newest at end (chronological order)
|
||||||
|
return array_values(array_slice($collected, -$lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List available log files with their sizes and date ranges. */
|
||||||
|
public static function listFiles(): array {
|
||||||
|
self::init();
|
||||||
|
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||||
|
rsort($files);
|
||||||
|
return array_map(fn($f) => [
|
||||||
|
'file' => basename($f),
|
||||||
|
'size_kb' => round(filesize($f) / 1024, 1),
|
||||||
|
'mtime' => date('Y-m-d H:i:s', filemtime($f)),
|
||||||
|
], $files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current effective level name. */
|
||||||
|
public static function levelName(): string {
|
||||||
|
self::init();
|
||||||
|
return ['DEBUG', 'INFO', 'WARN', 'ERROR'][self::$level];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current log file path. */
|
||||||
|
public static function currentFile(): string {
|
||||||
|
self::init();
|
||||||
|
return self::$logFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// LoggingPDOStatement — wraps PDOStatement to time and log every execute()
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class LoggingPDOStatement {
|
||||||
|
private \PDOStatement $stmt;
|
||||||
|
private string $sql;
|
||||||
|
|
||||||
|
public function __construct(\PDOStatement $stmt, string $sql) {
|
||||||
|
$this->stmt = $stmt;
|
||||||
|
$this->sql = $sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(?array $params = null): bool {
|
||||||
|
$t0 = microtime(true);
|
||||||
|
$ok = $this->stmt->execute($params);
|
||||||
|
$ms = round((microtime(true) - $t0) * 1000, 2);
|
||||||
|
$ctx = ['ms' => $ms, 'rows' => $this->stmt->rowCount()];
|
||||||
|
if ($ms > 500) $ctx['SLOW'] = true;
|
||||||
|
EverLog::query($this->sql, $this->stmt->rowCount(), (microtime(true) - $t0));
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch(int $mode = \PDO::FETCH_DEFAULT, ...$args): mixed {
|
||||||
|
return $this->stmt->fetch($mode, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchAll(int $mode = \PDO::FETCH_DEFAULT, ...$args): array {
|
||||||
|
return $this->stmt->fetchAll($mode ?: \PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchColumn(int $col = 0): mixed {
|
||||||
|
return $this->stmt->fetchColumn($col);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rowCount(): int {
|
||||||
|
return $this->stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindValue(int|string $param, mixed $value, int $type = \PDO::PARAM_STR): bool {
|
||||||
|
return $this->stmt->bindValue($param, $value, $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindParam(int|string $param, mixed &$var, int $type = \PDO::PARAM_STR, int $maxLength = 0): bool {
|
||||||
|
return $this->stmt->bindParam($param, $var, $type, $maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeCursor(): bool {
|
||||||
|
return $this->stmt->closeCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFetchMode(int $mode, mixed ...$args): bool {
|
||||||
|
return $this->stmt->setFetchMode($mode, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __get(string $name): mixed {
|
||||||
|
return $this->stmt->$name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __call(string $name, array $args): mixed {
|
||||||
|
return $this->stmt->$name(...$args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// LoggingPDO — wraps PDO to auto-log all prepare(), query(), exec()
|
||||||
|
// Drop-in replacement: return LoggingPDO from getDB() instead of PDO.
|
||||||
|
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class LoggingPDO extends \PDO {
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
|
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
||||||
|
$stmt = parent::prepare($query, $options);
|
||||||
|
if ($stmt === false) {
|
||||||
|
EverLog::error('PDO::prepare failed', ['sql' => substr($query, 0, 200)]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return new LoggingPDOStatement($stmt, $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): \PDOStatement|false {
|
||||||
|
$t0 = microtime(true);
|
||||||
|
$stmt = $fetchMode !== null
|
||||||
|
? parent::query($query, $fetchMode, ...$fetchModeArgs)
|
||||||
|
: parent::query($query);
|
||||||
|
$elapsed = microtime(true) - $t0;
|
||||||
|
if ($stmt !== false) {
|
||||||
|
EverLog::query($query, $stmt->rowCount(), $elapsed);
|
||||||
|
} else {
|
||||||
|
EverLog::error('PDO::query failed', ['sql' => substr($query, 0, 200)]);
|
||||||
|
}
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exec(string $statement): int|false {
|
||||||
|
// Skip WAL/PRAGMA logging below DEBUG (too noisy at startup)
|
||||||
|
$isPragma = stripos(ltrim($statement), 'PRAGMA') === 0;
|
||||||
|
$t0 = microtime(true);
|
||||||
|
$result = parent::exec($statement);
|
||||||
|
$elapsed = microtime(true) - $t0;
|
||||||
|
if (!$isPragma) {
|
||||||
|
EverLog::query($statement, $result === false ? 0 : $result, $elapsed);
|
||||||
|
} elseif (EverLog::DEBUG >= 0) {
|
||||||
|
// Log PRAGMAs only at DEBUG level
|
||||||
|
EverLog::query($statement, is_int($result) ? $result : 0, $elapsed);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-51
@@ -1,57 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* EverShelf Scale Gateway — Auto-discovery
|
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
|
||||||
*
|
|
||||||
* 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('Content-Type: application/json');
|
||||||
header('Cache-Control: no-cache');
|
header('Cache-Control: no-cache');
|
||||||
|
evershelfSendCorsHeaders();
|
||||||
|
|
||||||
$port = (int)($_GET['port'] ?? 8765);
|
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||||
if ($port < 1 || $port > 65535) $port = 8765;
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||||
// ── Determine server LAN IP ────────────────────────────────────────────────
|
exit;
|
||||||
// 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 = localLanIp();
|
// Simple rate limit: max 6 scans per minute per IP
|
||||||
|
$rlDir = dirname(__DIR__) . '/data/rate_limits';
|
||||||
|
if (!is_dir($rlDir)) {
|
||||||
|
@mkdir($rlDir, 0755, true);
|
||||||
|
}
|
||||||
|
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
|
||||||
|
$now = time();
|
||||||
|
$hits = [];
|
||||||
|
if (file_exists($rlFile)) {
|
||||||
|
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
|
||||||
|
}
|
||||||
|
if (count($hits) >= 6) {
|
||||||
|
http_response_code(429);
|
||||||
|
echo json_encode(['error' => 'Too many discovery scans']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$hits[] = $now;
|
||||||
|
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
|
||||||
|
|
||||||
|
$port = (int)($_GET['port'] ?? 8765);
|
||||||
|
if ($port < 1 || $port > 65535) {
|
||||||
|
$port = 8765;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverIp = evershelfLocalLanIp();
|
||||||
$parts = explode('.', $serverIp);
|
$parts = explode('.', $serverIp);
|
||||||
if (count($parts) !== 4) {
|
if (count($parts) !== 4) {
|
||||||
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
|
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
|
$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 = [];
|
$candidates = [];
|
||||||
for ($i = 1; $i <= 254; $i++) {
|
for ($i = 1; $i <= 254; $i++) {
|
||||||
$ip = $subnet . $i;
|
$ip = $subnet . $i;
|
||||||
@@ -74,25 +70,28 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
|||||||
$read = null;
|
$read = null;
|
||||||
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
||||||
$n = @stream_select($read, $write, $except, 0, $usec);
|
$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 = [];
|
$failed = [];
|
||||||
foreach ($except as $s) {
|
foreach ($except as $s) {
|
||||||
$ip = array_search($s, $candidates, true);
|
$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) {
|
foreach ($write as $s) {
|
||||||
$ip = array_search($s, $candidates, true);
|
$ip = array_search($s, $candidates, true);
|
||||||
if ($ip === false) continue;
|
if ($ip === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!isset($failed[$ip])) {
|
if (!isset($failed[$ip])) {
|
||||||
$found_tcp[] = $ip;
|
$found_tcp[] = $ip;
|
||||||
}
|
}
|
||||||
@fclose($s);
|
@fclose($s);
|
||||||
unset($candidates[$ip]);
|
unset($candidates[$ip]);
|
||||||
}
|
}
|
||||||
// Close failed sockets too
|
|
||||||
foreach ($failed as $ip => $_) {
|
foreach ($failed as $ip => $_) {
|
||||||
if (isset($candidates[$ip])) {
|
if (isset($candidates[$ip])) {
|
||||||
@fclose($candidates[$ip]);
|
@fclose($candidates[$ip]);
|
||||||
@@ -100,13 +99,16 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
|
foreach ($candidates as $s) {
|
||||||
|
@fclose($s);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
|
|
||||||
$gateways = [];
|
$gateways = [];
|
||||||
foreach ($found_tcp as $ip) {
|
foreach ($found_tcp as $ip) {
|
||||||
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
||||||
if (!$sock) continue;
|
if (!$sock) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
stream_set_timeout($sock, 2);
|
stream_set_timeout($sock, 2);
|
||||||
|
|
||||||
$key = base64_encode(random_bytes(16));
|
$key = base64_encode(random_bytes(16));
|
||||||
@@ -124,9 +126,13 @@ foreach ($found_tcp as $ip) {
|
|||||||
$dl = microtime(true) + 2;
|
$dl = microtime(true) + 2;
|
||||||
while (microtime(true) < $dl && !feof($sock)) {
|
while (microtime(true) < $dl && !feof($sock)) {
|
||||||
$line = fgets($sock, 256);
|
$line = fgets($sock, 256);
|
||||||
if ($line === false) break;
|
if ($line === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
$resp .= $line;
|
$resp .= $line;
|
||||||
if ($line === "\r\n") break;
|
if ($line === "\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fclose($sock);
|
fclose($sock);
|
||||||
|
|
||||||
@@ -138,5 +144,4 @@ foreach ($found_tcp as $ip) {
|
|||||||
echo json_encode([
|
echo json_encode([
|
||||||
'found' => $gateways,
|
'found' => $gateways,
|
||||||
'subnet' => rtrim($subnet, '.') . '.0/24',
|
'subnet' => rtrim($subnet, '.') . '.0/24',
|
||||||
'server_ip' => $serverIp,
|
|
||||||
]);
|
]);
|
||||||
|
|||||||
+16
-7
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* EverShelf Scale Gateway — Connection ping / test
|
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
|
||||||
*
|
|
||||||
* 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('Content-Type: application/json');
|
||||||
header('Cache-Control: no-cache');
|
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'] ?? '';
|
$rawUrl = $_GET['url'] ?? '';
|
||||||
|
|
||||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||||
@@ -19,7 +23,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$parsed = parse_url($rawUrl);
|
$parsed = parse_url($rawUrl);
|
||||||
$host = $parsed['host'] ?? '';
|
$host = strtolower($parsed['host'] ?? '');
|
||||||
$port = (int)($parsed['port'] ?? 8765);
|
$port = (int)($parsed['port'] ?? 8765);
|
||||||
$path = ($parsed['path'] ?? '') ?: '/';
|
$path = ($parsed['path'] ?? '') ?: '/';
|
||||||
|
|
||||||
@@ -28,6 +32,11 @@ if (!$host || $port < 1 || $port > 65535) {
|
|||||||
exit;
|
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
|
// Try to open a TCP connection with a 5-second timeout
|
||||||
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
||||||
if (!$sock) {
|
if (!$sock) {
|
||||||
|
|||||||
+17
-1
@@ -8,6 +8,16 @@
|
|||||||
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
* 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 ──────────────────────────────────────────────────────────
|
// ── Input validation ──────────────────────────────────────────────────────────
|
||||||
$rawUrl = $_GET['url'] ?? '';
|
$rawUrl = $_GET['url'] ?? '';
|
||||||
|
|
||||||
@@ -19,7 +29,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$parsed = parse_url($rawUrl);
|
$parsed = parse_url($rawUrl);
|
||||||
$wsHost = $parsed['host'] ?? '';
|
$wsHost = strtolower($parsed['host'] ?? '');
|
||||||
$wsPort = (int)($parsed['port'] ?? 8765);
|
$wsPort = (int)($parsed['port'] ?? 8765);
|
||||||
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
||||||
|
|
||||||
@@ -29,6 +39,12 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
|
|||||||
exit;
|
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 ───────────────────────────────────────────────────────────────
|
// ── SSE headers ───────────────────────────────────────────────────────────────
|
||||||
header('Content-Type: text/event-stream');
|
header('Content-Type: text/event-stream');
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.7 MiB |
+1741
-21
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
+5906
-992
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* EverShelf core — API token storage and auth headers.
|
||||||
|
*/
|
||||||
|
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
|
||||||
|
|
||||||
|
function getApiToken() {
|
||||||
|
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApiToken(token) {
|
||||||
|
const t = (token || '').trim();
|
||||||
|
if (t) {
|
||||||
|
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiAuthHeaders() {
|
||||||
|
const fromStorage = getApiToken();
|
||||||
|
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
|
||||||
|
const token = fromSettingsField || fromStorage;
|
||||||
|
if (!token) return {};
|
||||||
|
return { 'X-API-Token': token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch API token from server when loading the UI from the same origin. */
|
||||||
|
async function ensureApiToken() {
|
||||||
|
if (getApiToken()) return true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
window._apiTokenRequired = !!data.api_token_required;
|
||||||
|
if (data.api_token) {
|
||||||
|
setApiToken(data.api_token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) { /* offline / network */ }
|
||||||
|
return !!getApiToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _promptApiTokenIfNeeded() {
|
||||||
|
if (!window._apiTokenRequired) return;
|
||||||
|
if (getApiToken()) return;
|
||||||
|
const existing = document.getElementById('api-token-overlay');
|
||||||
|
if (existing) return;
|
||||||
|
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
|
||||||
|
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
|
||||||
|
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'api-token-overlay';
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width:420px;padding:20px">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p class="settings-hint">${hint}</p>
|
||||||
|
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
|
||||||
|
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.getElementById('api-token-save').onclick = () => {
|
||||||
|
const v = document.getElementById('api-token-input').value.trim();
|
||||||
|
if (v) {
|
||||||
|
setApiToken(v);
|
||||||
|
overlay.remove();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.getApiToken = getApiToken;
|
||||||
|
window.setApiToken = setApiToken;
|
||||||
|
window.apiAuthHeaders = apiAuthHeaders;
|
||||||
|
window.ensureApiToken = ensureApiToken;
|
||||||
|
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* EverShelf core — safe HTML escaping (loaded before app.js).
|
||||||
|
*/
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (str == null) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(str);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.escapeHtml = escapeHtml;
|
||||||
Vendored
+4
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+281
File diff suppressed because one or more lines are too long
+281
File diff suppressed because one or more lines are too long
+3
File diff suppressed because one or more lines are too long
+3
File diff suppressed because one or more lines are too long
+107
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+26
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
@@ -1,13 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Daily backup of EverShelf database (local only)
|
# Daily backup of EverShelf database (local only)
|
||||||
# The database is NOT pushed to remote repositories.
|
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
|
||||||
# Runs via cron: creates a local timestamped backup copy
|
|
||||||
#
|
|
||||||
# Example crontab entry:
|
|
||||||
# 0 3 * * * /var/www/html/evershelf/backup.sh
|
|
||||||
|
|
||||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
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"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
@@ -19,5 +25,5 @@ fi
|
|||||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||||
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
||||||
|
|
||||||
# Keep only the last 7 backups
|
# Keep only the newest N backups
|
||||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
|
||||||
|
Require all denied
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"2026-05": {
|
||||||
|
"input_tokens": 4438300,
|
||||||
|
"output_tokens": 1286760,
|
||||||
|
"calls": 8374,
|
||||||
|
"by_action": {},
|
||||||
|
"by_model": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
{
|
|
||||||
"226887def70e33ef73290ebfe75ed4d0": {
|
|
||||||
"days": 7,
|
|
||||||
"source": "ai",
|
|
||||||
"name": "Polpa di pomodoro finissima",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1777444819
|
|
||||||
},
|
|
||||||
"0ed51c9496aa9edfe38caf41772f54ed": {
|
|
||||||
"days": 7,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Latte di Montagna",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1777444820
|
|
||||||
},
|
|
||||||
"2d63d0216a75d46b465150e925d2e7ad": {
|
|
||||||
"days": 30,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Burro",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1777444821
|
|
||||||
},
|
|
||||||
"9afdf35c4a256867ef47c32495349eb6": {
|
|
||||||
"days": 5,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Yaourt Vanille",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1777480477
|
|
||||||
},
|
|
||||||
"584f57418733a1f2acd29fe2e8816129": {
|
|
||||||
"days": 5,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Passata di pomodoro",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778133522
|
|
||||||
},
|
|
||||||
"baeb7f2021b4bb91c368c9131a61f07c": {
|
|
||||||
"days": 10,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Formaggio Monte Maria",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778133523
|
|
||||||
},
|
|
||||||
"063f2d534407214786d039bb2bffbb93": {
|
|
||||||
"days": 5,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Carote",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778133524
|
|
||||||
},
|
|
||||||
"10a3d07c19bb1f889ebc9293862b4b36": {
|
|
||||||
"days": 60,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Ovomaltine",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419084
|
|
||||||
},
|
|
||||||
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
|
|
||||||
"days": 365,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Linguine pasta di Gragnano Igp",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419084
|
|
||||||
},
|
|
||||||
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
|
|
||||||
"days": 60,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Polpa di pomodoro finissima",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419085
|
|
||||||
},
|
|
||||||
"b8334ff0febd5c0440c9b24c9f3132ed": {
|
|
||||||
"days": 180,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Basilico tritato surgelato",
|
|
||||||
"location": "freezer",
|
|
||||||
"ts": 1778419086
|
|
||||||
},
|
|
||||||
"0cb14384d0ba763ccf12e079d6aa8d34": {
|
|
||||||
"days": 60,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Salsa Pronta Ciliegini",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419086
|
|
||||||
},
|
|
||||||
"188634f49edb8b014a46942ee9fad689": {
|
|
||||||
"days": 180,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Farina Barilla",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419204
|
|
||||||
},
|
|
||||||
"c8db359d8709c69a95f0e6f68216d220": {
|
|
||||||
"days": 9999,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Bicarbonato",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419205
|
|
||||||
},
|
|
||||||
"a6d16a09fd9a6bfbd0a915f05dd71780": {
|
|
||||||
"days": 7,
|
|
||||||
"source": "ai",
|
|
||||||
"name": "Salsa Pronta Ciliegini",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778419205
|
|
||||||
},
|
|
||||||
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
|
|
||||||
"days": 365,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Riso Chicchi Ricchi Gran Risparmio",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419206
|
|
||||||
},
|
|
||||||
"e116e4c11084a463f9aaac02e1749fe7": {
|
|
||||||
"days": 90,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Salsa di soia",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419207
|
|
||||||
},
|
|
||||||
"b1ad9afd4139b3f225b79af4dae256ce": {
|
|
||||||
"days": 60,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Tè Al limone",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778419504
|
|
||||||
},
|
|
||||||
"7ff2b7d326dcba52a664cebbf12f78a2": {
|
|
||||||
"days": 3,
|
|
||||||
"source": "ai",
|
|
||||||
"name": "Piselli fini 1\/2 vapore",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778419505
|
|
||||||
},
|
|
||||||
"71062dc7ffd82b3ee4f40bad076a7c91": {
|
|
||||||
"days": 60,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Cioccolato bianco",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778419506
|
|
||||||
},
|
|
||||||
"38a0eaea422dfe970eba125494e75981": {
|
|
||||||
"days": 180,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Zucca a pezzi",
|
|
||||||
"location": "freezer",
|
|
||||||
"ts": 1778419506
|
|
||||||
},
|
|
||||||
"cde21270e1cd50c431742e49117b225d": {
|
|
||||||
"days": 7,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Pancetta Dolce",
|
|
||||||
"location": "frigo",
|
|
||||||
"ts": 1778419507
|
|
||||||
},
|
|
||||||
"9e4189bd3f8cb1121e7389967dd4f74c": {
|
|
||||||
"days": 180,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Farina di grano tenero tipo rossa",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778427005
|
|
||||||
},
|
|
||||||
"e3472dd051ed13ae18fc96bbebedc1ba": {
|
|
||||||
"days": 60,
|
|
||||||
"source": "rule",
|
|
||||||
"name": "Lievito di birra",
|
|
||||||
"location": "dispensa",
|
|
||||||
"ts": 1778427005
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" standalone='no'?>
|
||||||
|
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||||
|
<service-group>
|
||||||
|
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
|
||||||
|
<service>
|
||||||
|
<type>_evershelf._tcp</type>
|
||||||
|
<port>80</port>
|
||||||
|
<txt-record>path=/api/</txt-record>
|
||||||
|
<txt-record>version=1.0</txt-record>
|
||||||
|
<txt-record>app=evershelf</txt-record>
|
||||||
|
</service>
|
||||||
|
</service-group>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# EverShelf — Architecture (modular layout)
|
||||||
|
|
||||||
|
```
|
||||||
|
dispensa/
|
||||||
|
├── api/
|
||||||
|
│ ├── bootstrap.php # Shared init: env, security, DB, logger
|
||||||
|
│ ├── index.php # HTTP handlers + router (split planned per domain)
|
||||||
|
│ ├── database.php # SQLite schema & migrations
|
||||||
|
│ ├── logger.php # Rotating file logger (logs/)
|
||||||
|
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── env.php # .env loader
|
||||||
|
│ │ ├── constants.php # Paths & pricing constants
|
||||||
|
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
|
||||||
|
│ │ ├── github.php # Encrypted GitHub Issues token
|
||||||
|
│ │ └── cron_log.php # data/cron.log rotation
|
||||||
|
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
|
||||||
|
├── assets/
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
|
||||||
|
│ │ └── app.js # SPA logic (domain modules: future split)
|
||||||
|
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
|
||||||
|
├── data/ # Runtime data (.htaccess: deny all)
|
||||||
|
├── logs/ # Application logs (.htaccess: deny all)
|
||||||
|
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
|
||||||
|
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
|
||||||
|
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
|
||||||
|
|
||||||
|
## Planned refactors
|
||||||
|
|
||||||
|
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
|
||||||
|
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
|
||||||
|
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# Home Assistant Integration
|
||||||
|
|
||||||
|
EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations.
|
||||||
|
|
||||||
|
**Capabilities:**
|
||||||
|
- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items)
|
||||||
|
- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates)
|
||||||
|
- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service
|
||||||
|
- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity
|
||||||
|
- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
1. **Generate a Long-Lived Access Token** in Home Assistant:
|
||||||
|
- Open HA → your **Profile** (bottom-left avatar) → **Security** → **Long-Lived Access Tokens** → **Create Token**
|
||||||
|
- Copy the generated token — you won't see it again.
|
||||||
|
|
||||||
|
2. **Open EverShelf Settings** → tab **🏠 Home Assistant**.
|
||||||
|
|
||||||
|
3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token.
|
||||||
|
|
||||||
|
4. Click **Test connection** — you should see ✅.
|
||||||
|
|
||||||
|
5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST Sensors
|
||||||
|
|
||||||
|
Add EverShelf pantry data as native HA sensor entities that update automatically.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| URL | Returns | Sensor |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
|
||||||
|
|
||||||
|
### Generate & Copy YAML
|
||||||
|
|
||||||
|
In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL.
|
||||||
|
|
||||||
|
### Manual YAML example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# configuration.yaml
|
||||||
|
sensor:
|
||||||
|
- platform: rest
|
||||||
|
name: "EverShelf Overview"
|
||||||
|
unique_id: evershelf_overview
|
||||||
|
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor"
|
||||||
|
scan_interval: 300 # seconds
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
json_attributes:
|
||||||
|
- expiring_soon
|
||||||
|
- expiring_3d
|
||||||
|
- expired_items
|
||||||
|
- total_items
|
||||||
|
- shopping_items
|
||||||
|
- expiring_list # full product details for expiring items
|
||||||
|
- expired_list # full product details for expired items
|
||||||
|
- low_stock_list # full product details for items with quantity ≤ 1
|
||||||
|
- next_expiry_name
|
||||||
|
- next_expiry_date
|
||||||
|
- days_to_next_expiry
|
||||||
|
- last_updated
|
||||||
|
unit_of_measurement: "items"
|
||||||
|
|
||||||
|
- platform: rest
|
||||||
|
name: "EverShelf Shopping Count"
|
||||||
|
unique_id: evershelf_shopping
|
||||||
|
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping"
|
||||||
|
scan_interval: 180
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
unit_of_measurement: "items"
|
||||||
|
|
||||||
|
# Full product inventory — each item includes all details (location, brand, category, …)
|
||||||
|
- platform: rest
|
||||||
|
name: "EverShelf Products"
|
||||||
|
unique_id: evershelf_products
|
||||||
|
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
|
||||||
|
scan_interval: 600
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
json_attributes:
|
||||||
|
- items
|
||||||
|
- last_updated
|
||||||
|
unit_of_measurement: "items"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Home Assistant after editing `configuration.yaml`.
|
||||||
|
|
||||||
|
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 42,
|
||||||
|
"inventory_id": 7,
|
||||||
|
"name": "Latte intero",
|
||||||
|
"brand": "Parmalat",
|
||||||
|
"category": "Lattiero-caseari",
|
||||||
|
"quantity": 2.0,
|
||||||
|
"unit": "conf",
|
||||||
|
"default_quantity": 1000.0,
|
||||||
|
"package_unit": "ml",
|
||||||
|
"location": "frigo",
|
||||||
|
"expiry_date": "2025-06-15",
|
||||||
|
"days_remaining": 3,
|
||||||
|
"opened_at": "2025-06-10",
|
||||||
|
"vacuum_sealed": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field details:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `product_id` | int | Products table ID |
|
||||||
|
| `inventory_id` | int | Inventory row ID |
|
||||||
|
| `name` | string | Product name |
|
||||||
|
| `brand` | string\|null | Brand (if set) |
|
||||||
|
| `category` | string\|null | Category (if set) |
|
||||||
|
| `quantity` | float | Current quantity in inventory |
|
||||||
|
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
|
||||||
|
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
|
||||||
|
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
|
||||||
|
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
|
||||||
|
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
|
||||||
|
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
|
||||||
|
| `opened_at` | string\|null | ISO date when the package was opened |
|
||||||
|
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Automations
|
||||||
|
|
||||||
|
EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
||||||
|
|
||||||
|
### Create the HA Webhook Automation
|
||||||
|
|
||||||
|
1. HA → **Settings** → **Automations & Scenes** → **Create Automation**
|
||||||
|
2. Click **Add Trigger** → choose **Webhook**
|
||||||
|
3. HA generates a **Webhook ID** — copy it
|
||||||
|
4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID**
|
||||||
|
5. Select which events should trigger the webhook
|
||||||
|
|
||||||
|
### Supported Events
|
||||||
|
|
||||||
|
| Event key | When it fires |
|
||||||
|
|-----------|--------------|
|
||||||
|
| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days |
|
||||||
|
| `shopping_add` | Item added to the shopping list |
|
||||||
|
| `stock_update` | Inventory quantity changed |
|
||||||
|
| `barcode_scan` | (reserved for future use) |
|
||||||
|
|
||||||
|
### Webhook Payload (POST body)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "expiry_alert",
|
||||||
|
"timestamp": "2025-06-12T08:00:00+00:00",
|
||||||
|
"data": {
|
||||||
|
"type": "expiring_soon",
|
||||||
|
"count": 3,
|
||||||
|
"days": 3,
|
||||||
|
"summary": "Milk, Yogurt, Butter",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_id": 42,
|
||||||
|
"inventory_id": 7,
|
||||||
|
"name": "Milk",
|
||||||
|
"brand": "Parmalat",
|
||||||
|
"category": "Dairy",
|
||||||
|
"quantity": 2.0,
|
||||||
|
"unit": "conf",
|
||||||
|
"default_quantity": 1000.0,
|
||||||
|
"package_unit": "ml",
|
||||||
|
"location": "frigo",
|
||||||
|
"expiry_date": "2025-06-14",
|
||||||
|
"days_remaining": 2,
|
||||||
|
"opened_at": "2025-06-10",
|
||||||
|
"vacuum_sealed": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Expiry Alert → Telegram
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: EverShelf Expiry Alert
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID
|
||||||
|
action:
|
||||||
|
- service: notify.telegram_bot
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||||
|
{% for item in trigger.json.data.items %}
|
||||||
|
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
|
||||||
|
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
|
||||||
|
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Automation on location
|
||||||
|
|
||||||
|
You can filter by location in the automation template to only alert for fridge items:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
condition:
|
||||||
|
- condition: template
|
||||||
|
value_template: >
|
||||||
|
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Push Notifications
|
||||||
|
|
||||||
|
If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly:
|
||||||
|
|
||||||
|
1. Find your notify service name in HA: **Developer Tools → Services** → search `notify`
|
||||||
|
2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`)
|
||||||
|
3. Save
|
||||||
|
|
||||||
|
EverShelf will call this service from the cron job whenever expiry alerts fire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TTS on Smart Speakers
|
||||||
|
|
||||||
|
Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`)
|
||||||
|
- Find it in HA: **Developer Tools → States**
|
||||||
|
2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers
|
||||||
|
3. Save settings
|
||||||
|
|
||||||
|
### How it Works
|
||||||
|
|
||||||
|
When recipe step TTS is triggered, EverShelf calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/services/tts/speak
|
||||||
|
Authorization: Bearer <HA_TOKEN>
|
||||||
|
{
|
||||||
|
"entity_id": "media_player.kitchen_display",
|
||||||
|
"message": "Add 200 g of flour and mix well."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
All settings are configurable from `.env` or from the in-app Settings panel.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `HA_ENABLED` | `false` | Master switch for all HA features |
|
||||||
|
| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash |
|
||||||
|
| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token |
|
||||||
|
| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS |
|
||||||
|
| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation |
|
||||||
|
| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events |
|
||||||
|
| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) |
|
||||||
|
| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Test shows ❌ "Connection failed"**
|
||||||
|
- Verify the URL is reachable from the EverShelf server (not just your browser)
|
||||||
|
- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead
|
||||||
|
- Check that port 8123 (or your custom port) is open on the HA host
|
||||||
|
|
||||||
|
**Test shows ❌ "bad_token"**
|
||||||
|
- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile
|
||||||
|
|
||||||
|
**Webhook not firing**
|
||||||
|
- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA
|
||||||
|
- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes)
|
||||||
|
- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS`
|
||||||
|
|
||||||
|
**TTS not speaking**
|
||||||
|
- Ensure the media player entity is online in HA (check its state in Developer Tools)
|
||||||
|
- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab
|
||||||
|
- Check HA logs for `tts.speak` errors (some platforms require `tts_options`)
|
||||||
|
|
||||||
|
**Sensors show unavailable in HA**
|
||||||
|
- The EverShelf URL must be reachable from the HA host
|
||||||
|
- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible
|
||||||
|
- Use `scan_interval` ≥ 60 to avoid hammering the server
|
||||||
@@ -5,14 +5,14 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "it.dadaloop.evershelf.kiosk"
|
namespace = "it.dadaloop.evershelf.kiosk"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 15
|
versionCode = 20
|
||||||
versionName = "1.7.14"
|
versionName = "1.7.19"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import android.os.Bundle
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.media.AudioManager
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import android.view.WindowInsetsController
|
import android.view.WindowInsetsController
|
||||||
@@ -99,6 +101,20 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
// Pending WebView permission request
|
// Pending WebView permission request
|
||||||
private var pendingWebPermission: PermissionRequest? = null
|
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 {
|
companion object {
|
||||||
private const val FILE_CHOOSER_REQUEST = 1002
|
private const val FILE_CHOOSER_REQUEST = 1002
|
||||||
private const val PERMISSION_REQUEST_CODE = 1003
|
private const val PERMISSION_REQUEST_CODE = 1003
|
||||||
@@ -113,7 +129,9 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||||
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
|
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
|
||||||
private const val SPLASH_DURATION = 1500L
|
private const val SPLASH_DURATION = 1500L
|
||||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
// Use the kiosk-specific rolling release tag so version comparison is always
|
||||||
|
// against the KIOSK version, not the webapp version (they diverge).
|
||||||
|
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/tags/kiosk-latest"
|
||||||
// Keys for persisting a pending update across restarts
|
// Keys for persisting a pending update across restarts
|
||||||
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
|
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
|
||||||
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
|
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
|
||||||
@@ -142,6 +160,25 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
|
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||||
tts?.language = Locale.getDefault()
|
tts?.language = Locale.getDefault()
|
||||||
}
|
}
|
||||||
|
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {}
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
runOnUiThread {
|
||||||
|
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Deprecated("Deprecated in API 21")
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
runOnUiThread {
|
||||||
|
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||||
|
runOnUiThread {
|
||||||
|
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
ttsReady = true
|
ttsReady = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,7 +501,10 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
if (!ttsReady) return
|
if (!ttsReady) return
|
||||||
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
|
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
|
||||||
engine.setPitch(pitch.coerceIn(0.1f, 4f))
|
engine.setPitch(pitch.coerceIn(0.1f, 4f))
|
||||||
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
|
val params = Bundle().apply {
|
||||||
|
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC)
|
||||||
|
}
|
||||||
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, "kiosk_tts")
|
||||||
}
|
}
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun stopSpeech() { tts?.stop() }
|
fun stopSpeech() { tts?.stop() }
|
||||||
@@ -603,6 +643,79 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
webView.evaluateJavascript("$jsCallback($escaped)", null)
|
webView.evaluateJavascript("$jsCallback($escaped)", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentKiosk = try {
|
||||||
|
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||||
|
} catch (_: Exception) { "" }
|
||||||
|
|
||||||
|
val installedVc: Long = try {
|
||||||
|
val pi = packageManager.getPackageInfo(packageName, 0)
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
|
||||||
|
else @Suppress("DEPRECATION") pi.versionCode.toLong()
|
||||||
|
} catch (_: Exception) { -1L }
|
||||||
|
|
||||||
|
fun semverNewer(remote: String, local: String): Boolean {
|
||||||
|
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||||
|
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||||
|
for (i in 0 until maxOf(r.size, l.size)) {
|
||||||
|
val rv = r.getOrElse(i) { 0 }
|
||||||
|
val lv = l.getOrElse(i) { 0 }
|
||||||
|
if (rv != lv) return rv > lv
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when {
|
||||||
|
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
|
||||||
|
currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) ->
|
||||||
|
semverNewer(remoteVersion, currentKiosk)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyUpdate(remoteVersion: String, apkUrl: String) {
|
||||||
|
val result = JSONObject()
|
||||||
|
.put("has_update", true)
|
||||||
|
.put("current", currentKiosk)
|
||||||
|
.put("latest", remoteVersion)
|
||||||
|
.put("apk_url", apkUrl)
|
||||||
|
notifyJs(result)
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_PENDING_UPDATE_VERSION, remoteVersion)
|
||||||
|
.putString(KEY_PENDING_UPDATE_URL, apkUrl)
|
||||||
|
.apply()
|
||||||
|
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteVersion", apkUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Prefer LAN/self-hosted update (no GitHub required)
|
||||||
|
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/')
|
||||||
|
if (baseUrl.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val localApi = "$baseUrl/api/index.php?action=kiosk_update"
|
||||||
|
val conn = openTrustedConnection(localApi)
|
||||||
|
conn.connectTimeout = 5000
|
||||||
|
conn.readTimeout = 5000
|
||||||
|
if (conn.responseCode == 200) {
|
||||||
|
val localJson = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||||
|
conn.disconnect()
|
||||||
|
if (localJson.optBoolean("success")) {
|
||||||
|
val remoteVersion = localJson.optString("version", "")
|
||||||
|
val remoteVc = localJson.optLong("version_code", -1L)
|
||||||
|
val apkUrl = localJson.optString("apk_url", "")
|
||||||
|
if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) {
|
||||||
|
applyUpdate(remoteVersion, apkUrl)
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
if (!needsUpdate(remoteVersion, remoteVc)) {
|
||||||
|
notifyJs(JSONObject().put("has_update", false).put("source", "local"))
|
||||||
|
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else conn.disconnect()
|
||||||
|
} catch (_: Exception) { /* fall through to GitHub */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) GitHub release fallback (requires internet)
|
||||||
try {
|
try {
|
||||||
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||||
@@ -617,81 +730,58 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
val body = conn.inputStream.bufferedReader().readText()
|
val body = conn.inputStream.bufferedReader().readText()
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
val latestTag = json.optString("tag_name", "")
|
val bodyText = json.optString("body", "")
|
||||||
if (latestTag.isEmpty()) {
|
|
||||||
notifyJs(JSONObject().put("has_update", false).put("error", "no tag"))
|
|
||||||
return@Thread
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentKiosk = try {
|
|
||||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
|
||||||
} catch (_: Exception) { "" }
|
|
||||||
|
|
||||||
// Strip any non-numeric prefix so "kiosk-1.7.0", "v1.7.0", "kiosk-v1.7.1"
|
|
||||||
// all normalise to "1.7.0" / "1.7.1" for comparison.
|
|
||||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||||
val isSemver = norm(latestTag).matches(Regex("\\d+\\.\\d+.*"))
|
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
|
||||||
|
.find(bodyText)?.groupValues?.get(1)
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?: norm(json.optString("tag_name", ""))
|
||||||
|
|
||||||
// Compare semver: returns true if `remote` is strictly greater than `local`
|
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
|
||||||
fun semverNewer(remote: String, local: String): Boolean {
|
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
|
||||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
|
||||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
|
||||||
val len = maxOf(r.size, l.size)
|
|
||||||
for (i in 0 until len) {
|
|
||||||
val rv = r.getOrElse(i) { 0 }
|
|
||||||
val lv = l.getOrElse(i) { 0 }
|
|
||||||
if (rv != lv) return rv > lv
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val assets = json.optJSONArray("assets")
|
val assets = json.optJSONArray("assets")
|
||||||
var kioskApkUrl = ""
|
var kioskApkUrl = ""
|
||||||
if (assets != null) {
|
if (assets != null) {
|
||||||
for (i in 0 until assets.length()) {
|
for (i in 0 until assets.length()) {
|
||||||
val a = assets.getJSONObject(i)
|
val a = assets.getJSONObject(i)
|
||||||
val name = a.optString("name", "").lowercase()
|
val url = a.optString("browser_download_url", "")
|
||||||
val url = a.optString("browser_download_url", "")
|
if (url.endsWith(".apk", ignoreCase = true) && url.isNotEmpty()) {
|
||||||
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
|
kioskApkUrl = url; break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||||
|
|
||||||
// Only flag an update when the remote tag is parseable as semver AND
|
if (!needsUpdate(remoteKioskVersion, remoteVc)) {
|
||||||
// the remote version is strictly greater than the installed version.
|
notifyJs(JSONObject().put("has_update", false))
|
||||||
// Non-semver tags (e.g. "kiosk-latest", "rolling") cannot be compared
|
|
||||||
// numerically → treat as "no update" to avoid false positives.
|
|
||||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() &&
|
|
||||||
isSemver && semverNewer(norm(latestTag), norm(currentKiosk))
|
|
||||||
|
|
||||||
val result = JSONObject()
|
|
||||||
.put("has_update", kioskNeedsUpdate)
|
|
||||||
.put("current", currentKiosk)
|
|
||||||
.put("latest", latestTag)
|
|
||||||
.put("apk_url", kioskApkUrl)
|
|
||||||
|
|
||||||
notifyJs(result)
|
|
||||||
|
|
||||||
if (!kioskNeedsUpdate) {
|
|
||||||
// Clear any stale pending update if the current version is now up to date
|
|
||||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||||
return@Thread
|
return@Thread
|
||||||
}
|
}
|
||||||
|
applyUpdate(remoteKioskVersion, kioskApkUrl)
|
||||||
// Persist the pending update so the banner reappears after a crash/restart
|
|
||||||
prefs.edit()
|
|
||||||
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
|
|
||||||
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
val label = if (isSemver) "$currentKiosk → $latestTag" else latestTag
|
|
||||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HTTPS with self-signed cert support (LAN servers). */
|
||||||
|
private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection {
|
||||||
|
val conn = URL(urlStr).openConnection()
|
||||||
|
if (conn is javax.net.ssl.HttpsURLConnection) {
|
||||||
|
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : javax.net.ssl.X509TrustManager {
|
||||||
|
override fun checkClientTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||||
|
override fun checkServerTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||||
|
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||||
|
})
|
||||||
|
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
|
||||||
|
sc.init(null, trustAll, java.security.SecureRandom())
|
||||||
|
conn.sslSocketFactory = sc.socketFactory
|
||||||
|
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||||
|
}
|
||||||
|
return conn as java.net.HttpURLConnection
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On resume: if a previous session detected an available update and saved it to prefs,
|
* On resume: if a previous session detected an available update and saved it to prefs,
|
||||||
* restore the update banner immediately without a network round-trip.
|
* restore the update banner immediately without a network round-trip.
|
||||||
@@ -765,7 +855,13 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
val q = DownloadManager.Query().setFilterById(downloadId)
|
||||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
||||||
var ok = false
|
var ok = false
|
||||||
if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
|
var dmStatus = -1
|
||||||
|
var dmReason = -1
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||||
|
dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||||
|
ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL
|
||||||
|
}
|
||||||
c.close()
|
c.close()
|
||||||
if (ok) {
|
if (ok) {
|
||||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||||
@@ -775,7 +871,12 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||||
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||||
ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
|
ErrorReporter.reportMessage(
|
||||||
|
"install_download_failed",
|
||||||
|
"DownloadManager returned failure for URL: $apkUrl",
|
||||||
|
mapOf("dm_status" to dmStatus, "dm_reason" to dmReason,
|
||||||
|
"device" to buildDeviceLabel())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,6 +903,52 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
file.delete()
|
file.delete()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ── Pre-install validation via PackageManager ──────────────────────
|
||||||
|
// This catches version-downgrade or same-version attempts before PackageInstaller
|
||||||
|
// gets them (which would silently fail with STATUS_FAILURE=1 on many OEMs).
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val apkInfo = try { packageManager.getPackageArchiveInfo(file.absolutePath, 0) } catch (_: Exception) { null }
|
||||||
|
if (apkInfo != null) {
|
||||||
|
// Wrong package: would always fail with STATUS_FAILURE=1
|
||||||
|
if (apkInfo.packageName != packageName) {
|
||||||
|
val detail = "APK package=${apkInfo.packageName}, expected=$packageName"
|
||||||
|
setInstallUI("\u274C", "APK non valido", detail, 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||||
|
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||||
|
ErrorReporter.reportMessage("install_wrong_package", detail, mapOf("apk_pkg" to apkInfo.packageName, "expected" to packageName), forceReport = true)
|
||||||
|
file.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Version downgrade or same versionCode: Android rejects it
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val apkVc: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
apkInfo.longVersionCode
|
||||||
|
else
|
||||||
|
apkInfo.versionCode.toLong()
|
||||||
|
val installedVc: Long = try {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
packageManager.getPackageInfo(packageName, 0).longVersionCode
|
||||||
|
else
|
||||||
|
packageManager.getPackageInfo(packageName, 0).versionCode.toLong()
|
||||||
|
} catch (_: Exception) { -1L }
|
||||||
|
|
||||||
|
if (installedVc >= 0 && apkVc <= installedVc) {
|
||||||
|
// Same or older version — no real update, dismiss banner silently
|
||||||
|
runOnUiThread {
|
||||||
|
updateBanner.visibility = View.GONE
|
||||||
|
bannerProgressBar.visibility = View.GONE
|
||||||
|
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||||
|
}
|
||||||
|
ErrorReporter.reportMessage(
|
||||||
|
"install_no_upgrade",
|
||||||
|
"APK versionCode=$apkVc (${apkInfo.versionName}) ≤ installed=$installedVc — not an upgrade",
|
||||||
|
mapOf("apk_vc" to apkVc, "apk_ver" to (apkInfo.versionName ?: ""), "installed_vc" to installedVc),
|
||||||
|
forceReport = true
|
||||||
|
)
|
||||||
|
file.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// Only kiosk self-update is handled; gateway is now integrated
|
// Only kiosk self-update is handled; gateway is now integrated
|
||||||
val targetPkg = packageName
|
val targetPkg = packageName
|
||||||
installWithPackageInstaller(file, targetPkg)
|
installWithPackageInstaller(file, targetPkg)
|
||||||
@@ -813,6 +960,11 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
|
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
|
||||||
// on some OEM/Android versions even when the package name is correct.
|
// on some OEM/Android versions even when the package name is correct.
|
||||||
|
// setInstallReason is required on Android 14+ (API 34+) for PackageInstaller
|
||||||
|
// to accept self-updates; without it Android 16 returns STATUS_FAILURE=1.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER)
|
||||||
|
}
|
||||||
val sessionId = pi.createSession(params)
|
val sessionId = pi.createSession(params)
|
||||||
val session = pi.openSession(sessionId)
|
val session = pi.openSession(sessionId)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
// Back
|
// Back
|
||||||
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
||||||
|
|
||||||
|
// Advanced settings → back to webapp (where HA, Gemini, Bring! etc. are configured)
|
||||||
|
findViewById<MaterialButton>(R.id.btnOpenAppSettings).setOnClickListener { finish() }
|
||||||
|
|
||||||
// Test connection
|
// Test connection
|
||||||
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ import javax.net.ssl.X509TrustManager
|
|||||||
* 2 — Permissions rationale + grant
|
* 2 — Permissions rationale + grant
|
||||||
* 3 — Server URL + auto-discovery + connection test
|
* 3 — Server URL + auto-discovery + connection test
|
||||||
* 4 — Smart scale question → gateway info + install
|
* 4 — Smart scale question → gateway info + install
|
||||||
* 5 — Screensaver toggle (NEW)
|
* 5 — Features (screensaver / prices / meal-plan / zero-waste)
|
||||||
* 6 — Done
|
* 6 — Gemini AI key (optional, auto-skipped if already set)
|
||||||
|
* 7 — Bring! credentials (optional, auto-skipped if already set)
|
||||||
|
* 8 — Done
|
||||||
*/
|
*/
|
||||||
class SetupActivity : AppCompatActivity() {
|
class SetupActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -73,6 +75,8 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
private lateinit var stepServer: LinearLayout
|
private lateinit var stepServer: LinearLayout
|
||||||
private lateinit var stepScale: LinearLayout
|
private lateinit var stepScale: LinearLayout
|
||||||
private lateinit var stepScreensaver: LinearLayout
|
private lateinit var stepScreensaver: LinearLayout
|
||||||
|
private lateinit var stepGemini: LinearLayout
|
||||||
|
private lateinit var stepBring: LinearLayout
|
||||||
private lateinit var stepDone: LinearLayout
|
private lateinit var stepDone: LinearLayout
|
||||||
|
|
||||||
// Progress dots
|
// Progress dots
|
||||||
@@ -110,6 +114,14 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Screensaver step
|
// Screensaver step
|
||||||
private lateinit var setupSwitchScreensaver: SwitchMaterial
|
private lateinit var setupSwitchScreensaver: SwitchMaterial
|
||||||
|
private lateinit var setupSwitchPrices: SwitchMaterial
|
||||||
|
private lateinit var setupSwitchMealPlan: SwitchMaterial
|
||||||
|
private lateinit var setupSwitchZeroWaste: SwitchMaterial
|
||||||
|
|
||||||
|
// Gemini + Bring steps
|
||||||
|
private lateinit var setupGeminiKeyEdit: EditText
|
||||||
|
private lateinit var setupBringEmailEdit: EditText
|
||||||
|
private lateinit var setupBringPasswordEdit: EditText
|
||||||
|
|
||||||
// Done step
|
// Done step
|
||||||
private lateinit var summaryText: TextView
|
private lateinit var summaryText: TextView
|
||||||
@@ -128,6 +140,12 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
private const val KEY_HAS_SCALE = "has_scale"
|
private const val KEY_HAS_SCALE = "has_scale"
|
||||||
private const val KEY_LANGUAGE = "kiosk_language"
|
private const val KEY_LANGUAGE = "kiosk_language"
|
||||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||||
|
private const val KEY_PRICE_ENABLED = "price_enabled"
|
||||||
|
private const val KEY_MEAL_PLAN = "meal_plan_enabled"
|
||||||
|
private const val KEY_ZEROWASTE_TIPS = "zerowaste_tips_enabled"
|
||||||
|
private const val KEY_GEMINI_KEY = "gemini_api_key"
|
||||||
|
private const val KEY_BRING_EMAIL = "bring_email"
|
||||||
|
private const val KEY_BRING_PASSWORD = "bring_password"
|
||||||
private const val PERMISSION_REQUEST_CODE = 2004
|
private const val PERMISSION_REQUEST_CODE = 2004
|
||||||
private const val BLE_PERMISSION_REQUEST = 2006
|
private const val BLE_PERMISSION_REQUEST = 2006
|
||||||
|
|
||||||
@@ -178,8 +196,11 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
when (currentStep) {
|
when (currentStep) {
|
||||||
0 -> confirmExit()
|
0 -> confirmExit()
|
||||||
1 -> showStep(0) // back to language
|
1 -> showStep(0) // back to language
|
||||||
|
8 -> showStep(7) // done → bring
|
||||||
|
7 -> showStep(6) // bring → gemini
|
||||||
|
6 -> showStep(5) // gemini → features
|
||||||
else -> showStep(currentStep - 1)
|
else -> showStep(currentStep - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,8 +236,18 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
stepServer = findViewById(R.id.stepServer)
|
stepServer = findViewById(R.id.stepServer)
|
||||||
stepScale = findViewById(R.id.stepScale)
|
stepScale = findViewById(R.id.stepScale)
|
||||||
stepScreensaver = findViewById(R.id.stepScreensaver)
|
stepScreensaver = findViewById(R.id.stepScreensaver)
|
||||||
|
stepGemini = findViewById(R.id.stepGemini)
|
||||||
|
stepBring = findViewById(R.id.stepBring)
|
||||||
stepDone = findViewById(R.id.stepDone)
|
stepDone = findViewById(R.id.stepDone)
|
||||||
|
|
||||||
|
// Gemini + Bring fields
|
||||||
|
setupGeminiKeyEdit = findViewById(R.id.setupGeminiKeyEdit)
|
||||||
|
setupBringEmailEdit = findViewById(R.id.setupBringEmailEdit)
|
||||||
|
setupBringPasswordEdit = findViewById(R.id.setupBringPasswordEdit)
|
||||||
|
// Pre-fill from saved prefs
|
||||||
|
(prefs.getString(KEY_GEMINI_KEY, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupGeminiKeyEdit.setText(it) }
|
||||||
|
(prefs.getString(KEY_BRING_EMAIL, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupBringEmailEdit.setText(it) }
|
||||||
|
|
||||||
// Server step
|
// Server step
|
||||||
urlEdit = findViewById(R.id.setupUrlEdit)
|
urlEdit = findViewById(R.id.setupUrlEdit)
|
||||||
urlStatus = findViewById(R.id.setupUrlStatus)
|
urlStatus = findViewById(R.id.setupUrlStatus)
|
||||||
@@ -238,10 +269,17 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
tvTestWeight = findViewById(R.id.tvTestWeight)
|
tvTestWeight = findViewById(R.id.tvTestWeight)
|
||||||
testWeightBox = findViewById(R.id.testWeightBox)
|
testWeightBox = findViewById(R.id.testWeightBox)
|
||||||
|
|
||||||
// Screensaver step
|
// Features step — bind all four toggles
|
||||||
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
|
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
|
||||||
// Pre-fill saved screensaver pref
|
setupSwitchPrices = findViewById(R.id.setupSwitchPrices)
|
||||||
setupSwitchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
|
setupSwitchMealPlan = findViewById(R.id.setupSwitchMealPlan)
|
||||||
|
setupSwitchZeroWaste = findViewById(R.id.setupSwitchZeroWaste)
|
||||||
|
// Pre-fill from saved prefs only if each key was previously configured
|
||||||
|
// ("se non sono impostati, chiedi!" — fresh install → all start at false)
|
||||||
|
setupSwitchScreensaver.isChecked = if (prefs.contains(KEY_SCREENSAVER)) prefs.getBoolean(KEY_SCREENSAVER, false) else false
|
||||||
|
setupSwitchPrices.isChecked = if (prefs.contains(KEY_PRICE_ENABLED)) prefs.getBoolean(KEY_PRICE_ENABLED, false) else false
|
||||||
|
setupSwitchMealPlan.isChecked = if (prefs.contains(KEY_MEAL_PLAN)) prefs.getBoolean(KEY_MEAL_PLAN, false) else false
|
||||||
|
setupSwitchZeroWaste.isChecked = if (prefs.contains(KEY_ZEROWASTE_TIPS)) prefs.getBoolean(KEY_ZEROWASTE_TIPS, false) else false
|
||||||
|
|
||||||
// Done step
|
// Done step
|
||||||
summaryText = findViewById(R.id.setupSummaryText)
|
summaryText = findViewById(R.id.setupSummaryText)
|
||||||
@@ -260,6 +298,8 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
findViewById<MaterialButton>(R.id.btnLangIt).setOnClickListener { selectLanguage("it") }
|
findViewById<MaterialButton>(R.id.btnLangIt).setOnClickListener { selectLanguage("it") }
|
||||||
findViewById<MaterialButton>(R.id.btnLangEn).setOnClickListener { selectLanguage("en") }
|
findViewById<MaterialButton>(R.id.btnLangEn).setOnClickListener { selectLanguage("en") }
|
||||||
findViewById<MaterialButton>(R.id.btnLangDe).setOnClickListener { selectLanguage("de") }
|
findViewById<MaterialButton>(R.id.btnLangDe).setOnClickListener { selectLanguage("de") }
|
||||||
|
findViewById<MaterialButton>(R.id.btnLangEs).setOnClickListener { selectLanguage("es") }
|
||||||
|
findViewById<MaterialButton>(R.id.btnLangFr).setOnClickListener { selectLanguage("fr") }
|
||||||
|
|
||||||
// ── Welcome ──────────────────────────────────────────────────────
|
// ── Welcome ──────────────────────────────────────────────────────
|
||||||
findViewById<MaterialButton>(R.id.btnSetupExit).setOnClickListener { confirmExit() }
|
findViewById<MaterialButton>(R.id.btnSetupExit).setOnClickListener { confirmExit() }
|
||||||
@@ -360,12 +400,13 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
scaleTestCard.visibility = View.GONE
|
scaleTestCard.visibility = View.GONE
|
||||||
testWeightBox.visibility = View.GONE
|
testWeightBox.visibility = View.GONE
|
||||||
bleSetupCard.visibility = View.VISIBLE
|
bleSetupCard.visibility = View.VISIBLE
|
||||||
|
step3NextButtons.visibility = View.VISIBLE // restore nav buttons (back/next)
|
||||||
tvSelectedScale.text = ""
|
tvSelectedScale.text = ""
|
||||||
tvSelectedScale.visibility = View.GONE
|
tvSelectedScale.visibility = View.GONE
|
||||||
tvScanStatus.text = "Bilancia non confermata. Riprova la scansione."
|
tvScanStatus.text = getString(R.string.ble_not_confirmed)
|
||||||
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
||||||
btnScanBle.isEnabled = true
|
btnScanBle.isEnabled = true
|
||||||
btnScanBle.text = "🔍 Cerca bilancia"
|
btnScanBle.text = getString(R.string.ble_scan_again)
|
||||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
|
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
|
||||||
}
|
}
|
||||||
findViewById<MaterialButton>(R.id.btnTestSkip).setOnClickListener {
|
findViewById<MaterialButton>(R.id.btnTestSkip).setOnClickListener {
|
||||||
@@ -381,13 +422,38 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
|
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Screensaver ───────────────────────────────────────────────────
|
// ── Features step (screensaver / prices / meal plan / zero-waste) ────
|
||||||
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
|
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
|
||||||
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
|
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
|
||||||
prefs.edit().putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked).apply()
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
|
||||||
|
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
|
||||||
|
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
|
||||||
|
.putBoolean(KEY_ZEROWASTE_TIPS, setupSwitchZeroWaste.isChecked)
|
||||||
|
.apply()
|
||||||
showStep(6)
|
showStep(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gemini step ───────────────────────────────────────────────────
|
||||||
|
findViewById<MaterialButton>(R.id.btnGeminiBack).setOnClickListener { showStep(5) }
|
||||||
|
findViewById<MaterialButton>(R.id.btnGeminiSkip).setOnClickListener { showStep(7) }
|
||||||
|
findViewById<MaterialButton>(R.id.btnGeminiNext).setOnClickListener {
|
||||||
|
val key = setupGeminiKeyEdit.text.toString().trim()
|
||||||
|
if (key.isNotEmpty()) prefs.edit().putString(KEY_GEMINI_KEY, key).apply()
|
||||||
|
showStep(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bring step ────────────────────────────────────────────────────
|
||||||
|
findViewById<MaterialButton>(R.id.btnBringBack).setOnClickListener { showStep(6) }
|
||||||
|
findViewById<MaterialButton>(R.id.btnBringSkip).setOnClickListener { showStep(8) }
|
||||||
|
findViewById<MaterialButton>(R.id.btnBringNext).setOnClickListener {
|
||||||
|
val email = setupBringEmailEdit.text.toString().trim()
|
||||||
|
val pass = setupBringPasswordEdit.text.toString().trim()
|
||||||
|
if (email.isNotEmpty()) prefs.edit().putString(KEY_BRING_EMAIL, email).apply()
|
||||||
|
if (pass.isNotEmpty()) prefs.edit().putString(KEY_BRING_PASSWORD, pass).apply()
|
||||||
|
showStep(8)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Done ──────────────────────────────────────────────────────────
|
// ── Done ──────────────────────────────────────────────────────────
|
||||||
findViewById<MaterialButton>(R.id.btnLaunch).setOnClickListener { finishSetup() }
|
findViewById<MaterialButton>(R.id.btnLaunch).setOnClickListener { finishSetup() }
|
||||||
}
|
}
|
||||||
@@ -403,20 +469,27 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun highlightSelectedLang() {
|
private fun highlightSelectedLang() {
|
||||||
val saved = prefs.getString(KEY_LANGUAGE, null) ?: return
|
val saved = prefs.getString(KEY_LANGUAGE, null) ?: return
|
||||||
val (btnIt, btnEn, btnDe) = Triple(
|
val btnIt = findViewById<MaterialButton>(R.id.btnLangIt)
|
||||||
findViewById<MaterialButton>(R.id.btnLangIt),
|
val btnEn = findViewById<MaterialButton>(R.id.btnLangEn)
|
||||||
findViewById<MaterialButton>(R.id.btnLangEn),
|
val btnDe = findViewById<MaterialButton>(R.id.btnLangDe)
|
||||||
findViewById<MaterialButton>(R.id.btnLangDe)
|
val btnEs = findViewById<MaterialButton>(R.id.btnLangEs)
|
||||||
)
|
val btnFr = findViewById<MaterialButton>(R.id.btnLangFr)
|
||||||
// Add checkmark to selected
|
// Add checkmark to selected
|
||||||
btnIt.text = if (saved == "it") "✅ 🇮🇹 Italiano" else "🇮🇹 Italiano"
|
btnIt.text = if (saved == "it") "✅ 🇮🇹 Italiano" else "🇮🇹 Italiano"
|
||||||
btnEn.text = if (saved == "en") "✅ 🇬🇧 English" else "🇬🇧 English"
|
btnEn.text = if (saved == "en") "✅ 🇬🇧 English" else "🇬🇧 English"
|
||||||
btnDe.text = if (saved == "de") "✅ 🇩🇪 Deutsch" else "🇩🇪 Deutsch"
|
btnDe.text = if (saved == "de") "✅ 🇩🇪 Deutsch" else "🇩🇪 Deutsch"
|
||||||
|
btnEs.text = if (saved == "es") "✅ 🇪🇸 Español" else "🇪🇸 Español"
|
||||||
|
btnFr.text = if (saved == "fr") "✅ 🇫🇷 Français" else "🇫🇷 Français"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step navigation ───────────────────────────────────────────────────
|
// ── Step navigation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun showStep(step: Int) {
|
private fun showStep(step: Int) {
|
||||||
|
// Auto-skip Gemini step if already configured
|
||||||
|
if (step == 6 && !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()) { showStep(7); return }
|
||||||
|
// Auto-skip Bring step if already configured
|
||||||
|
if (step == 7 && !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()) { showStep(8); return }
|
||||||
|
|
||||||
currentStep = step
|
currentStep = step
|
||||||
stepLanguage.visibility = if (step == 0) View.VISIBLE else View.GONE
|
stepLanguage.visibility = if (step == 0) View.VISIBLE else View.GONE
|
||||||
stepWelcome.visibility = if (step == 1) View.VISIBLE else View.GONE
|
stepWelcome.visibility = if (step == 1) View.VISIBLE else View.GONE
|
||||||
@@ -424,7 +497,9 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
stepServer.visibility = if (step == 3) View.VISIBLE else View.GONE
|
stepServer.visibility = if (step == 3) View.VISIBLE else View.GONE
|
||||||
stepScale.visibility = if (step == 4) View.VISIBLE else View.GONE
|
stepScale.visibility = if (step == 4) View.VISIBLE else View.GONE
|
||||||
stepScreensaver.visibility = if (step == 5) View.VISIBLE else View.GONE
|
stepScreensaver.visibility = if (step == 5) View.VISIBLE else View.GONE
|
||||||
stepDone.visibility = if (step == 6) View.VISIBLE else View.GONE
|
stepGemini.visibility = if (step == 6) View.VISIBLE else View.GONE
|
||||||
|
stepBring.visibility = if (step == 7) View.VISIBLE else View.GONE
|
||||||
|
stepDone.visibility = if (step == 8) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
updateProgressDots()
|
updateProgressDots()
|
||||||
|
|
||||||
@@ -460,22 +535,27 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build summary when entering done step
|
// Build summary when entering done step
|
||||||
if (step == 6) buildSummary()
|
if (step == 8) buildSummary()
|
||||||
|
|
||||||
// Cancel auto-discover when leaving server step
|
// Cancel auto-discover when leaving server step
|
||||||
if (step != 3) discoverCancelled.set(true)
|
if (step != 3) discoverCancelled.set(true)
|
||||||
|
|
||||||
|
// Auto-discover when entering server step (empty URL only)
|
||||||
|
if (step == 3 && urlEdit.text.toString().trim().isEmpty()) {
|
||||||
|
autoDiscover()
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to top
|
// Scroll to top
|
||||||
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
|
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateProgressDots() {
|
private fun updateProgressDots() {
|
||||||
progressDots.removeAllViews()
|
progressDots.removeAllViews()
|
||||||
// Show 5 dots for steps 1-5; step 0 (language) and step 6 (done) have no dots
|
// Show 7 dots for steps 1-7; step 0 (language) and step 8 (done) have no dots
|
||||||
if (currentStep == 0 || currentStep == 6) return
|
if (currentStep == 0 || currentStep == 8) return
|
||||||
val active = currentStep // 1..5
|
val active = currentStep // 1..7
|
||||||
val density = resources.displayMetrics.density
|
val density = resources.displayMetrics.density
|
||||||
for (i in 1..5) {
|
for (i in 1..7) {
|
||||||
val dot = View(this)
|
val dot = View(this)
|
||||||
val sizeDp = if (i == active) 10 else 7
|
val sizeDp = if (i == active) 10 else 7
|
||||||
val px = (sizeDp * density).toInt()
|
val px = (sizeDp * density).toInt()
|
||||||
@@ -622,6 +702,58 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeDiscoveredBase(urlStr: String): String {
|
||||||
|
var base = urlStr.substringBefore("/api/")
|
||||||
|
if (base.endsWith(":443")) base = base.removeSuffix(":443")
|
||||||
|
if (base.endsWith(":80")) base = base.removeSuffix(":80")
|
||||||
|
return if (base.endsWith("/")) base else "$base/"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeEverShelfEndpoint(urlStr: String): String? {
|
||||||
|
return try {
|
||||||
|
val conn = openConn(urlStr) ?: return null
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code !in 200..399) {
|
||||||
|
conn.disconnect()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val body = conn.inputStream.bufferedReader().readText()
|
||||||
|
conn.disconnect()
|
||||||
|
if (body.contains("gemini_key_set") || body.contains("\"success\"") || body.contains("\"ok\"")) {
|
||||||
|
normalizeDiscoveredBase(urlStr)
|
||||||
|
} else null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeEverShelfHost(ip: String, port: Int): String? {
|
||||||
|
val reachable = try {
|
||||||
|
Socket().use { s -> s.connect(InetSocketAddress(ip, port), 800); true }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!reachable) return null
|
||||||
|
|
||||||
|
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||||
|
val portInUrl = when {
|
||||||
|
scheme == "https" && port == 443 -> ""
|
||||||
|
scheme == "http" && port == 80 -> ""
|
||||||
|
else -> ":$port"
|
||||||
|
}
|
||||||
|
val paths = listOf(
|
||||||
|
"/dispensa/api/index.php?action=ping",
|
||||||
|
"/api/index.php?action=ping",
|
||||||
|
"/dispensa/api/index.php?action=get_settings",
|
||||||
|
"/api/index.php?action=get_settings",
|
||||||
|
"/evershelf/api/index.php?action=get_settings",
|
||||||
|
)
|
||||||
|
for (path in paths) {
|
||||||
|
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun openConn(urlStr: String): HttpURLConnection? {
|
private fun openConn(urlStr: String): HttpURLConnection? {
|
||||||
return try {
|
return try {
|
||||||
val conn = URL(urlStr).openConnection()
|
val conn = URL(urlStr).openConnection()
|
||||||
@@ -697,9 +829,52 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
|
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
|
||||||
|
|
||||||
val ports = listOf(443, 80, 8080, 8443)
|
val ports = listOf(443, 80, 8080, 8443)
|
||||||
|
|
||||||
|
// ── 1b. Fast path: likely hosts on Wi-Fi subnet (incl. .128) before full sweep ─
|
||||||
|
val priorityIps = linkedSetOf<String>()
|
||||||
|
try {
|
||||||
|
val ifaces = NetworkInterface.getNetworkInterfaces()
|
||||||
|
while (ifaces != null && ifaces.hasMoreElements()) {
|
||||||
|
val intf = ifaces.nextElement()
|
||||||
|
if (!intf.isUp || intf.isLoopback) continue
|
||||||
|
for (addr in intf.interfaceAddresses) {
|
||||||
|
val ip = addr.address
|
||||||
|
if (ip is java.net.Inet4Address && !ip.isLoopbackAddress) {
|
||||||
|
priorityIps.add(ip.hostAddress ?: continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
for (subnet in wifiSubnets.ifEmpty { subnets.take(1) }) {
|
||||||
|
for (last in listOf(1, 128, 100, 10, 50, 254)) {
|
||||||
|
priorityIps.add("$subnet.$last")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnUiThread { discoverStatus.text = "🔍 ${getString(R.string.setup_discovering_detail)}" }
|
||||||
|
for (ip in priorityIps) {
|
||||||
|
if (discoverCancelled.get()) break
|
||||||
|
for (port in ports) {
|
||||||
|
val hit = probeEverShelfHost(ip, port)
|
||||||
|
if (hit != null) {
|
||||||
|
runOnUiThread {
|
||||||
|
urlEdit.setText(hit)
|
||||||
|
discoverStatus.text = "✅ ${getString(R.string.setup_server_found)}: $hit"
|
||||||
|
discoverStatus.setTextColor(0xFF34d399.toInt())
|
||||||
|
showUrlStatus("✅ ${getString(R.string.setup_server_found)}", true)
|
||||||
|
btnDiscover.isEnabled = true
|
||||||
|
btnDiscover.text = getString(R.string.setup_discover_btn)
|
||||||
|
}
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val paths = listOf(
|
val paths = listOf(
|
||||||
"/api/index.php?action=get_settings",
|
"/dispensa/api/index.php?action=ping",
|
||||||
|
"/api/index.php?action=ping",
|
||||||
"/dispensa/api/index.php?action=get_settings",
|
"/dispensa/api/index.php?action=get_settings",
|
||||||
|
"/api/index.php?action=get_settings",
|
||||||
"/evershelf/api/index.php?action=get_settings",
|
"/evershelf/api/index.php?action=get_settings",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -744,30 +919,24 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Full HTTP probe on reachable host
|
// Full HTTP probe on reachable host
|
||||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||||
|
val portInUrl = when {
|
||||||
|
scheme == "https" && port == 443 -> ""
|
||||||
|
scheme == "http" && port == 80 -> ""
|
||||||
|
else -> ":$port"
|
||||||
|
}
|
||||||
for (path in paths) {
|
for (path in paths) {
|
||||||
if (discoverCancelled.get() || found.get()) break
|
if (discoverCancelled.get() || found.get()) break
|
||||||
val urlStr = "$scheme://$ip:$port$path"
|
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it }
|
||||||
try {
|
|
||||||
val conn = openConn(urlStr) ?: continue
|
|
||||||
val code = conn.responseCode
|
|
||||||
if (code in 200..399) {
|
|
||||||
val body = conn.inputStream.bufferedReader().readText()
|
|
||||||
conn.disconnect()
|
|
||||||
if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
|
|
||||||
return@submit urlStr.substringBefore("/api/") + "/"
|
|
||||||
}
|
|
||||||
} else conn.disconnect()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Collect results as they complete (not in submission order) ────
|
// ── 3. Collect results until all tasks finish or a server is found ────
|
||||||
var result: String? = null
|
var result: String? = null
|
||||||
var collected = 0
|
var collected = 0
|
||||||
while (collected < total && !discoverCancelled.get()) {
|
while (collected < total && !discoverCancelled.get() && result == null) {
|
||||||
val future = cs.poll(3, TimeUnit.SECONDS) ?: break
|
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue
|
||||||
collected++
|
collected++
|
||||||
val r = try { future.get() } catch (_: Exception) { null }
|
val r = try { future.get() } catch (_: Exception) { null }
|
||||||
if (r != null && found.compareAndSet(false, true)) {
|
if (r != null && found.compareAndSet(false, true)) {
|
||||||
@@ -819,7 +988,7 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
discoveredDevices.clear()
|
discoveredDevices.clear()
|
||||||
deviceAdapter?.notifyDataSetChanged()
|
deviceAdapter?.notifyDataSetChanged()
|
||||||
tvScanStatus.text = "🔍 Scansione in corso…"
|
tvScanStatus.text = getString(R.string.ble_scanning)
|
||||||
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
||||||
btnScanBle.isEnabled = false
|
btnScanBle.isEnabled = false
|
||||||
mgr.startScan()
|
mgr.startScan()
|
||||||
@@ -832,7 +1001,7 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
tvSelectedScale.text = "✅ ${info.name}"
|
tvSelectedScale.text = "✅ ${info.name}"
|
||||||
tvSelectedScale.visibility = View.VISIBLE
|
tvSelectedScale.visibility = View.VISIBLE
|
||||||
btnScanBle.isEnabled = true
|
btnScanBle.isEnabled = true
|
||||||
btnScanBle.text = "🔄 Scansiona di nuovo"
|
btnScanBle.text = getString(R.string.ble_scan_again)
|
||||||
// Start connection test
|
// Start connection test
|
||||||
startScaleTest(info)
|
startScaleTest(info)
|
||||||
}
|
}
|
||||||
@@ -845,7 +1014,7 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
scaleTestCard.visibility = View.VISIBLE
|
scaleTestCard.visibility = View.VISIBLE
|
||||||
testWeightBox.visibility = View.GONE
|
testWeightBox.visibility = View.GONE
|
||||||
step3NextButtons.visibility = View.GONE
|
step3NextButtons.visibility = View.GONE
|
||||||
tvTestStatus.text = "🔗 Connessione a ${info.name}…"
|
tvTestStatus.text = getString(R.string.ble_connecting_to).format(info.name)
|
||||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||||
tvTestWeight.text = "— g"
|
tvTestWeight.text = "— g"
|
||||||
// Disable confirm/retry until we have data
|
// Disable confirm/retry until we have data
|
||||||
@@ -869,23 +1038,25 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
override fun onConnecting(device: BluetoothDevice) {
|
override fun onConnecting(device: BluetoothDevice) {
|
||||||
if (!isInTestMode) return
|
if (!isInTestMode) return
|
||||||
tvTestStatus.text = "🔗 Connessione in corso…"
|
tvTestStatus.text = getString(R.string.ble_connecting)
|
||||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||||
}
|
}
|
||||||
override fun onConnected(deviceName: String) {
|
override fun onConnected(deviceName: String) {
|
||||||
if (!isInTestMode) return
|
if (!isInTestMode) return
|
||||||
tvTestStatus.text = "⚖️ Connesso! Posiziona un oggetto sulla bilancia…"
|
tvTestStatus.text = getString(R.string.ble_connected)
|
||||||
tvTestStatus.setTextColor(0xFF34d399.toInt())
|
tvTestStatus.setTextColor(0xFF34d399.toInt())
|
||||||
testWeightBox.visibility = View.VISIBLE
|
testWeightBox.visibility = View.VISIBLE
|
||||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||||
}
|
}
|
||||||
override fun onDisconnected() {
|
override fun onDisconnected() {
|
||||||
if (!isInTestMode) return
|
if (!isInTestMode) return
|
||||||
tvTestStatus.text = "⚠️ Connessione persa. Riprova."
|
tvTestStatus.text = getString(R.string.ble_disconnected)
|
||||||
tvTestStatus.setTextColor(0xFFfbbf24.toInt())
|
tvTestStatus.setTextColor(0xFFfbbf24.toInt())
|
||||||
testWeightBox.visibility = View.GONE
|
testWeightBox.visibility = View.GONE
|
||||||
testHasWeight = false
|
testHasWeight = false
|
||||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
|
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
|
||||||
|
// Always re-enable retry so the user is never stuck
|
||||||
|
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||||
}
|
}
|
||||||
override fun onWeightReceived(reading: WeightReading) {
|
override fun onWeightReceived(reading: WeightReading) {
|
||||||
if (!isInTestMode) return
|
if (!isInTestMode) return
|
||||||
@@ -896,7 +1067,7 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
"%g ${reading.unit}".format(reading.value)
|
"%g ${reading.unit}".format(reading.value)
|
||||||
tvTestWeight.text = display
|
tvTestWeight.text = display
|
||||||
testWeightBox.visibility = View.VISIBLE
|
testWeightBox.visibility = View.VISIBLE
|
||||||
tvTestStatus.text = "Peso ricevuto — coincide con quello sulla bilancia?"
|
tvTestStatus.text = getString(R.string.ble_weight_received)
|
||||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = true
|
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = true
|
||||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||||
@@ -918,10 +1089,10 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
override fun onScanStopped() {
|
override fun onScanStopped() {
|
||||||
btnScanBle.isEnabled = true
|
btnScanBle.isEnabled = true
|
||||||
if (discoveredDevices.isEmpty()) {
|
if (discoveredDevices.isEmpty()) {
|
||||||
tvScanStatus.text = "Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova."
|
tvScanStatus.text = getString(R.string.ble_no_scale_found)
|
||||||
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
||||||
} else {
|
} else {
|
||||||
tvScanStatus.text = "Seleziona la tua bilancia dall'elenco."
|
tvScanStatus.text = getString(R.string.ble_select_from_list)
|
||||||
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -971,22 +1142,32 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
// ── Summary / Finish ─────────────────────────────────────────────────
|
// ── Summary / Finish ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun buildSummary() {
|
private fun buildSummary() {
|
||||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||||
val screensOn = setupSwitchScreensaver.isChecked
|
val screensOn = setupSwitchScreensaver.isChecked
|
||||||
val scaleName = bleManager?.getSavedDeviceName()
|
val pricesOn = setupSwitchPrices.isChecked
|
||||||
val scaleOk = hasScale && scaleName != null
|
val mealPlanOn = setupSwitchMealPlan.isChecked
|
||||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
val zeroWasteOn = setupSwitchZeroWaste.isChecked
|
||||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
val scaleName = bleManager?.getSavedDeviceName()
|
||||||
|
val scaleOk = hasScale && scaleName != null
|
||||||
|
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||||
|
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; "es" -> "Español 🇪🇸"; "fr" -> "Français 🇫🇷"; else -> "Italiano 🇮🇹" }
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
|
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
|
||||||
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
|
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
|
||||||
sb.appendLine(when {
|
sb.appendLine(when {
|
||||||
scaleOk -> "✅ Bilancia: $scaleName"
|
scaleOk -> getString(R.string.summary_scale_ok).format(scaleName)
|
||||||
hasScale -> "⚠️ Bilancia: da configurare"
|
hasScale -> "⚠️ ${getString(R.string.summary_scale_warn)}"
|
||||||
else -> "⏭ ${getString(R.string.summary_scale_skip)}"
|
else -> "⏭ ${getString(R.string.summary_scale_skip)}"
|
||||||
})
|
})
|
||||||
sb.appendLine(if (screensOn) "🌙 ${getString(R.string.summary_screensaver_on)}" else "💡 ${getString(R.string.summary_screensaver_off)}")
|
sb.appendLine(if (screensOn) getString(R.string.summary_screensaver_on) else getString(R.string.summary_screensaver_off))
|
||||||
|
if (pricesOn) sb.appendLine(getString(R.string.summary_prices_on))
|
||||||
|
if (mealPlanOn) sb.appendLine(getString(R.string.summary_mealplan_on))
|
||||||
|
if (zeroWasteOn) sb.appendLine(getString(R.string.summary_zerowaste_on))
|
||||||
|
val geminiSet = !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()
|
||||||
|
val bringSet = !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()
|
||||||
|
sb.appendLine(if (geminiSet) getString(R.string.summary_gemini_set) else getString(R.string.summary_gemini_skip))
|
||||||
|
sb.appendLine(if (bringSet) getString(R.string.summary_bring_set) else getString(R.string.summary_bring_skip))
|
||||||
summaryText.text = sb.toString().trimEnd()
|
summaryText.text = sb.toString().trimEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,19 +1175,29 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
||||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
|
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
|
||||||
if (baseUrl.isNotEmpty()) {
|
if (baseUrl.isNotEmpty()) {
|
||||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||||
|
val priceEnabled = prefs.getBoolean(KEY_PRICE_ENABLED, false)
|
||||||
|
val mealPlan = prefs.getBoolean(KEY_MEAL_PLAN, false)
|
||||||
|
val zeroWaste = prefs.getBoolean(KEY_ZEROWASTE_TIPS, false)
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
val url = "$baseUrl/api/index.php?action=save_settings"
|
val url = "$baseUrl/api/index.php?action=save_settings"
|
||||||
|
val geminiKey = prefs.getString(KEY_GEMINI_KEY, "") ?: ""
|
||||||
|
val bringEmail = prefs.getString(KEY_BRING_EMAIL, "") ?: ""
|
||||||
|
val bringPassword = prefs.getString(KEY_BRING_PASSWORD, "") ?: ""
|
||||||
val body = buildString {
|
val body = buildString {
|
||||||
append("{\"screensaver_enabled\":$screensaver")
|
append("{\"screensaver_enabled\":$screensaver")
|
||||||
|
append(",\"price_enabled\":$priceEnabled")
|
||||||
|
append(",\"meal_plan_enabled\":$mealPlan")
|
||||||
|
append(",\"zerowaste_tips_enabled\":$zeroWaste")
|
||||||
if (hasScale) {
|
if (hasScale) {
|
||||||
// Use the tablet's actual LAN IP so the EverShelf server
|
|
||||||
// (potentially on a different machine) can reach the gateway.
|
|
||||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||||
}
|
}
|
||||||
|
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"")
|
||||||
|
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"")
|
||||||
|
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"")
|
||||||
append("}")
|
append("}")
|
||||||
}
|
}
|
||||||
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||||
|
|||||||
@@ -43,17 +43,18 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals -->
|
<!-- Settings gear (shown after setup, over WebView) — bottom-right corner so it never
|
||||||
|
overlaps the webapp header buttons (e.g. the 📷 scan button at top-right) -->
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btnSettings"
|
android:id="@+id/btnSettings"
|
||||||
android:layout_width="44dp"
|
android:layout_width="44dp"
|
||||||
android:layout_height="44dp"
|
android:layout_height="44dp"
|
||||||
android:layout_gravity="top|end"
|
android:layout_gravity="bottom|end"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginBottom="80dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:src="@android:drawable/ic_menu_manage"
|
android:src="@android:drawable/ic_menu_manage"
|
||||||
android:alpha="0.12"
|
android:alpha="0.28"
|
||||||
android:contentDescription="Settings"
|
android:contentDescription="Settings"
|
||||||
android:scaleType="centerInside"
|
android:scaleType="centerInside"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|||||||
@@ -224,6 +224,43 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Advanced / App Settings link -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="IMPOSTAZIONI AVANZATE"
|
||||||
|
android:textColor="#7c3aed"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/card_background"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Home Assistant, Gemini AI, Bring!, TTS, notifiche e tutte le altre funzionalità si configurano direttamente nell'app EverShelf."
|
||||||
|
android:textColor="#94a3b8"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnOpenAppSettings"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:text="← Torna all'app per le impostazioni avanzate"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="#7c3aed" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -78,11 +78,11 @@
|
|||||||
android:layout_marginBottom="24dp"
|
android:layout_marginBottom="24dp"
|
||||||
android:contentDescription="EverShelf" />
|
android:contentDescription="EverShelf" />
|
||||||
|
|
||||||
<!-- Title shown in all 3 languages so it's always readable -->
|
<!-- Title shown in all 5 languages so it's always readable -->
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Scegli la lingua\nChoose your language\nSprache wählen"
|
android:text="Scegli la lingua · Choose your language\nSprache wählen · Elige el idioma\nChoisissez votre langue"
|
||||||
android:textColor="#f1f5f9"
|
android:textColor="#f1f5f9"
|
||||||
android:textSize="22sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
@@ -117,7 +117,27 @@
|
|||||||
android:text="🇩🇪 Deutsch"
|
android:text="🇩🇪 Deutsch"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
android:backgroundTint="#b91c1c" />
|
android:backgroundTint="#b91c1c"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnLangEs"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:text="🇪🇸 Español"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="#c2410c"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnLangFr"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:text="🇫🇷 Français"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="#1d4ed8" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -1050,7 +1070,7 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════
|
<!-- ════════════════════════════════════════════
|
||||||
STEP 5 — Screensaver
|
STEP 5 — Features
|
||||||
════════════════════════════════════════════ -->
|
════════════════════════════════════════════ -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/stepScreensaver"
|
android:id="@+id/stepScreensaver"
|
||||||
@@ -1063,66 +1083,58 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🌙"
|
android:text="⚡"
|
||||||
android:textSize="52sp"
|
android:textSize="52sp"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:id="@+id/tvScreensaverTitle"
|
android:text="@string/setup_features_title"
|
||||||
android:text="@string/setup_screensaver_title"
|
|
||||||
android:textColor="#f1f5f9"
|
android:textColor="#f1f5f9"
|
||||||
android:textSize="24sp"
|
android:textSize="24sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvScreensaverDesc"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Dopo 5 minuti di inattività mostra un overlay con l'orologio e informazioni utili (statistiche, piano pasti). Lo schermo rimane SEMPRE acceso — questa opzione riguarda solo l'overlay visivo in-app."
|
android:text="@string/setup_features_desc"
|
||||||
android:textColor="#94a3b8"
|
android:textColor="#94a3b8"
|
||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:lineSpacingExtra="4dp"
|
android:lineSpacingExtra="4dp"
|
||||||
android:layout_marginBottom="28dp" />
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
<!-- Toggle card -->
|
<!-- Toggle: Screensaver -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:background="@drawable/card_background"
|
android:background="@drawable/card_background"
|
||||||
android:padding="20dp"
|
android:padding="16dp"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:layout_marginBottom="32dp">
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvScreensaverToggleLabel"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/setup_screensaver_toggle_label"
|
android:text="@string/setup_screensaver_toggle_label"
|
||||||
android:textColor="#f1f5f9"
|
android:textColor="#f1f5f9"
|
||||||
android:textSize="16sp"
|
android:textSize="15sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="3dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvScreensaverToggleHint"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/setup_screensaver_toggle_hint"
|
android:text="@string/setup_screensaver_toggle_hint"
|
||||||
android:textColor="#64748b"
|
android:textColor="#64748b"
|
||||||
android:textSize="13sp" />
|
android:textSize="12sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
android:id="@+id/setupSwitchScreensaver"
|
android:id="@+id/setupSwitchScreensaver"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -1130,6 +1142,114 @@
|
|||||||
android:checked="false" />
|
android:checked="false" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Toggle: Prezzi lista spesa -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="@drawable/card_background"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_prices_toggle_label"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="3dp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_prices_toggle_hint"
|
||||||
|
android:textColor="#64748b"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/setupSwitchPrices"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:checked="false" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Toggle: Piano pasti -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="@drawable/card_background"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_mealplan_toggle_label"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="3dp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_mealplan_toggle_hint"
|
||||||
|
android:textColor="#64748b"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/setupSwitchMealPlan"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:checked="false" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Toggle: Suggerimenti zero-waste -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="@drawable/card_background"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_zerowaste_toggle_label"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="3dp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_zerowaste_toggle_hint"
|
||||||
|
android:textColor="#64748b"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/setupSwitchZeroWaste"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:checked="false" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -1141,7 +1261,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="← Indietro"
|
android:text="@string/setup_step_back"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
@@ -1154,7 +1274,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
android:layout_weight="2"
|
android:layout_weight="2"
|
||||||
android:text="Avanti →"
|
android:text="@string/setup_step_next"
|
||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
android:backgroundTint="#7c3aed" />
|
android:backgroundTint="#7c3aed" />
|
||||||
@@ -1162,7 +1282,230 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════
|
<!-- ════════════════════════════════════════════
|
||||||
STEP 6 — Done
|
STEP 6 — Gemini AI key
|
||||||
|
════════════════════════════════════════════ -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/stepGemini"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🤖"
|
||||||
|
android:textSize="52sp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_gemini_title"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_gemini_desc"
|
||||||
|
android:textColor="#94a3b8"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<!-- How-to card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="@drawable/card_background"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="💡"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:layout_marginEnd="10dp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_gemini_how"
|
||||||
|
android:textColor="#7dd3fc"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/setupGeminiKeyEdit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/setup_gemini_hint"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textColorHint="#475569"
|
||||||
|
android:backgroundTint="#334155"
|
||||||
|
android:inputType="textVisiblePassword"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnGeminiBack"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/setup_step_back"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:strokeColor="#334155"
|
||||||
|
android:textColor="#64748b"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnGeminiSkip"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/setup_skip_later"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:strokeColor="#475569"
|
||||||
|
android:textColor="#94a3b8"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnGeminiNext"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/setup_confirm"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="#7c3aed" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════
|
||||||
|
STEP 7 — Bring! credentials
|
||||||
|
════════════════════════════════════════════ -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/stepBring"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🛒"
|
||||||
|
android:textSize="52sp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_bring_title"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/setup_bring_desc"
|
||||||
|
android:textColor="#94a3b8"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/setupBringEmailEdit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/setup_bring_email_hint"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textColorHint="#475569"
|
||||||
|
android:backgroundTint="#334155"
|
||||||
|
android:inputType="textEmailAddress"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:layout_marginBottom="10dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/setupBringPasswordEdit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/setup_bring_pass_hint"
|
||||||
|
android:textColor="#f1f5f9"
|
||||||
|
android:textColorHint="#475569"
|
||||||
|
android:backgroundTint="#334155"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnBringBack"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/setup_step_back"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:strokeColor="#334155"
|
||||||
|
android:textColor="#64748b"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnBringSkip"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/setup_skip_later"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:strokeColor="#475569"
|
||||||
|
android:textColor="#94a3b8"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnBringNext"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/setup_confirm"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="#059669" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════
|
||||||
|
STEP 8 — Done
|
||||||
════════════════════════════════════════════ -->
|
════════════════════════════════════════════ -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/stepDone"
|
android:id="@+id/stepDone"
|
||||||
@@ -1182,7 +1525,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Tutto pronto!"
|
android:text="@string/setup_done_title"
|
||||||
android:textColor="#f1f5f9"
|
android:textColor="#f1f5f9"
|
||||||
android:textSize="28sp"
|
android:textSize="28sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
@@ -1191,7 +1534,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk."
|
android:text="@string/setup_done_desc"
|
||||||
android:textColor="#94a3b8"
|
android:textColor="#94a3b8"
|
||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
@@ -1210,10 +1553,10 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Riepilogo configurazione"
|
android:text="@string/setup_done_summary_label"
|
||||||
android:textColor="#94a3b8"
|
android:textColor="#94a3b8"
|
||||||
android:textSize="13sp"
|
android:textSize="13sp"
|
||||||
android:textAllCaps="true"
|
android:textAllCaps="false"
|
||||||
android:letterSpacing="0.08"
|
android:letterSpacing="0.08"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
@@ -1231,7 +1574,7 @@
|
|||||||
android:id="@+id/btnLaunch"
|
android:id="@+id/btnLaunch"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="60dp"
|
android:layout_height="60dp"
|
||||||
android:text="🚀 Avvia EverShelf"
|
android:text="@string/btn_launch"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
android:backgroundTint="#059669" />
|
android:backgroundTint="#059669" />
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">EverShelf Kiosk</string>
|
<string name="app_name">EverShelf Kiosk</string>
|
||||||
|
|
||||||
<!-- Setup-Assistent Zeichenfolgen -->
|
|
||||||
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
|
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
|
||||||
<string name="setup_testing">Verbindung wird getestet…</string>
|
<string name="setup_testing">Verbindung wird getestet…</string>
|
||||||
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
|
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
|
||||||
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
|
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
|
||||||
<string name="setup_unreachable">Server nicht erreichbar</string>
|
<string name="setup_unreachable">Server nicht erreichbar</string>
|
||||||
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string> <string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string> <string name="setup_discovering">Suche läuft…</string>
|
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string>
|
||||||
|
<string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string>
|
||||||
|
<string name="setup_discovering">Suche läuft…</string>
|
||||||
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
|
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
|
||||||
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
|
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
|
||||||
<string name="setup_exit_title">Setup beenden?</string>
|
<string name="setup_exit_title">Setup beenden?</string>
|
||||||
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
|
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
|
||||||
<string name="setup_exit_confirm">Beenden</string>
|
<string name="setup_exit_confirm">Beenden</string>
|
||||||
<string name="setup_exit_cancel">Weiter</string>
|
<string name="setup_exit_cancel">Weiter</string>
|
||||||
|
<string name="setup_step_back">← Zurück</string>
|
||||||
<!-- Wizard Schritt 3: Smart-Waage -->
|
<string name="setup_step_next">Weiter →</string>
|
||||||
|
<string name="setup_skip_later">Später einrichten</string>
|
||||||
|
<string name="setup_confirm">Bestätigen →</string>
|
||||||
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
|
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
|
||||||
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
|
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
|
||||||
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
|
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
|
||||||
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
|
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
|
||||||
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
|
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
|
||||||
|
<string name="ble_scanning">🔍 Suche läuft…</string>
|
||||||
<!-- Gateway-Statusmeldungen -->
|
<string name="ble_connected">Verbunden! Gegenstand auf die Waage legen…</string>
|
||||||
|
<string name="ble_disconnected">Verbindung getrennt. Erneut versuchen.</string>
|
||||||
|
<string name="ble_no_scale_found">Keine Waage gefunden. Sicherstellen, dass sie eingeschaltet und in der Nähe ist, und erneut versuchen.</string>
|
||||||
|
<string name="ble_select_from_list">Waage aus der Liste auswählen.</string>
|
||||||
|
<string name="ble_not_confirmed">Waage nicht bestätigt. Erneut scannen.</string>
|
||||||
|
<string name="ble_scan_again">🔄 Erneut scannen</string>
|
||||||
|
<string name="ble_weight_received">Gewicht empfangen — Stimmt es mit der Anzeige überein?</string>
|
||||||
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
|
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
|
||||||
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
|
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
|
||||||
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
|
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
|
||||||
@@ -32,8 +40,6 @@
|
|||||||
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
|
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
|
||||||
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
|
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
|
||||||
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
|
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
|
||||||
|
|
||||||
<!-- Download- / Installationsfortschritt -->
|
|
||||||
<string name="install_downloading">Download läuft…</string>
|
<string name="install_downloading">Download läuft…</string>
|
||||||
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
|
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
|
||||||
<string name="install_installing">Installation läuft…</string>
|
<string name="install_installing">Installation läuft…</string>
|
||||||
@@ -45,29 +51,50 @@
|
|||||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||||
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||||
|
|
||||||
<!-- Schaltflächen -->
|
|
||||||
<string name="btn_back">Zurück</string>
|
<string name="btn_back">Zurück</string>
|
||||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||||
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
|
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
|
||||||
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
|
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
|
||||||
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
|
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
|
||||||
|
|
||||||
<!-- Server-Erreichbarkeit prüfen (Wizard Schritt 3) -->
|
|
||||||
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
|
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
|
||||||
<string name="wizard_server_ok">Server erreichbar ✅</string>
|
<string name="wizard_server_ok">Server erreichbar ✅</string>
|
||||||
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
|
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
|
||||||
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
|
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
|
||||||
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
|
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
|
||||||
<!-- Bildschirmschoner-Schritt -->
|
<string name="setup_features_title">Funktionen</string>
|
||||||
<string name="setup_screensaver_title">Bildschirmschoner</string>
|
<string name="setup_features_desc">Aktiviere die gewünschten Funktionen. Du kannst sie später jederzeit in den Servereinstellungen ändern.</string>
|
||||||
<string name="setup_screensaver_desc">Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert (Bildschirm bleibt immer an).</string>
|
<string name="setup_screensaver_toggle_label">Uhr-Bildschirmschoner</string>
|
||||||
<string name="setup_screensaver_toggle_label">Bildschirmschoner aktivieren</string>
|
<string name="setup_screensaver_toggle_hint">Zeigt eine Uhranzeige nach 5 Min. Inaktivität.</string>
|
||||||
<string name="setup_screensaver_toggle_hint">Wenn deaktiviert, bleibt der Bildschirm immer an.</string>
|
<string name="setup_prices_toggle_label">Einkaufslisten-Preise</string>
|
||||||
|
<string name="setup_prices_toggle_hint">KI-gestützte automatische Kostensätzung für jeden Artikel.</string>
|
||||||
<!-- Zusammenfassung -->
|
<string name="setup_mealplan_toggle_label">Mahlzeitenplan</string>
|
||||||
|
<string name="setup_mealplan_toggle_hint">Plane die Wöchentliche Mahlzeiten mit Rezepten aus deiner Vorratskammer.</string>
|
||||||
|
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
||||||
|
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
||||||
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
|
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.\n\nZum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||||
|
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → \"API-Schlüssel erhalten\"</string>
|
||||||
|
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
||||||
|
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
||||||
|
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||||
|
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
||||||
|
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
||||||
|
<string name="setup_done_title">Alles bereit!</string>
|
||||||
|
<string name="setup_done_desc">Die Einrichtung ist abgeschlossen. Auf den Button tippen, um EverShelf im Kiosk-Modus zu starten.</string>
|
||||||
|
<string name="setup_done_summary_label">KONFIGURATIONSSÜBERSICHT</string>
|
||||||
<string name="summary_lang">Sprache</string>
|
<string name="summary_lang">Sprache</string>
|
||||||
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
|
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
|
||||||
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
|
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
|
||||||
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
|
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
|
||||||
</resources>
|
<string name="summary_prices_on">Einkaufslisten-Preise: aktiviert</string>
|
||||||
|
<string name="summary_mealplan_on">Mahlzeitenplan: aktiviert</string>
|
||||||
|
<string name="summary_zerowaste_on">Zero-Waste-Tipps: aktiviert</string>
|
||||||
|
<string name="summary_gemini_set">Gemini AI: aktiviert</string>
|
||||||
|
<string name="summary_gemini_skip">Gemini AI: nicht konfiguriert</string>
|
||||||
|
<string name="summary_bring_set">Bring!: verbunden</string>
|
||||||
|
<string name="summary_bring_skip">Bring!: nicht konfiguriert</string>
|
||||||
|
<string name="ble_connecting_to">🔗 Verbinde mit %s…</string>
|
||||||
|
<string name="ble_connecting">🔗 Verbindung wird hergestellt…</string>
|
||||||
|
<string name="summary_scale_ok">Waage: %s</string>
|
||||||
|
<string name="summary_scale_warn">Waage: nicht bestätigt</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">EverShelf Kiosk</string>
|
||||||
|
<string name="setup_enter_url">Introduce primero una URL</string>
|
||||||
|
<string name="setup_testing">Probando conexión…</string>
|
||||||
|
<string name="setup_server_found">¡Servidor EverShelf encontrado y API activa!</string>
|
||||||
|
<string name="setup_api_not_found">Servidor accesible pero API EverShelf no encontrada. Comprueba la ruta.</string>
|
||||||
|
<string name="setup_unreachable">No se puede alcanzar el servidor</string>
|
||||||
|
<string name="setup_discover_btn">🔍 Buscar en la red local</string>
|
||||||
|
<string name="setup_perms_granted_next">✅ Permisos concedidos — Continuar →</string>
|
||||||
|
<string name="setup_discovering">Escaneando…</string>
|
||||||
|
<string name="setup_discovering_detail">Buscando servidores EverShelf en la red local…</string>
|
||||||
|
<string name="setup_discover_not_found">Ningún servidor EverShelf encontrado automáticamente. Introduce la URL manualmente.</string>
|
||||||
|
<string name="setup_exit_title">¿Salir de la configuración?</string>
|
||||||
|
<string name="setup_exit_message">Puedes completar la configuración más tarde cuando vuelvas a abrir la app.</string>
|
||||||
|
<string name="setup_exit_confirm">Salir</string>
|
||||||
|
<string name="setup_exit_cancel">Continuar</string>
|
||||||
|
<string name="setup_step_back">← Atrás</string>
|
||||||
|
<string name="setup_step_next">Siguiente →</string>
|
||||||
|
<string name="setup_skip_later">Configurar después</string>
|
||||||
|
<string name="setup_confirm">Confirmar →</string>
|
||||||
|
<string name="wizard_step3_title">Báscula inteligente</string>
|
||||||
|
<string name="wizard_step3_description">EverShelf Kiosk incluye una pasarela Bluetooth integrada — no necesitas ninguna app externa. Selecciona tu báscula abajo.</string>
|
||||||
|
<string name="wizard_step3_question">¿Tienes una báscula inteligente Bluetooth?</string>
|
||||||
|
<string name="wizard_step3_yes">✅ Sí, tengo una báscula</string>
|
||||||
|
<string name="wizard_step3_no">➡️ No, saltar este paso</string>
|
||||||
|
<string name="ble_scanning">🔍 Escaneando…</string>
|
||||||
|
<string name="ble_connected">¡Conectado! Coloca un objeto en la báscula…</string>
|
||||||
|
<string name="ble_disconnected">Conexión perdida. Reintentar.</string>
|
||||||
|
<string name="ble_no_scale_found">No se encontró ninguna báscula. Asegúrate de que esté encendida y cerca, e inténtalo de nuevo.</string>
|
||||||
|
<string name="ble_select_from_list">Selecciona tu báscula de la lista.</string>
|
||||||
|
<string name="ble_not_confirmed">Báscula no confirmada. Vuelve a escanear.</string>
|
||||||
|
<string name="ble_scan_again">🔄 Volver a escanear</string>
|
||||||
|
<string name="ble_weight_received">Peso recibido — ¿coincide con el mostrado en la báscula?</string>
|
||||||
|
<string name="wizard_gateway_installed">Báscula guardada ✅</string>
|
||||||
|
<string name="wizard_gateway_installed_detail">La pasarela BLE integrada se conectará automáticamente al inicio.</string>
|
||||||
|
<string name="wizard_gateway_not_installed">Ninguna báscula seleccionada</string>
|
||||||
|
<string name="wizard_gateway_not_installed_detail">Escanea las básculas BLE cercanas y toca una para seleccionarla.</string>
|
||||||
|
<string name="wizard_gateway_checking">Escaneando básculas BLE…</string>
|
||||||
|
<string name="wizard_gateway_up_to_date">Servicio BLE de báscula listo.</string>
|
||||||
|
<string name="wizard_gateway_update_available">Báscula BLE encontrada</string>
|
||||||
|
<string name="wizard_gateway_update_detail">Toca la báscula en la lista para conectarte.</string>
|
||||||
|
<string name="install_downloading">Descargando…</string>
|
||||||
|
<string name="install_downloading_detail">Por favor, espera mientras se descarga el archivo.</string>
|
||||||
|
<string name="install_installing">Instalando…</string>
|
||||||
|
<string name="install_confirm_detail">Confirma la instalación en el diálogo que se ha abierto.</string>
|
||||||
|
<string name="install_success">¡Instalado correctamente!</string>
|
||||||
|
<string name="install_success_detail">La app ha sido actualizada.</string>
|
||||||
|
<string name="install_error_download">Descarga fallida</string>
|
||||||
|
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
||||||
|
<string name="install_error_install">Instalación fallida</string>
|
||||||
|
<string name="install_perm_detail">Habilita \'Instalar apps desconocidas\' en los ajustes y vuelve aquí.</string>
|
||||||
|
<string name="install_btn_retry">↩ Reintentar</string>
|
||||||
|
<string name="btn_back">Atrás</string>
|
||||||
|
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
||||||
|
<string name="btn_launch_no_scale">🚀 Iniciar sin báscula</string>
|
||||||
|
<string name="btn_download_gateway">📥 Instalar Scale Gateway</string>
|
||||||
|
<string name="btn_update_gateway">📥 Actualizar Scale Gateway</string>
|
||||||
|
<string name="wizard_server_checking">Comprobando conexión al servidor…</string>
|
||||||
|
<string name="wizard_server_ok">Servidor accesible ✅</string>
|
||||||
|
<string name="wizard_server_ok_detail">Informe de errores activo — los fallos de instalación se enviarán automáticamente a GitHub Issues.</string>
|
||||||
|
<string name="wizard_server_error">Servidor no accesible ⚠️</string>
|
||||||
|
<string name="wizard_server_error_detail">Los errores no llegarán a GitHub Issues. Comprueba la URL introducida en el paso 2.</string>
|
||||||
|
<string name="setup_features_title">Funcionalidades</string>
|
||||||
|
<string name="setup_features_desc">Activa las funciones que quieras usar. Puedes cambiarlas en cualquier momento desde los ajustes del servidor.</string>
|
||||||
|
<string name="setup_screensaver_toggle_label">Salvapantallas reloj</string>
|
||||||
|
<string name="setup_screensaver_toggle_hint">Muestra un reloj después de 5 min de inactividad.</string>
|
||||||
|
<string name="setup_prices_toggle_label">Precios lista de la compra</string>
|
||||||
|
<string name="setup_prices_toggle_hint">Estimación automática del coste de cada artículo mediante IA.</string>
|
||||||
|
<string name="setup_mealplan_toggle_label">Plan de comidas</string>
|
||||||
|
<string name="setup_mealplan_toggle_hint">Planifica las comidas de la semana con recetas basadas en tu despensa.</string>
|
||||||
|
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
||||||
|
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
||||||
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
|
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.\n\nPara activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||||
|
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → \"Obtener clave API\"</string>
|
||||||
|
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
||||||
|
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
||||||
|
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.\n\nIntroduce tus credenciales de Bring! para activar la integración.</string>
|
||||||
|
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
||||||
|
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
||||||
|
<string name="setup_done_title">¡Todo listo!</string>
|
||||||
|
<string name="setup_done_desc">La configuración está completa. Pulsa el botón para iniciar EverShelf en modo quiosco.</string>
|
||||||
|
<string name="setup_done_summary_label">RESUMEN DE CONFIGURACIÓN</string>
|
||||||
|
<string name="summary_lang">Idioma</string>
|
||||||
|
<string name="summary_scale_skip">Báscula: no configurada</string>
|
||||||
|
<string name="summary_screensaver_on">Salvapantallas: activo</string>
|
||||||
|
<string name="summary_screensaver_off">Pantalla siempre encendida (salvapantallas desactivado)</string>
|
||||||
|
<string name="summary_prices_on">Precios lista de la compra: activados</string>
|
||||||
|
<string name="summary_mealplan_on">Plan de comidas: activado</string>
|
||||||
|
<string name="summary_zerowaste_on">Consejos zero-waste: activados</string>
|
||||||
|
<string name="summary_gemini_set">Gemini AI: activada</string>
|
||||||
|
<string name="summary_gemini_skip">Gemini AI: no configurada</string>
|
||||||
|
<string name="summary_bring_set">Bring!: conectada</string>
|
||||||
|
<string name="summary_bring_skip">Bring!: no configurada</string>
|
||||||
|
<string name="ble_connecting_to">🔗 Conectando con %s…</string>
|
||||||
|
<string name="ble_connecting">🔗 Estableciendo conexión…</string>
|
||||||
|
<string name="summary_scale_ok">Báscula: %s</string>
|
||||||
|
<string name="summary_scale_warn">Báscula: no confirmada</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">EverShelf Kiosk</string>
|
||||||
|
<string name="setup_enter_url">Veuillez d\'abord saisir une URL</string>
|
||||||
|
<string name="setup_testing">Test de connexion…</string>
|
||||||
|
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
||||||
|
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
||||||
|
<string name="setup_unreachable">Impossible d\'atteindre le serveur</string>
|
||||||
|
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
||||||
|
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
||||||
|
<string name="setup_discovering">Analyse en cours…</string>
|
||||||
|
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
||||||
|
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l\'URL manuellement.</string>
|
||||||
|
<string name="setup_exit_title">Quitter la configuration ?</string>
|
||||||
|
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l\'app.</string>
|
||||||
|
<string name="setup_exit_confirm">Quitter</string>
|
||||||
|
<string name="setup_exit_cancel">Continuer</string>
|
||||||
|
<string name="setup_step_back">← Retour</string>
|
||||||
|
<string name="setup_step_next">Suivant →</string>
|
||||||
|
<string name="setup_skip_later">Configurer plus tard</string>
|
||||||
|
<string name="setup_confirm">Confirmer →</string>
|
||||||
|
<string name="wizard_step3_title">Balance intelligente</string>
|
||||||
|
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
||||||
|
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
||||||
|
<string name="wizard_step3_yes">✅ Oui, j\'ai une balance</string>
|
||||||
|
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
||||||
|
<string name="ble_scanning">🔍 Scan en cours…</string>
|
||||||
|
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
||||||
|
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
||||||
|
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu\'elle est allumée et à proximité, puis réessayez.</string>
|
||||||
|
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
||||||
|
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
||||||
|
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
||||||
|
<string name="ble_weight_received">Poids reçu — correspond-il à l\'affichage de la balance ?</string>
|
||||||
|
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
||||||
|
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
||||||
|
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
||||||
|
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l\'une d\'elles pour la sélectionner.</string>
|
||||||
|
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
||||||
|
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
||||||
|
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
||||||
|
<string name="wizard_gateway_update_detail">Appuyez sur la balance dans la liste pour vous connecter.</string>
|
||||||
|
<string name="install_downloading">Téléchargement en cours…</string>
|
||||||
|
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
||||||
|
<string name="install_installing">Installation en cours…</string>
|
||||||
|
<string name="install_confirm_detail">Confirmez l\'installation dans la boîte de dialogue ouverte.</string>
|
||||||
|
<string name="install_success">Installé avec succès !</string>
|
||||||
|
<string name="install_success_detail">L\'app a été mise à jour.</string>
|
||||||
|
<string name="install_error_download">Téléchargement échoué</string>
|
||||||
|
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
||||||
|
<string name="install_error_install">Installation échouée</string>
|
||||||
|
<string name="install_perm_detail">Activez \'Installer des apps inconnues\' dans les paramètres, puis revenez ici.</string>
|
||||||
|
<string name="install_btn_retry">↩ Réessayer</string>
|
||||||
|
<string name="btn_back">Retour</string>
|
||||||
|
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
||||||
|
<string name="btn_launch_no_scale">🚀 Lancer sans balance</string>
|
||||||
|
<string name="btn_download_gateway">📥 Installer Scale Gateway</string>
|
||||||
|
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
||||||
|
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
||||||
|
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
||||||
|
<string name="wizard_server_ok_detail">Rapport d\'erreurs actif — les échecs d\'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||||
|
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
||||||
|
<string name="wizard_server_error_detail">Les erreurs n\'atteindront pas GitHub Issues. Vérifiez l\'URL saisie à l\'étape 2.</string>
|
||||||
|
<string name="setup_features_title">Fonctionnalités</string>
|
||||||
|
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
||||||
|
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
||||||
|
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d\'inactivité.</string>
|
||||||
|
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
||||||
|
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
||||||
|
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
||||||
|
<string name="setup_mealplan_toggle_hint">Planifiez les repas de la semaine avec des recettes basées sur votre garde-manger.</string>
|
||||||
|
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
||||||
|
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
||||||
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
|
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.\n\nPour l\'activer, entrez votre clé API Gemini gratuite.</string>
|
||||||
|
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → \"Obtenir une clé API\"</string>
|
||||||
|
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
||||||
|
<string name="setup_bring_title">Bring! Liste de courses</string>
|
||||||
|
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l\'app Bring!.\n\nEntrez vos identifiants Bring! pour activer l\'intégration.</string>
|
||||||
|
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
||||||
|
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
||||||
|
<string name="setup_done_title">Tout est prêt !</string>
|
||||||
|
<string name="setup_done_desc">La configuration est terminée. Appuyez sur le bouton pour lancer EverShelf en mode kiosque.</string>
|
||||||
|
<string name="setup_done_summary_label">RÉSUMÉ DE CONFIGURATION</string>
|
||||||
|
<string name="summary_lang">Langue</string>
|
||||||
|
<string name="summary_scale_skip">Balance : non configurée</string>
|
||||||
|
<string name="summary_screensaver_on">Écran de veille : actif</string>
|
||||||
|
<string name="summary_screensaver_off">Écran toujours allumé (écran de veille désactivé)</string>
|
||||||
|
<string name="summary_prices_on">Prix liste de courses : activés</string>
|
||||||
|
<string name="summary_mealplan_on">Plan de repas : activé</string>
|
||||||
|
<string name="summary_zerowaste_on">Conseils zéro déchet : activés</string>
|
||||||
|
<string name="summary_gemini_set">Gemini AI : activée</string>
|
||||||
|
<string name="summary_gemini_skip">Gemini AI : non configurée</string>
|
||||||
|
<string name="summary_bring_set">Bring! : connectée</string>
|
||||||
|
<string name="summary_bring_skip">Bring! : non configurée</string>
|
||||||
|
<string name="ble_connecting_to">🔗 Connexion à %s…</string>
|
||||||
|
<string name="ble_connecting">🔗 Connexion en cours…</string>
|
||||||
|
<string name="summary_scale_ok">Balance : %s</string>
|
||||||
|
<string name="summary_scale_warn">Balance : non confirmée</string>
|
||||||
|
</resources>
|
||||||
@@ -1,39 +1,45 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">EverShelf Kiosk</string>
|
<string name="app_name">EverShelf Kiosk</string>
|
||||||
|
|
||||||
<!-- Stringhe setup wizard -->
|
|
||||||
<string name="setup_enter_url">Inserisci prima un URL</string>
|
<string name="setup_enter_url">Inserisci prima un URL</string>
|
||||||
<string name="setup_testing">Verifica connessione…</string>
|
<string name="setup_testing">Verifica connessione…</string>
|
||||||
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
|
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
|
||||||
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
|
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
|
||||||
<string name="setup_unreachable">Impossibile raggiungere il server</string>
|
<string name="setup_unreachable">Impossibile raggiungere il server</string>
|
||||||
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_discovering">Scansione in corso…</string>
|
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string>
|
||||||
|
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
||||||
|
<string name="setup_discovering">Scansione in corso…</string>
|
||||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||||
<string name="setup_exit_confirm">Esci</string>
|
<string name="setup_exit_confirm">Esci</string>
|
||||||
<string name="setup_exit_cancel">Continua</string>
|
<string name="setup_exit_cancel">Continua</string>
|
||||||
|
<string name="setup_step_back">← Indietro</string>
|
||||||
<!-- Wizard Step 3: Bilancia smart -->
|
<string name="setup_step_next">Avanti →</string>
|
||||||
|
<string name="setup_skip_later">Lo faccio dopo</string>
|
||||||
|
<string name="setup_confirm">Conferma →</string>
|
||||||
<string name="wizard_step3_title">Bilancia Smart</string>
|
<string name="wizard_step3_title">Bilancia Smart</string>
|
||||||
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
|
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
|
||||||
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
|
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
|
||||||
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
|
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
|
||||||
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
|
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
|
||||||
|
<string name="ble_scanning">🔍 Scansione in corso…</string>
|
||||||
<!-- Messaggi stato gateway -->
|
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
||||||
|
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
||||||
|
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
||||||
|
<string name="ble_select_from_list">Seleziona la tua bilancia dall\'elenco.</string>
|
||||||
|
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
||||||
|
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
||||||
|
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
||||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||||
|
|
||||||
<!-- Stati scaricamento / installazione -->
|
|
||||||
<string name="install_downloading">Scaricamento in corso…</string>
|
<string name="install_downloading">Scaricamento in corso…</string>
|
||||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||||
<string name="install_installing">Installazione in corso…</string>
|
<string name="install_installing">Installazione in corso…</string>
|
||||||
@@ -45,29 +51,50 @@
|
|||||||
<string name="install_error_install">Installazione fallita</string>
|
<string name="install_error_install">Installazione fallita</string>
|
||||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||||
<string name="install_btn_retry">↩ Riprova</string>
|
<string name="install_btn_retry">↩ Riprova</string>
|
||||||
|
|
||||||
<!-- Pulsanti -->
|
|
||||||
<string name="btn_back">Indietro</string>
|
<string name="btn_back">Indietro</string>
|
||||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||||
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
|
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
|
||||||
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
|
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
|
||||||
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
|
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
|
||||||
|
|
||||||
<!-- Verifica raggiungibilità server (step 3 wizard) -->
|
|
||||||
<string name="wizard_server_checking">Verifica connessione server…</string>
|
<string name="wizard_server_checking">Verifica connessione server…</string>
|
||||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||||
<!-- Passo salvaschermo -->
|
<string name="setup_features_title">Funzionalità</string>
|
||||||
<string name="setup_screensaver_title">Salvaschermo</string>
|
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||||
<string name="setup_screensaver_desc">Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato (lo schermo resta sempre acceso).</string>
|
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||||
<string name="setup_screensaver_toggle_label">Attiva salvaschermo</string>
|
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||||
<string name="setup_screensaver_toggle_hint">Se disattivo, lo schermo resta sempre acceso.</string>
|
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||||
|
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||||
<!-- Riepilogo -->
|
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||||
|
<string name="setup_mealplan_toggle_hint">Pianifica i pasti della settimana suggerendo ricette basate sulla dispensa.</string>
|
||||||
|
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||||
|
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||||
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
|
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.\n\nPer abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||||
|
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → \"Ottieni chiave API\"</string>
|
||||||
|
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
||||||
|
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
||||||
|
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l\'app Bring!.\n\nInserisci le credenziali del tuo account Bring! per abilitare l\'integrazione.</string>
|
||||||
|
<string name="setup_bring_email_hint">Email Bring!</string>
|
||||||
|
<string name="setup_bring_pass_hint">Password Bring!</string>
|
||||||
|
<string name="setup_done_title">Tutto pronto!</string>
|
||||||
|
<string name="setup_done_desc">La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk.</string>
|
||||||
|
<string name="setup_done_summary_label">RIEPILOGO CONFIGURAZIONE</string>
|
||||||
<string name="summary_lang">Lingua</string>
|
<string name="summary_lang">Lingua</string>
|
||||||
<string name="summary_scale_skip">Bilancia: non configurata</string>
|
<string name="summary_scale_skip">Bilancia: non configurata</string>
|
||||||
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
|
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
|
||||||
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
|
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
|
||||||
</resources>
|
<string name="summary_prices_on">Prezzi lista spesa: abilitati</string>
|
||||||
|
<string name="summary_mealplan_on">Piano pasti: abilitato</string>
|
||||||
|
<string name="summary_zerowaste_on">Suggerimenti zero-waste: abilitati</string>
|
||||||
|
<string name="summary_gemini_set">Gemini AI: abilitata</string>
|
||||||
|
<string name="summary_gemini_skip">Gemini AI: non configurata</string>
|
||||||
|
<string name="summary_bring_set">Bring!: connessa</string>
|
||||||
|
<string name="summary_bring_skip">Bring!: non configurata</string>
|
||||||
|
<string name="ble_connecting_to">🔗 Connessione a %s…</string>
|
||||||
|
<string name="ble_connecting">🔗 Connessione in corso…</string>
|
||||||
|
<string name="summary_scale_ok">Bilancia: %s</string>
|
||||||
|
<string name="summary_scale_warn">Bilancia: da configurare</string>
|
||||||
|
</resources>
|
||||||
@@ -1,28 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">EverShelf Kiosk</string>
|
<string name="app_name">EverShelf Kiosk</string>
|
||||||
|
|
||||||
<!-- Setup wizard strings -->
|
<!-- ── Setup wizard ─────────────────────────────────────────────────── -->
|
||||||
<string name="setup_enter_url">Please enter a URL first</string>
|
<string name="setup_enter_url">Please enter a URL first</string>
|
||||||
<string name="setup_testing">Testing connection…</string>
|
<string name="setup_testing">Testing connection…</string>
|
||||||
<string name="setup_server_found">EverShelf server found and API active!</string>
|
<string name="setup_server_found">EverShelf server found and API active!</string>
|
||||||
<string name="setup_api_not_found">Server reachable but EverShelf API not found. Check the path.</string>
|
<string name="setup_api_not_found">Server reachable but EverShelf API not found. Check the path.</string>
|
||||||
<string name="setup_unreachable">Cannot reach server</string>
|
<string name="setup_unreachable">Cannot reach server</string>
|
||||||
<string name="setup_discover_btn">🔍 Search local network</string> <string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string> <string name="setup_discovering">Scanning…</string>
|
<string name="setup_discover_btn">🔍 Search local network</string>
|
||||||
|
<string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string>
|
||||||
|
<string name="setup_discovering">Scanning…</string>
|
||||||
<string name="setup_discovering_detail">Searching for EverShelf servers on the local network…</string>
|
<string name="setup_discovering_detail">Searching for EverShelf servers on the local network…</string>
|
||||||
<string name="setup_discover_not_found">No EverShelf server found automatically. Enter the URL manually.</string>
|
<string name="setup_discover_not_found">No EverShelf server found automatically. Enter the URL manually.</string>
|
||||||
<string name="setup_exit_title">Exit setup?</string>
|
<string name="setup_exit_title">Exit setup?</string>
|
||||||
<string name="setup_exit_message">You can complete setup later when you reopen the app.</string>
|
<string name="setup_exit_message">You can complete setup later when you reopen the app.</string>
|
||||||
<string name="setup_exit_confirm">Exit</string>
|
<string name="setup_exit_confirm">Exit</string>
|
||||||
<string name="setup_exit_cancel">Continue</string>
|
<string name="setup_exit_cancel">Continue</string>
|
||||||
|
<string name="setup_step_back">← Back</string>
|
||||||
|
<string name="setup_step_next">Next →</string>
|
||||||
|
<string name="setup_skip_later">Set up later</string>
|
||||||
|
<string name="setup_confirm">Confirm →</string>
|
||||||
|
|
||||||
<!-- Wizard Step 3: Smart scale -->
|
<!-- ── Wizard Step 4: Smart scale ───────────────────────────────────── -->
|
||||||
<string name="wizard_step3_title">Smart Scale</string>
|
<string name="wizard_step3_title">Smart Scale</string>
|
||||||
<string name="wizard_step3_description">EverShelf Kiosk includes a built-in Bluetooth gateway — no external app needed. Select your scale below.</string>
|
<string name="wizard_step3_description">EverShelf Kiosk includes a built-in Bluetooth gateway — no external app needed. Select your scale below.</string>
|
||||||
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
|
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
|
||||||
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
|
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
|
||||||
<string name="wizard_step3_no">➡️ No, skip this step</string>
|
<string name="wizard_step3_no">➡️ No, skip this step</string>
|
||||||
|
|
||||||
<!-- Gateway status messages -->
|
<!-- BLE scan / test feedback (previously hardcoded) -->
|
||||||
|
<string name="ble_scanning">🔍 Scanning…</string>
|
||||||
|
<string name="ble_connected">Connected! Place an object on the scale…</string>
|
||||||
|
<string name="ble_disconnected">Connection lost. Retry.</string>
|
||||||
|
<string name="ble_no_scale_found">No scale found. Make sure it is on and nearby, then retry.</string>
|
||||||
|
<string name="ble_select_from_list">Select your scale from the list.</string>
|
||||||
|
<string name="ble_not_confirmed">Scale not confirmed. Retry scan.</string>
|
||||||
|
<string name="ble_scan_again">🔄 Scan again</string>
|
||||||
|
<string name="ble_weight_received">Weight received — does it match the display?</string>
|
||||||
|
|
||||||
|
<!-- ── Gateway status messages ──────────────────────────────────────── -->
|
||||||
<string name="wizard_gateway_installed">Scale device saved ✅</string>
|
<string name="wizard_gateway_installed">Scale device saved ✅</string>
|
||||||
<string name="wizard_gateway_installed_detail">The integrated BLE gateway will connect automatically on startup.</string>
|
<string name="wizard_gateway_installed_detail">The integrated BLE gateway will connect automatically on startup.</string>
|
||||||
<string name="wizard_gateway_not_installed">No scale selected</string>
|
<string name="wizard_gateway_not_installed">No scale selected</string>
|
||||||
@@ -32,41 +49,76 @@
|
|||||||
<string name="wizard_gateway_update_available">BLE scale found</string>
|
<string name="wizard_gateway_update_available">BLE scale found</string>
|
||||||
<string name="wizard_gateway_update_detail">Tap the scale in the list to connect.</string>
|
<string name="wizard_gateway_update_detail">Tap the scale in the list to connect.</string>
|
||||||
|
|
||||||
<!-- Install / download progress states -->
|
<!-- ── Install / download progress states ───────────────────────────── -->
|
||||||
<string name="install_downloading">Scaricamento in corso…</string>
|
<string name="install_downloading">Downloading…</string>
|
||||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
<string name="install_downloading_detail">Please wait, the file is being downloaded.</string>
|
||||||
<string name="install_installing">Installazione in corso…</string>
|
<string name="install_installing">Installing…</string>
|
||||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
<string name="install_confirm_detail">Confirm the installation in the dialog that has opened.</string>
|
||||||
<string name="install_success">Installato con successo!</string>
|
<string name="install_success">Installed successfully!</string>
|
||||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
<string name="install_success_detail">The app has been updated.</string>
|
||||||
<string name="install_error_download">Download fallito</string>
|
<string name="install_error_download">Download failed</string>
|
||||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
<string name="install_error_download_detail">Check your connection and try again.</string>
|
||||||
<string name="install_error_install">Installazione fallita</string>
|
<string name="install_error_install">Installation failed</string>
|
||||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
<string name="install_perm_detail">Enable \'Install unknown apps\' in settings, then come back here.</string>
|
||||||
<string name="install_btn_retry">↩ Riprova</string>
|
<string name="install_btn_retry">↩ Retry</string>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- ── Buttons ───────────────────────────────────────────────────────── -->
|
||||||
<string name="btn_back">Back</string>
|
<string name="btn_back">Back</string>
|
||||||
<string name="btn_launch">🚀 Launch EverShelf</string>
|
<string name="btn_launch">🚀 Launch EverShelf</string>
|
||||||
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
|
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
|
||||||
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
|
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
|
||||||
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
|
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
|
||||||
|
|
||||||
<!-- Server reachability check (wizard step 3) -->
|
<!-- ── Server reachability check ────────────────────────────────────── -->
|
||||||
<string name="wizard_server_checking">Checking server connection…</string>
|
<string name="wizard_server_checking">Checking server connection…</string>
|
||||||
<string name="wizard_server_ok">Server reachable ✅</string>
|
<string name="wizard_server_ok">Server reachable ✅</string>
|
||||||
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
|
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
|
||||||
<string name="wizard_server_error">Server not reachable ⚠️</string>
|
<string name="wizard_server_error">Server not reachable ⚠️</string>
|
||||||
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
|
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
|
||||||
<!-- Screensaver step -->
|
|
||||||
<string name="setup_screensaver_title">Salvaschermo in-app</string>
|
|
||||||
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
|
|
||||||
<string name="setup_screensaver_toggle_label">Abilita salvaschermo orologio</string>
|
|
||||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min. Lo schermo resta sempre acceso.</string>
|
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- ── Step 5 — Features ─────────────────────────────────────────────── -->
|
||||||
|
<string name="setup_features_title">Features</string>
|
||||||
|
<string name="setup_features_desc">Enable the features you want to use. You can always change them later in the server settings.</string>
|
||||||
|
<string name="setup_screensaver_toggle_label">Clock screensaver</string>
|
||||||
|
<string name="setup_screensaver_toggle_hint">Shows a clock overlay after 5 min of inactivity.</string>
|
||||||
|
<string name="setup_prices_toggle_label">Shopping list prices</string>
|
||||||
|
<string name="setup_prices_toggle_hint">AI-powered automatic cost estimate for each item in the list.</string>
|
||||||
|
<string name="setup_mealplan_toggle_label">Meal plan</string>
|
||||||
|
<string name="setup_mealplan_toggle_hint">Plan the week\'s meals with recipes based on your pantry.</string>
|
||||||
|
<string name="setup_zerowaste_toggle_label">Zero-waste tips</string>
|
||||||
|
<string name="setup_zerowaste_toggle_hint">Show tips for reusing scraps (peels, cooking water, etc.) while cooking.</string>
|
||||||
|
|
||||||
|
<!-- ── Step 6 — Gemini AI key ─────────────────────────────────────────── -->
|
||||||
|
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||||
|
<string name="setup_gemini_desc">EverShelf uses Google Gemini AI for recipe suggestions, smart shopping estimates and more.\n\nTo enable it, enter your free Gemini API key below.</string>
|
||||||
|
<string name="setup_gemini_how">Get your free key at: aistudio.google.com → \"Get API key\"</string>
|
||||||
|
<string name="setup_gemini_hint">Paste your API key here (starts with AIza…)</string>
|
||||||
|
|
||||||
|
<!-- ── Step 7 — Bring! credentials ──────────────────────────────────── -->
|
||||||
|
<string name="setup_bring_title">Bring! Shopping List</string>
|
||||||
|
<string name="setup_bring_desc">EverShelf can sync your shopping list with the Bring! app.\n\nEnter your Bring! account credentials to enable this integration.</string>
|
||||||
|
<string name="setup_bring_email_hint">Bring! email address</string>
|
||||||
|
<string name="setup_bring_pass_hint">Bring! password</string>
|
||||||
|
|
||||||
|
<!-- ── Step 8 — Done ─────────────────────────────────────────────────── -->
|
||||||
|
<string name="setup_done_title">All set!</string>
|
||||||
|
<string name="setup_done_desc">Setup is complete. Press the button below to launch EverShelf in kiosk mode.</string>
|
||||||
|
<string name="setup_done_summary_label">CONFIGURATION SUMMARY</string>
|
||||||
|
|
||||||
|
<!-- ── Summary lines ─────────────────────────────────────────────────── -->
|
||||||
<string name="summary_lang">Language</string>
|
<string name="summary_lang">Language</string>
|
||||||
<string name="summary_scale_skip">Scale: not configured</string>
|
<string name="summary_scale_skip">Scale: not configured</string>
|
||||||
<string name="summary_screensaver_on">Screensaver: enabled</string>
|
<string name="summary_screensaver_on">Screensaver: enabled</string>
|
||||||
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</string>
|
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</string>
|
||||||
|
<string name="summary_prices_on">Shopping list prices: enabled</string>
|
||||||
|
<string name="summary_mealplan_on">Meal plan: enabled</string>
|
||||||
|
<string name="summary_zerowaste_on">Zero-waste tips: enabled</string>
|
||||||
|
<string name="summary_gemini_set">Gemini AI: enabled</string>
|
||||||
|
<string name="summary_gemini_skip">Gemini AI: not configured</string>
|
||||||
|
<string name="summary_bring_set">Bring!: connected</string>
|
||||||
|
<string name="summary_bring_skip">Bring!: not configured</string>
|
||||||
|
<string name="ble_connecting_to">🔗 Connecting to %s…</string>
|
||||||
|
<string name="ble_connecting">🔗 Connecting…</string>
|
||||||
|
<string name="summary_scale_ok">Scale: %s</string>
|
||||||
|
<string name="summary_scale_warn">Scale: not confirmed</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+510
-81
@@ -11,9 +11,23 @@
|
|||||||
<title>EverShelf</title>
|
<title>EverShelf</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||||
<link rel="stylesheet" href="assets/css/style.css?v=20260516b">
|
<link rel="stylesheet" href="assets/css/style.css?v=20260606m">
|
||||||
<!-- QuaggaJS for barcode scanning -->
|
<!-- Core modules (auth, DOM helpers) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
<script src="assets/js/core/dom.js?v=20260603a"></script>
|
||||||
|
<script src="assets/js/core/auth.js?v=20260603b"></script>
|
||||||
|
<!-- ZBar WASM — lazy on kiosk WebView (OOM); eager elsewhere -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var kioskWv = /; wv\)/.test(navigator.userAgent);
|
||||||
|
if (kioskWv) return;
|
||||||
|
document.write('<script src="assets/vendor/zbar/index.js?v=20260606a"><\/script>');
|
||||||
|
document.write('<script>if(window.zbarWasm&&zbarWasm.setModuleArgs){zbarWasm.setModuleArgs({locateFile:function(f){return"assets/vendor/zbar/"+f}});}<\/script>');
|
||||||
|
document.write('<script src="assets/vendor/zbar/polyfill.js?v=20260606a"><\/script>');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- QuaggaJS — legacy last-resort only -->
|
||||||
|
<script src="assets/vendor/quagga/quagga.min.js?v=20260603a"></script>
|
||||||
|
<script>if(typeof Quagga==='undefined'){document.write('<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"><\\/script>');}</script>
|
||||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||||
<script type="module">
|
<script type="module">
|
||||||
// Lazy-load the embedding pipeline only when first needed.
|
// Lazy-load the embedding pipeline only when first needed.
|
||||||
@@ -25,11 +39,27 @@
|
|||||||
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
|
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
|
||||||
window._categoryPipelinePromise = (async () => {
|
window._categoryPipelinePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const { pipeline, env } = await import(
|
const localBase = 'assets/vendor/transformers/';
|
||||||
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
|
const cdnBase = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/';
|
||||||
);
|
const modelProbe = localBase + 'Xenova/all-MiniLM-L6-v2/tokenizer.json';
|
||||||
// Keep WASM/model files in the browser cache; disable remote model check
|
let pipeline, env;
|
||||||
// to avoid CORS issues with the self-hosted instance.
|
try {
|
||||||
|
({ pipeline, env } = await import(localBase + 'transformers.min.js'));
|
||||||
|
} catch (_) {
|
||||||
|
({ pipeline, env } = await import(cdnBase + 'transformers.min.js'));
|
||||||
|
}
|
||||||
|
// Use bundled model files when present; otherwise HuggingFace CDN (no 404 spam)
|
||||||
|
let localModels = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch(modelProbe, { method: 'HEAD' });
|
||||||
|
localModels = r.ok;
|
||||||
|
} catch (_) {}
|
||||||
|
if (localModels) {
|
||||||
|
env.localModelPath = localBase;
|
||||||
|
env.allowLocalModels = true;
|
||||||
|
} else {
|
||||||
|
env.allowLocalModels = false;
|
||||||
|
}
|
||||||
env.allowRemoteModels = true;
|
env.allowRemoteModels = true;
|
||||||
env.useBrowserCache = true;
|
env.useBrowserCache = true;
|
||||||
const pipe = await pipeline(
|
const pipe = await pipeline(
|
||||||
@@ -54,8 +84,17 @@
|
|||||||
<div id="app-preloader" aria-hidden="true">
|
<div id="app-preloader" aria-hidden="true">
|
||||||
<div class="app-preloader-inner">
|
<div class="app-preloader-inner">
|
||||||
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
|
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
|
||||||
<div class="app-preloader-spinner"></div>
|
<div class="app-preloader-spinner" id="preloader-spinner"></div>
|
||||||
<span class="app-preloader-version" id="preloader-version">v1.7.15</span>
|
<div id="preloader-progress-wrap" class="preloader-progress-wrap" style="display:none">
|
||||||
|
<div class="preloader-bar-track">
|
||||||
|
<div id="preloader-bar" class="preloader-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="check-ticker" class="check-ticker"></div>
|
||||||
|
</div>
|
||||||
|
<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.41</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,7 +107,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<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.15</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.41</span>
|
||||||
</h1>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -160,10 +199,12 @@
|
|||||||
<div id="expired-list"></div>
|
<div id="expired-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
|
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
|
||||||
<div id="dashboard-insight-wrap" style="position:relative">
|
<div id="dashboard-insight-wrap" style="position:relative">
|
||||||
<div id="waste-chart-section" style="display:none"></div>
|
<div id="waste-chart-section" style="display:none"></div>
|
||||||
<div id="nutrition-section" style="display:none"></div>
|
<div id="nutrition-section" style="display:none"></div>
|
||||||
|
<div id="monthly-stats-section" style="display:none"></div>
|
||||||
|
<div id="macros-section" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert for soonest expiring items -->
|
<!-- Alert for soonest expiring items -->
|
||||||
@@ -183,8 +224,9 @@
|
|||||||
<!-- ===== INVENTORY LIST ===== -->
|
<!-- ===== INVENTORY LIST ===== -->
|
||||||
<section class="page" id="page-inventory">
|
<section class="page" id="page-inventory">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||||
|
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="location-tabs" id="location-tabs">
|
<div class="location-tabs" id="location-tabs">
|
||||||
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
|
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
|
||||||
@@ -213,7 +255,7 @@
|
|||||||
<!-- ===== SCAN PAGE ===== -->
|
<!-- ===== SCAN PAGE ===== -->
|
||||||
<section class="page" id="page-scan">
|
<section class="page" id="page-scan">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="scan.title">Scansiona</h2>
|
<h2 data-i18n="scan.title">Scansiona</h2>
|
||||||
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,6 +281,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Live partial code preview -->
|
<!-- Live partial code preview -->
|
||||||
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
|
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
|
||||||
|
<!-- Scan status bar -->
|
||||||
|
<div class="scan-status-bar" id="scan-status-bar">
|
||||||
|
<span id="scan-status-method" class="scan-status-method"></span>
|
||||||
|
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||||
|
</div>
|
||||||
|
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
|
||||||
|
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||||
|
<div class="scan-ai-overlay-inner">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span class="scan-ai-overlay-label" data-i18n="scan.ai_overlay_label">Gemini Vision</span>
|
||||||
|
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Success flash overlay -->
|
<!-- Success flash overlay -->
|
||||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||||
<div class="scan-confirm-check">✓</div>
|
<div class="scan-confirm-check">✓</div>
|
||||||
@@ -257,6 +312,9 @@
|
|||||||
<!-- Scan errors -->
|
<!-- Scan errors -->
|
||||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Manual AI identification (only when user taps — never automatic) -->
|
||||||
|
<button class="btn btn-accent scan-ai-manual-btn" id="scan-ai-manual-btn" type="button" style="display:none" onclick="_triggerManualAiScan()" data-i18n="scan.ai_manual_btn">🤖 Identifica con AI</button>
|
||||||
|
|
||||||
<!-- Recent scans -->
|
<!-- Recent scans -->
|
||||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||||
@@ -316,13 +374,14 @@
|
|||||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||||
<section class="page" id="page-action">
|
<section class="page" id="page-action">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Banner: shopping list scan context -->
|
<!-- Banner: shopping list scan context -->
|
||||||
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
||||||
<div class="product-preview product-preview-large" id="action-product-preview"></div>
|
<div class="product-preview product-preview-small" id="action-product-preview"></div>
|
||||||
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
||||||
|
<div id="action-related-stock" style="display:none"></div>
|
||||||
<div class="action-buttons" id="action-buttons-container">
|
<div class="action-buttons" id="action-buttons-container">
|
||||||
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||||
<span class="btn-icon">📥</span>
|
<span class="btn-icon">📥</span>
|
||||||
@@ -338,7 +397,7 @@
|
|||||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-add">
|
<section class="page" id="page-add">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="add-product-preview"></div>
|
<div class="product-preview-small" id="add-product-preview"></div>
|
||||||
@@ -361,6 +420,7 @@
|
|||||||
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
|
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||||
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="qty-unit-badge qty-unit-muted" id="add-quantity-unit" aria-live="polite">pz</span>
|
||||||
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
||||||
<option value="pz">pz</option>
|
<option value="pz">pz</option>
|
||||||
<option value="conf">conf</option>
|
<option value="conf">conf</option>
|
||||||
@@ -401,7 +461,7 @@
|
|||||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-use">
|
<section class="page" id="page-use">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="use-product-preview"></div>
|
<div class="product-preview-small" id="use-product-preview"></div>
|
||||||
@@ -440,11 +500,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="use-partial">
|
<div class="use-partial">
|
||||||
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
||||||
<div class="qty-control">
|
<div class="qty-control-with-unit">
|
||||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
<div class="qty-control">
|
||||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||||
|
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="qty-unit-badge" id="use-quantity-unit" aria-live="polite">—</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,7 +520,7 @@
|
|||||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||||
<section class="page" id="page-product-form">
|
<section class="page" id="page-product-form">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||||
</div>
|
</div>
|
||||||
<form class="form" onsubmit="submitProduct(event)">
|
<form class="form" onsubmit="submitProduct(event)">
|
||||||
@@ -645,7 +708,7 @@
|
|||||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||||
<section class="page" id="page-products">
|
<section class="page" id="page-products">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
@@ -657,11 +720,11 @@
|
|||||||
<!-- ===== RECIPE PAGE ===== -->
|
<!-- ===== RECIPE PAGE ===== -->
|
||||||
<section class="page" id="page-recipe">
|
<section class="page" id="page-recipe">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-page-container">
|
<div class="recipe-page-container">
|
||||||
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
||||||
✨ Genera nuova ricetta
|
✨ Genera nuova ricetta
|
||||||
</button>
|
</button>
|
||||||
<div id="recipe-archive" class="recipe-archive"></div>
|
<div id="recipe-archive" class="recipe-archive"></div>
|
||||||
@@ -671,7 +734,7 @@
|
|||||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||||
<section class="page" id="page-shopping">
|
<section class="page" id="page-shopping">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-container">
|
<div class="shopping-container">
|
||||||
@@ -779,7 +842,7 @@
|
|||||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||||
<section class="page" id="page-ai">
|
<section class="page" id="page-ai">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-container">
|
<div class="ai-container">
|
||||||
@@ -817,24 +880,123 @@
|
|||||||
<!-- ===== SETTINGS PAGE ===== -->
|
<!-- ===== SETTINGS PAGE ===== -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||||
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-language')" data-tab="tab-language" title="Lingua" data-i18n-title="settings.tab_language">🌐</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-ha'); _loadHaTab();" data-tab="tab-ha" title="Home Assistant" data-i18n-title="settings.ha.tab">🏠</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
||||||
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
|
||||||
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info">ℹ️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
|
<!-- Generali Tab -->
|
||||||
|
<div class="settings-panel active" id="tab-general">
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
|
||||||
|
</select>
|
||||||
|
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.info.currency_title">💱 Valuta</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.currency_hint">La valuta usata per tutti i costi e i prezzi nell'app.</p>
|
||||||
|
<div class="form-group" style="margin-top:8px">
|
||||||
|
<select id="setting-price-currency" class="form-input">
|
||||||
|
<option value="EUR">€ Euro (EUR)</option>
|
||||||
|
<option value="USD">$ Dollaro USA (USD)</option>
|
||||||
|
<option value="GBP">£ Sterlina (GBP)</option>
|
||||||
|
<option value="CHF">CHF Franco Svizzero</option>
|
||||||
|
<option value="CAD">CA$ Dollaro Canadese</option>
|
||||||
|
<option value="AUD">A$ Dollaro Australiano</option>
|
||||||
|
<option value="BRL">R$ Real Brasiliano</option>
|
||||||
|
<option value="JPY">¥ Yen Giapponese</option>
|
||||||
|
<option value="SEK">kr Corona Svedese</option>
|
||||||
|
<option value="NOK">kr Corona Norvegese</option>
|
||||||
|
<option value="DKK">kr Corona Danese</option>
|
||||||
|
<option value="PLN">zł Zloty Polacco</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:10px">
|
||||||
|
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="btn.save">Salva</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
|
||||||
|
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
|
||||||
|
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (orario)</option>
|
||||||
|
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.screensaver.card_title">🌙 Salvaschermo</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.screensaver.card_hint">Mostra un orologio con fatti utili dopo un periodo di inattività. Di default è disattivato.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.screensaver.label">Attiva salvaschermo</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-screensaver-enabled">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
|
||||||
|
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
|
||||||
|
<select id="setting-screensaver-timeout" class="form-input" style="margin-top:6px;max-width:200px">
|
||||||
|
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
|
||||||
|
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
|
||||||
|
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
|
||||||
|
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
|
||||||
|
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
|
||||||
|
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
|
||||||
|
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.zerowaste.card_title">♻️ Suggerimenti zero-waste</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.zerowaste.card_hint">Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.zerowaste.label">Mostra suggerimenti durante la cottura</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-zerowaste-tips">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="export.title">📤 Esporta inventario</h4>
|
||||||
|
<p class="settings-hint" data-i18n="export.hint">Scarica l'inventario corrente in CSV o apri una versione stampabile (PDF).</p>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-outline" onclick="exportInventory('csv')" style="flex:1;min-width:120px">
|
||||||
|
📊 <span data-i18n="export.btn_csv">CSV</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" onclick="exportInventory('html')" style="flex:1;min-width:120px">
|
||||||
|
🖨️ <span data-i18n="export.btn_pdf">PDF / Stampa</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- API Keys Tab -->
|
<!-- API Keys Tab -->
|
||||||
<div class="settings-panel active" id="tab-api">
|
<div class="settings-panel" id="tab-api">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4>
|
<h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4>
|
||||||
<p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
|
<p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
|
||||||
@@ -847,9 +1009,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Bring! Tab -->
|
<!-- Bring! Tab -->
|
||||||
<div class="settings-panel" id="tab-bring">
|
<div class="settings-panel" id="tab-bring">
|
||||||
|
<!-- Shopping enable + provider -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
|
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
|
||||||
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
|
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="shopping-mode-group">
|
||||||
|
<label data-i18n="settings.shopping.mode_label">Provider</label>
|
||||||
|
<div class="radio-group" style="margin-top:6px">
|
||||||
|
<label class="radio-option">
|
||||||
|
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
|
||||||
|
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-option" style="margin-left:16px">
|
||||||
|
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
|
||||||
|
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bring! sub-section (shown only when mode = bring) -->
|
||||||
|
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
|
||||||
|
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
||||||
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
||||||
@@ -860,6 +1049,37 @@
|
|||||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Smart suggestions + forecast -->
|
||||||
|
<div class="settings-card" style="margin-top:12px">
|
||||||
|
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-shopping-smart-suggestions">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-shopping-forecast">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:8px">
|
||||||
|
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
|
||||||
|
<div class="qty-control" style="margin-top:6px">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)">−</button>
|
||||||
|
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Price Estimation Settings -->
|
<!-- Price Estimation Settings -->
|
||||||
<div class="settings-card" style="margin-top:12px">
|
<div class="settings-card" style="margin-top:12px">
|
||||||
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
||||||
@@ -893,23 +1113,6 @@
|
|||||||
<option value="Japan">🇯🇵 Giappone</option>
|
<option value="Japan">🇯🇵 Giappone</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label data-i18n="settings.price.currency_label">💱 Valuta</label>
|
|
||||||
<select id="setting-price-currency" class="form-input">
|
|
||||||
<option value="EUR">€ Euro (EUR)</option>
|
|
||||||
<option value="USD">$ Dollaro USA (USD)</option>
|
|
||||||
<option value="GBP">£ Sterlina (GBP)</option>
|
|
||||||
<option value="CHF">CHF Franco Svizzero</option>
|
|
||||||
<option value="CAD">CA$ Dollaro Canadese</option>
|
|
||||||
<option value="AUD">A$ Dollaro Australiano</option>
|
|
||||||
<option value="BRL">R$ Real Brasiliano</option>
|
|
||||||
<option value="JPY">¥ Yen Giapponese</option>
|
|
||||||
<option value="SEK">kr Corona Svedese</option>
|
|
||||||
<option value="NOK">kr Corona Norvegese</option>
|
|
||||||
<option value="DKK">kr Corona Danese</option>
|
|
||||||
<option value="PLN">zł Zloty Polacco</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
|
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
|
||||||
<div class="qty-control">
|
<div class="qty-control">
|
||||||
@@ -1025,6 +1228,9 @@
|
|||||||
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:14px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.camera.ai_manual_hint">Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Security Tab -->
|
<!-- Security Tab -->
|
||||||
@@ -1034,10 +1240,10 @@
|
|||||||
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
|
<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">
|
<div class="form-group">
|
||||||
<label data-i18n="settings.security.token_label">Token di accesso</label>
|
<label data-i18n="settings.security.token_label">Token di accesso</label>
|
||||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
|
<input type="password" id="setting-settings-token" class="form-input" placeholder="API_TOKEN da .env" data-i18n-placeholder="settings.security.token_placeholder">
|
||||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
|
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token API (API_TOKEN nel file .env). Il token viene salvato nel browser.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
||||||
@@ -1163,10 +1369,127 @@
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /tts-server-section -->
|
</div><!-- /tts-server-section -->
|
||||||
|
|
||||||
|
<button class="btn btn-large btn-secondary full-width mt-2" onclick="testSound()" data-i18n="settings.tts.test_sound_btn">🔔 Esegui Test Suono</button>
|
||||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
||||||
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
||||||
|
<!-- HA TTS quick-fill hint -->
|
||||||
|
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
|
||||||
|
<span data-i18n="settings.ha.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Home Assistant Tab -->
|
||||||
|
<div class="settings-panel" id="tab-ha">
|
||||||
|
<!-- Connection card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.title">🏠 Home Assistant</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.hint">Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.ha.enabled">✅ Abilita integrazione Home Assistant</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-ha-enabled" onchange="onHaEnabledChange()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ha-config-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.url_label">🌐 Home Assistant URL</label>
|
||||||
|
<input type="url" id="setting-ha-url" class="form-input" placeholder="http://192.168.1.50:8123">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.url_hint">URL base della tua istanza HA (senza slash finale). Es: <code>http://homeassistant.local:8123</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.token_label">🔑 Long-Lived Access Token</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<input type="password" id="setting-ha-token" class="form-input" style="flex:1" placeholder="eyJhbGci...">
|
||||||
|
<button class="btn btn-secondary" style="flex-shrink:0" onclick="togglePasswordVisibility('setting-ha-token')" data-i18n="btn.toggle_password">👁️</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.token_hint">Genera un token in HA → Profilo → Token di accesso a lungo termine.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary full-width" onclick="testHaConnection()" data-i18n="settings.ha.test_btn">🔗 Testa connessione HA</button>
|
||||||
|
<div id="ha-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TTS via HA card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.tts_title">🔊 TTS su Speaker Smart</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.tts_hint">Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.tts_entity_label">🔈 Entity ID del media player</label>
|
||||||
|
<input type="text" id="setting-ha-tts-entity" class="form-input" placeholder="media_player.living_room">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.tts_entity_hint">Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.tts_platform_label">🎙️ Piattaforma TTS</label>
|
||||||
|
<select id="setting-ha-tts-platform" class="form-input">
|
||||||
|
<option value="tts.speak" data-i18n="settings.ha.tts_platform_speak">tts.speak (raccomandato)</option>
|
||||||
|
<option value="notify" data-i18n="settings.ha.tts_platform_notify">notify.* (servizio notifiche)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary full-width" onclick="applyHaTtsPreset()" data-i18n="settings.ha.tts_apply_btn">✅ Applica preset HA al TTS</button>
|
||||||
|
<p class="settings-hint mt-2" data-i18n="settings.ha.tts_apply_hint">Configura automaticamente il tab TTS con i parametri HA corretti.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.webhook_title">⚡ Automazioni Webhook</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.webhook_hint">EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.webhook_id_label">🔗 Webhook ID</label>
|
||||||
|
<input type="text" id="setting-ha-webhook-id" class="form-input" placeholder="evershelf_events">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.webhook_id_hint">Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. <a href="#" onclick="showHaWebhookHelp();return false" style="color:var(--accent)">Come farlo?</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.webhook_events_label">📋 Eventi da notificare</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||||
|
<input type="checkbox" id="ha-event-expiry" value="expiry"> <span data-i18n="settings.ha.event_expiry">Prodotti in scadenza (cron giornaliero)</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||||
|
<input type="checkbox" id="ha-event-shopping" value="shopping_add"> <span data-i18n="settings.ha.event_shopping">Aggiunta alla lista della spesa</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||||
|
<input type="checkbox" id="ha-event-stock" value="stock_update"> <span data-i18n="settings.ha.event_stock">Aggiornamento scorte (quantità modificata)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.expiry_days_label">📅 Giorni anticipo per scadenze</label>
|
||||||
|
<input type="number" id="setting-ha-expiry-days" class="form-input" min="1" max="30" value="3">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.expiry_days_hint">Quanti giorni prima della scadenza inviare l'alert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notify service card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.notify_title">📱 Notifiche Push</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.notify_hint">EverShelf invia notifiche push tramite il servizio <code>notify.*</code> di HA (Telegram, Pushover, app mobile, ecc.).</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.notify_service_label">📣 Servizio notify</label>
|
||||||
|
<input type="text" id="setting-ha-notify-service" class="form-input" placeholder="notify.mobile_app_mio_telefono">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.notify_service_hint">Formato: <code>notify.NOME_SERVIZIO</code>. Lascia vuoto per disabilitare. Richiede token HA configurato.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sensor card (read-only info) -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.sensor_title">📊 Sensori REST per HA</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.sensor_hint">HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a <code>configuration.yaml</code>:</p>
|
||||||
|
<div id="ha-sensor-yaml" style="background:var(--bg-secondary,#f1f5f9);border-radius:8px;padding:12px;font-family:monospace;font-size:0.75rem;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;border:1px solid var(--border,#e2e8f0)"></div>
|
||||||
|
<button class="btn btn-secondary full-width mt-2" onclick="copyHaSensorYaml()" data-i18n="settings.ha.sensor_copy_btn">📋 Copia YAML</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save button -->
|
||||||
|
<button class="btn btn-large btn-accent full-width" onclick="saveHaSettings()" data-i18n="settings.ha.save_btn">💾 Salva impostazioni HA</button>
|
||||||
|
<div id="ha-save-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
</div>
|
||||||
<!-- Scale Tab -->
|
<!-- Scale Tab -->
|
||||||
<div class="settings-panel" id="tab-scale">
|
<div class="settings-panel" id="tab-scale">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
@@ -1251,40 +1574,123 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Language Tab -->
|
<!-- Language Tab -->
|
||||||
<div class="settings-panel" id="tab-language">
|
|
||||||
|
<!-- Backup Tab -->
|
||||||
|
<div class="settings-panel" id="tab-backup">
|
||||||
|
<!-- Local Backup -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
|
<h4 data-i18n="settings.backup.local_title">💾 Backup Locale</h4>
|
||||||
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
|
<p class="settings-hint" data-i18n="settings.backup.local_hint">Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).</p>
|
||||||
<div class="form-group">
|
<div id="backup-last-info" style="margin-bottom:12px;padding:10px 12px;background:var(--bg-secondary,#f8fafc);border-radius:8px;font-size:0.83rem;color:var(--text-secondary)">
|
||||||
<label data-i18n="settings.language.label">🌐 Lingua</label>
|
<span data-i18n="settings.info.loading">Caricamento…</span>
|
||||||
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
|
</div>
|
||||||
</select>
|
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
|
||||||
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
|
<label data-i18n="settings.backup.retention_days" style="flex-shrink:0">Retention (giorni):</label>
|
||||||
|
<input type="number" id="setting-backup-retention-days" class="form-input" style="width:80px" min="1" max="90" value="3">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button>
|
||||||
|
<div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
<!-- List of backups -->
|
||||||
|
<div id="backup-list-container" style="margin-top:14px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Google Drive -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.screensaver.card_title">🌙 Salvaschermo</h4>
|
<h4 data-i18n="settings.backup.gdrive_title">☁️ Google Drive</h4>
|
||||||
<p class="settings-hint" data-i18n="settings.screensaver.card_hint">Mostra un orologio con fatti utili dopo un periodo di inattività. Di default è disattivato.</p>
|
<p class="settings-hint" data-i18n="settings.backup.gdrive_hint">Carica automaticamente il backup su Google Drive usando un Service Account.</p>
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<span data-i18n="settings.screensaver.label">Attiva salvaschermo</span>
|
<span data-i18n="settings.backup.gdrive_enabled">Abilita backup Google Drive</span>
|
||||||
<span class="toggle-switch">
|
<span class="toggle-switch">
|
||||||
<input type="checkbox" id="setting-screensaver-enabled">
|
<input type="checkbox" id="setting-gdrive-enabled" onchange="saveSettings()">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
|
<div id="gdrive-config-section">
|
||||||
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
|
<!-- Folder ID (shared between both methods) -->
|
||||||
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
|
<div class="form-group">
|
||||||
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
|
<label data-i18n="settings.backup.gdrive_folder_id">ID Cartella Drive</label>
|
||||||
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
|
<input type="text" id="setting-gdrive-folder-id" class="form-input" placeholder="1ABCdef_xyz…">
|
||||||
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
|
<p class="settings-hint" data-i18n="settings.backup.gdrive_folder_id_hint">Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong></p>
|
||||||
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
|
</div>
|
||||||
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
|
<!-- OAuth 2.0 section -->
|
||||||
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
|
<div id="gdrive-oauth-section">
|
||||||
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
|
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
|
||||||
</select>
|
<summary style="cursor:pointer;font-weight:600;font-size:0.83rem" data-i18n="settings.backup.gdrive_oauth_how_to">📋 Come configurare OAuth 2.0 (passo dopo passo)</summary>
|
||||||
|
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8" data-i18n-html="settings.backup.gdrive_oauth_steps"></ol>
|
||||||
|
</details>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.backup.gdrive_client_id">Client ID</label>
|
||||||
|
<input type="text" id="setting-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.backup.gdrive_client_secret">Client Secret</label>
|
||||||
|
<input type="password" id="setting-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px;font-size:0.82rem">
|
||||||
|
<span data-i18n="settings.backup.gdrive_redirect_uri_label">Redirect URI (aggiungi in Google Cloud Console):</span>
|
||||||
|
<code id="gdrive-redirect-uri-display" style="display:block;margin-top:4px;word-break:break-all;color:var(--text-primary);font-size:0.78rem">http://localhost</code>
|
||||||
|
<p class="settings-hint" style="margin-top:6px;margin-bottom:0" data-i18n="settings.backup.gdrive_redirect_uri_hint">Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa <strong>http://localhost</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button class="btn btn-secondary" onclick="_gdriveAuthorize()" id="btn-gdrive-authorize" data-i18n="settings.backup.gdrive_oauth_authorize">🔑 Autorizza con Google</button>
|
||||||
|
<span id="gdrive-oauth-token-status" style="font-size:0.83rem"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Manual code entry (appears after clicking Authorize) -->
|
||||||
|
<div id="gdrive-code-section" style="display:none;margin-top:12px;padding:12px 14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;border:1px solid var(--border)">
|
||||||
|
<p style="font-size:0.82rem;margin-bottom:8px;font-weight:600" data-i18n="settings.backup.gdrive_code_title">Incolla l'URL o il codice di autorizzazione</p>
|
||||||
|
<p class="settings-hint" style="margin-bottom:8px" data-i18n="settings.backup.gdrive_code_hint">Dopo aver autorizzato su Google, il browser proverà ad aprire <code>http://localhost</code> e mostrerà un errore. Copia l'intero URL dalla barra degli indirizzi e incollalo qui sotto.</p>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<input type="text" id="gdrive-code-input" class="form-input" style="flex:1;min-width:0" placeholder="http://localhost/?code=4%2F0A… oppure solo il codice">
|
||||||
|
<button class="btn btn-primary" onclick="_gdriveSubmitCode()" id="btn-gdrive-submit-code" data-i18n="settings.backup.gdrive_code_submit">Conferma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Retention + action buttons (shared) -->
|
||||||
|
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
|
||||||
|
<label data-i18n="settings.backup.gdrive_retention_days" style="flex-shrink:0">Retention Drive (giorni, 0=tutto):</label>
|
||||||
|
<input type="number" id="setting-gdrive-retention-days" class="form-input" style="width:80px" min="0" max="365" value="30">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||||
|
<button class="btn btn-secondary" onclick="_gdriveTest()" id="btn-gdrive-test" data-i18n="settings.backup.gdrive_test">🔗 Testa Connessione</button>
|
||||||
|
<button class="btn btn-accent" onclick="_gdrivePushNow()" id="btn-gdrive-push" data-i18n="settings.backup.gdrive_push_now">☁️ Carica Ora su Drive</button>
|
||||||
|
</div>
|
||||||
|
<div id="gdrive-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Tab -->
|
||||||
|
<div class="settings-panel" id="tab-info">
|
||||||
|
<!-- Gemini AI Usage card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.info.ai_title">Gemini AI — Token Usage</h4>
|
||||||
|
<p class="settings-hint info-ai-subtitle" data-i18n="settings.info.ai_overview">Utilizzo AI, inventario e sistema</p>
|
||||||
|
<div id="info-ai-content" style="margin-top:10px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Inventory card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.info.inv_title">Inventario</h4>
|
||||||
|
<div id="info-inv-content" style="margin-top:10px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Activity card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.info.act_title">Attività del mese</h4>
|
||||||
|
<div id="info-act-content" style="margin-top:10px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- System Info card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.info.system_title">Sistema</h4>
|
||||||
|
<div id="info-system-content" style="margin-top:10px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1466,9 +1872,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="recipe-result" style="display:none" class="recipe-result">
|
<div id="recipe-result" style="display:none" class="recipe-result">
|
||||||
<div id="recipe-content"></div>
|
<div id="recipe-content"></div>
|
||||||
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
|
<button id="recipe-regen-btn" class="btn btn-large btn-secondary full-width mt-2" onclick="showRegenChoice()" data-i18n="recipes.regenerate">
|
||||||
🔄 Generane un'altra
|
🔄 Generane un'altra
|
||||||
</button>
|
</button>
|
||||||
|
<div id="recipe-regen-choice" style="display:none" class="recipe-regen-choice">
|
||||||
|
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p>
|
||||||
|
<button class="btn btn-large btn-warning full-width" onclick="doRegenerateReplace()" data-i18n="recipes.regen_replace">🔄 Genera un'altra (scarta questa)</button>
|
||||||
|
<button class="btn btn-large btn-success full-width mt-2" onclick="doRegenerateSave()" data-i18n="recipes.regen_save_new">💾 Salva nell'archivio e genera nuova</button>
|
||||||
|
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="confirm.cancel">Annulla</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
|
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
|
||||||
✅ Chiudi
|
✅ Chiudi
|
||||||
</button>
|
</button>
|
||||||
@@ -1525,6 +1937,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== NETWORK ERROR OVERLAY ===== -->
|
||||||
|
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
|
||||||
|
<div class="net-error-body">
|
||||||
|
<div class="net-error-icon" id="net-error-icon">📡</div>
|
||||||
|
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
|
||||||
|
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
|
||||||
|
<div class="net-error-status" id="net-error-status"></div>
|
||||||
|
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== COOKING MODE OVERLAY ===== -->
|
<!-- ===== COOKING MODE OVERLAY ===== -->
|
||||||
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
||||||
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
||||||
@@ -1553,6 +1976,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
|
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
|
||||||
|
<div id="cooking-zerowaste-tip" class="cooking-zerowaste-tip" style="display:none">
|
||||||
|
<span class="cooking-zerowaste-label" data-i18n="cooking.zerowaste_label">♻️ Scarto</span>
|
||||||
|
<span id="cooking-zerowaste-scrap" class="cooking-zerowaste-scrap"></span>
|
||||||
|
<p id="cooking-zerowaste-text" class="cooking-zerowaste-text"></p>
|
||||||
|
<button class="cooking-zerowaste-close" onclick="_dismissZeroWasteTip()" aria-label="Chiudi">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cooking-nav">
|
<div class="cooking-nav">
|
||||||
<button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button>
|
<button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button>
|
||||||
@@ -1560,6 +1989,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/app.js?v=20260516b"></script>
|
<script src="assets/js/app.js?v=20260606z"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# logs/
|
||||||
|
|
||||||
|
This directory contains EverShelf runtime log files.
|
||||||
|
|
||||||
|
Files are generated automatically by `api/logger.php` and follow the naming pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
evershelf_YYYY-MM-DD_HH.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The directory is tracked in git (via this README) but `.log` files are ignored via `.gitignore`.
|
||||||
|
|
||||||
|
## Configuration (`.env`)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `LOG_LEVEL` | `INFO` | Minimum log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
||||||
|
| `LOG_ROTATE_HOURS` | `24` | Hours per file before rotating |
|
||||||
|
| `LOG_MAX_FILES` | `14` | Maximum number of rotated files to keep |
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {"ctx":"value"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote inspection
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/?action=get_logs&lines=100&level=WARN
|
||||||
|
```
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "EverShelf",
|
"name": "EverShelf",
|
||||||
"short_name": "EverShelf",
|
"short_name": "EverShelf",
|
||||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
"version": "1.7.15",
|
"version": "1.7.41",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "evershelf",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build:js": "npx --yes terser assets/js/app.js -c -m -o assets/js/app.min.js",
|
||||||
|
"build:css": "npx --yes clean-css-cli -o assets/css/style.min.css assets/css/style.css",
|
||||||
|
"build": "npm run build:js && npm run build:css"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "1.7.19",
|
||||||
|
"version_code": 20
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Audit: products depleted in last N days vs shopping list / Bring / smart shopping.
|
||||||
|
* Usage: php scripts/audit-finished-shopping.php [days]
|
||||||
|
*/
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$days = max(1, (int)($argv[1] ?? 30));
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
// Recompute smart shopping fresh
|
||||||
|
ob_start();
|
||||||
|
smartShopping($db);
|
||||||
|
$smartJson = ob_get_clean();
|
||||||
|
$smartData = json_decode($smartJson, true);
|
||||||
|
$smartItems = $smartData['items'] ?? [];
|
||||||
|
$smartByPid = [];
|
||||||
|
$smartByName = [];
|
||||||
|
foreach ($smartItems as $si) {
|
||||||
|
foreach ($si['variants'] ?? [] as $v) {
|
||||||
|
$smartByPid[(int)$v['product_id']] = $si;
|
||||||
|
}
|
||||||
|
$smartByPid[(int)$si['product_id']] = $si;
|
||||||
|
$sn = strtolower(trim($si['shopping_name'] ?? $si['name'] ?? ''));
|
||||||
|
if ($sn !== '') $smartByName[$sn] = $si;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bring list
|
||||||
|
$bringNames = [];
|
||||||
|
$bringSpecs = [];
|
||||||
|
$auth = bringAuth();
|
||||||
|
if ($auth && !empty($auth['bringListUUID'])) {
|
||||||
|
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
|
||||||
|
if ($listData && isset($listData['purchase'])) {
|
||||||
|
foreach ($listData['purchase'] as $bi) {
|
||||||
|
$k = mb_strtolower($bi['name'] ?? '');
|
||||||
|
$bringNames[$k] = $bi['name'] ?? '';
|
||||||
|
$bringSpecs[$k] = $bi['specification'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal shopping list
|
||||||
|
$shopNames = [];
|
||||||
|
$shopRows = $db->query("SELECT name, specification FROM shopping_list")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($shopRows as $r) {
|
||||||
|
$shopNames[mb_strtolower($r['name'])] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products with zero stock, last activity in window
|
||||||
|
$rows = $db->query("
|
||||||
|
SELECT p.id, p.name, p.brand, p.shopping_name, p.unit,
|
||||||
|
COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) AS stock_qty,
|
||||||
|
(SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0
|
||||||
|
AND t.type IN ('out','waste','in')
|
||||||
|
AND t.created_at >= datetime('now', '-{$days} days')) AS last_activity,
|
||||||
|
(SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0
|
||||||
|
AND t.type IN ('out','waste')
|
||||||
|
AND t.created_at >= datetime('now', '-{$days} days')) AS last_out,
|
||||||
|
(SELECT COUNT(*) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')) AS use_count,
|
||||||
|
(SELECT COUNT(*) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type = 'in') AS buy_count
|
||||||
|
FROM products p
|
||||||
|
WHERE COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001
|
||||||
|
AND (SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0
|
||||||
|
AND t.type IN ('out','waste','in')
|
||||||
|
AND t.created_at >= datetime('now', '-{$days} days')) IS NOT NULL
|
||||||
|
ORDER BY last_activity DESC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
$onList = [];
|
||||||
|
$suppressed = [];
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$pid = (int)$r['id'];
|
||||||
|
$generic = trim($r['shopping_name'] ?? '') ?: computeShoppingName($r['name'], '', $r['brand'] ?? '');
|
||||||
|
$bringKey = mb_strtolower(italianToBring($generic));
|
||||||
|
$shopKey = mb_strtolower($generic);
|
||||||
|
|
||||||
|
$smart = $smartByPid[$pid] ?? $smartByName[mb_strtolower($generic)] ?? null;
|
||||||
|
$onBring = isset($bringNames[$bringKey]);
|
||||||
|
$onShop = isset($shopNames[$shopKey]);
|
||||||
|
$inSmart = $smart !== null && ($smart['urgency'] ?? 'none') !== 'none';
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'id' => $pid,
|
||||||
|
'name' => $r['name'],
|
||||||
|
'brand' => $r['brand'],
|
||||||
|
'generic' => $generic,
|
||||||
|
'last_activity' => $r['last_activity'],
|
||||||
|
'last_out' => $r['last_out'],
|
||||||
|
'use_count' => (int)$r['use_count'],
|
||||||
|
'buy_count' => (int)$r['buy_count'],
|
||||||
|
'on_bring' => $onBring,
|
||||||
|
'on_shop' => $onShop,
|
||||||
|
'in_smart' => $inSmart,
|
||||||
|
'smart_urgency' => $smart['urgency'] ?? null,
|
||||||
|
'smart_reasons' => $smart['reasons'] ?? [],
|
||||||
|
'bring_spec' => $bringSpecs[$bringKey] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$onBring && !$onShop && !$inSmart) {
|
||||||
|
$missing[] = $entry;
|
||||||
|
} elseif ($onBring || $onShop) {
|
||||||
|
$onList[] = $entry;
|
||||||
|
} elseif ($inSmart) {
|
||||||
|
$suppressed[] = $entry; // in smart but not synced yet
|
||||||
|
} else {
|
||||||
|
$missing[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Audit prodotti esauriti (ultimi {$days} giorni) ===\n";
|
||||||
|
echo 'Totale esauriti con attività recente: ' . count($rows) . "\n";
|
||||||
|
echo 'Già in lista/Bring: ' . count($onList) . "\n";
|
||||||
|
echo 'In smart shopping ma non in lista: ' . count($suppressed) . "\n";
|
||||||
|
echo 'MANCANTI (né lista né Bring né smart): ' . count($missing) . "\n\n";
|
||||||
|
|
||||||
|
if ($missing) {
|
||||||
|
echo "--- MANCANTI ---\n";
|
||||||
|
foreach ($missing as $m) {
|
||||||
|
echo sprintf(
|
||||||
|
"- [%d] %s%s → generico: %s | usi:%d acquisti:%d | ultimo:%s\n",
|
||||||
|
$m['id'],
|
||||||
|
$m['name'],
|
||||||
|
$m['brand'] ? " ({$m['brand']})" : '',
|
||||||
|
$m['generic'],
|
||||||
|
$m['use_count'],
|
||||||
|
$m['buy_count'],
|
||||||
|
$m['last_activity']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($suppressed) {
|
||||||
|
echo "--- IN SMART MA NON IN LISTA/BRING ---\n";
|
||||||
|
foreach ($suppressed as $m) {
|
||||||
|
echo sprintf(
|
||||||
|
"- [%d] %s → %s | urgenza:%s | %s\n",
|
||||||
|
$m['id'],
|
||||||
|
$m['name'],
|
||||||
|
$m['generic'],
|
||||||
|
$m['smart_urgency'] ?? '?',
|
||||||
|
implode(', ', $m['smart_reasons'] ?? [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export JSON for fix script
|
||||||
|
file_put_contents(
|
||||||
|
__DIR__ . '/../data/audit_finished_missing.json',
|
||||||
|
json_encode(['days' => $days, 'missing' => $missing, 'suppressed' => $suppressed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||||
|
);
|
||||||
|
echo "\nReport salvato in data/audit_finished_missing.json\n";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Backfill Bring!/shopping list for products depleted in the last N days.
|
||||||
|
* Usage: php scripts/backfill-finished-shopping.php [days]
|
||||||
|
*/
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$days = max(1, (int)($argv[1] ?? RECENTLY_EXHAUSTED_DAYS));
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$rows = $db->query("
|
||||||
|
SELECT p.id, p.name, p.shopping_name
|
||||||
|
FROM products p
|
||||||
|
WHERE COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001
|
||||||
|
AND (
|
||||||
|
SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')
|
||||||
|
) >= datetime('now', '-{$days} days')
|
||||||
|
ORDER BY (
|
||||||
|
SELECT MAX(t.created_at) FROM transactions t
|
||||||
|
WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste')
|
||||||
|
) DESC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] Backfill {$days}d — " . count($rows) . " prodotti esauriti\n";
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$res = bringAddDepletedProduct($db, (int)$r['id']);
|
||||||
|
if (!empty($res['added'])) {
|
||||||
|
$added++;
|
||||||
|
echo " + {$r['name']} → {$res['generic_name']}\n";
|
||||||
|
} elseif (!empty($res['updated'])) {
|
||||||
|
$updated++;
|
||||||
|
echo " ~ {$r['name']} → {$res['generic_name']}\n";
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
smartShopping($db);
|
||||||
|
$json = ob_get_clean();
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
if ($decoded && !empty($decoded['success'])) {
|
||||||
|
$decoded['cached_at'] = date('c');
|
||||||
|
$decoded['cached_ts'] = time();
|
||||||
|
file_put_contents(
|
||||||
|
__DIR__ . '/../data/smart_shopping_cache.json',
|
||||||
|
json_encode($decoded, JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
bringSyncFull($db, false);
|
||||||
|
$sync = json_decode(ob_get_clean(), true);
|
||||||
|
$auto = $sync['auto_add'] ?? [];
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] bringAddDepleted: added={$added} updated={$updated} skipped={$skipped}\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] bringSync auto_add: ' . json_encode($auto, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/** Delete all comments on open feature/enhancement backlog issues (English-only tracker policy). */
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/github.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/constants.php';
|
||||||
|
|
||||||
|
$token = _ghToken();
|
||||||
|
if ($token === '') {
|
||||||
|
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghRequest(string $token, string $method, string $url, ?array $body = null): array {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = [
|
||||||
|
'Authorization: token ' . $token,
|
||||||
|
'Accept: application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version: 2022-11-28',
|
||||||
|
'User-Agent: EverShelf-Triage/1.0',
|
||||||
|
];
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||||
|
} elseif ($method === 'GET') {
|
||||||
|
// default
|
||||||
|
}
|
||||||
|
if ($body !== null) {
|
||||||
|
$headers[] = 'Content-Type: application/json';
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||||
|
}
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['code' => $code, 'body' => $raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues = [122, 121, 120, 119, 118, 117, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
|
||||||
|
$deleted = 0;
|
||||||
|
|
||||||
|
foreach ($issues as $num) {
|
||||||
|
$page = 1;
|
||||||
|
while (true) {
|
||||||
|
$url = 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments?per_page=100&page=$page";
|
||||||
|
$r = ghRequest($token, 'GET', $url);
|
||||||
|
if ($r['code'] !== 200) {
|
||||||
|
fwrite(STDERR, "#$num list comments HTTP {$r['code']}\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$comments = json_decode($r['body'], true);
|
||||||
|
if (!is_array($comments) || empty($comments)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($comments as $c) {
|
||||||
|
$id = (int)($c['id'] ?? 0);
|
||||||
|
if ($id <= 0) continue;
|
||||||
|
$dr = ghRequest($token, 'DELETE', 'https://api.github.com/repos/' . GH_REPO . "/issues/comments/$id");
|
||||||
|
if ($dr['code'] === 204) {
|
||||||
|
$deleted++;
|
||||||
|
echo "deleted comment $id on #$num\n";
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "FAIL delete comment $id on #$num HTTP {$dr['code']}\n");
|
||||||
|
}
|
||||||
|
usleep(200000);
|
||||||
|
}
|
||||||
|
if (count($comments) < 100) break;
|
||||||
|
$page++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done. Deleted $deleted comments.\n";
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Encrypt a GitHub Issues token for storage in .env as GH_ISSUE_TOKEN_ENC.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php scripts/encrypt-gh-token.php 'ghp_xxxx' 'your-secret-key'
|
||||||
|
*/
|
||||||
|
if ($argc < 3) {
|
||||||
|
fwrite(STDERR, "Usage: php scripts/encrypt-gh-token.php <token> <key>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/../api/lib/github.php';
|
||||||
|
echo evershelfEncryptGhToken($argv[1], $argv[2]) . "\n";
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fix ownership and permissions for EverShelf runtime directories.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
WEB_USER="${WEB_USER:-www-data}"
|
||||||
|
|
||||||
|
chown -R "${WEB_USER}:${WEB_USER}" "${ROOT}/data" "${ROOT}/logs" 2>/dev/null || true
|
||||||
|
chmod 750 "${ROOT}/data" "${ROOT}/logs"
|
||||||
|
chmod 640 "${ROOT}/.env" 2>/dev/null || true
|
||||||
|
find "${ROOT}/data" -type f -exec chmod 660 {} \;
|
||||||
|
find "${ROOT}/logs" -type f -exec chmod 640 {} \;
|
||||||
|
echo "Permissions updated for ${WEB_USER}"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Download @xenova/transformers runtime + all-MiniLM-L6-v2 for offline category classification.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
VENDOR="$ROOT/assets/vendor/transformers"
|
||||||
|
MODEL="$VENDOR/Xenova/all-MiniLM-L6-v2"
|
||||||
|
ONNX="$MODEL/onnx"
|
||||||
|
BASE="https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main"
|
||||||
|
|
||||||
|
mkdir -p "$ONNX"
|
||||||
|
|
||||||
|
echo "→ transformers.min.js"
|
||||||
|
curl -fsSL "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js" \
|
||||||
|
-o "$VENDOR/transformers.min.js"
|
||||||
|
|
||||||
|
for f in config.json tokenizer.json tokenizer_config.json; do
|
||||||
|
echo "→ $f"
|
||||||
|
curl -fsSL "$BASE/$f" -o "$MODEL/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "→ onnx/model_quantized.onnx (~22 MB)"
|
||||||
|
curl -fsSL "$BASE/onnx/model_quantized.onnx" -o "$ONNX/model_quantized.onnx"
|
||||||
|
|
||||||
|
chown -R www-data:www-data "$VENDOR" 2>/dev/null || true
|
||||||
|
echo "Done. Model installed under assets/vendor/transformers/"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* One-time merge of duplicate product records (same normalized name + compatible brand).
|
||||||
|
* Opened-package splits remain as separate inventory rows on the canonical product.
|
||||||
|
*
|
||||||
|
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
$dbPath = __DIR__ . '/../data/evershelf.db';
|
||||||
|
if (!file_exists($dbPath)) {
|
||||||
|
fwrite(STDERR, "Database not found: $dbPath\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = new PDO('sqlite:' . $dbPath);
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
function normName(string $name): string {
|
||||||
|
return mb_strtolower(trim($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normBrand(string $brand): string {
|
||||||
|
return mb_strtolower(trim($brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandsCompatible(string $a, string $b): bool {
|
||||||
|
$na = normBrand($a);
|
||||||
|
$nb = normBrand($b);
|
||||||
|
return $na === $nb || $na === '' || $nb === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function productScore(PDO $db, int $id): float {
|
||||||
|
$tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn();
|
||||||
|
$inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn();
|
||||||
|
return $tx * 10 + $inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
$db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
|
||||||
|
$db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]);
|
||||||
|
$db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]);
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction()) {
|
||||||
|
$db->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$byName = [];
|
||||||
|
foreach ($products as $p) {
|
||||||
|
$key = normName($p['name']);
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byName[$key][] = $p;
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged = 0;
|
||||||
|
foreach ($byName as $nameKey => $group) {
|
||||||
|
if (count($group) < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into compatible-brand clusters
|
||||||
|
$clusters = [];
|
||||||
|
foreach ($group as $p) {
|
||||||
|
$placed = false;
|
||||||
|
foreach ($clusters as &$cluster) {
|
||||||
|
$ref = $cluster[0];
|
||||||
|
if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) {
|
||||||
|
$cluster[] = $p;
|
||||||
|
$placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($cluster);
|
||||||
|
if (!$placed) {
|
||||||
|
$clusters[] = [$p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($clusters as $cluster) {
|
||||||
|
if (count($cluster) < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id']));
|
||||||
|
$keep = (int)$cluster[0]['id'];
|
||||||
|
$keepName = $cluster[0]['name'];
|
||||||
|
for ($i = 1; $i < count($cluster); $i++) {
|
||||||
|
$drop = (int)$cluster[$i]['id'];
|
||||||
|
echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n";
|
||||||
|
if (!$dryRun) {
|
||||||
|
mergeProducts($db, $keep, $drop);
|
||||||
|
}
|
||||||
|
$merged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $dryRun
|
||||||
|
? "Dry run: $merged merge(s) would be performed.\n"
|
||||||
|
: "Done: $merged duplicate product(s) merged.\n";
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* One-time security migration: GitHub token → encrypted .env, optional API_TOKEN.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../api/lib/env.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/github.php';
|
||||||
|
|
||||||
|
$envFile = dirname(__DIR__) . '/.env';
|
||||||
|
if (!file_exists($envFile)) {
|
||||||
|
fwrite(STDERR, ".env not found\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
||||||
|
$vars = loadEnv();
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
// Migrate legacy XOR token from previous index.php if still in git history
|
||||||
|
if (empty($vars['GH_ISSUE_TOKEN']) && empty($vars['GH_ISSUE_TOKEN_ENC'])) {
|
||||||
|
$legacyEnc = '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004';
|
||||||
|
$legacyKey = 'D1sp3ns4!Ev3r#26';
|
||||||
|
$encBin = hex2bin($legacyEnc);
|
||||||
|
$plain = '';
|
||||||
|
if ($encBin) {
|
||||||
|
for ($i = 0; $i < strlen($encBin); $i++) {
|
||||||
|
$plain .= chr(ord($encBin[$i]) ^ ord($legacyKey[$i % strlen($legacyKey)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($plain !== '' && str_starts_with($plain, 'github_')) {
|
||||||
|
$newKey = bin2hex(random_bytes(16));
|
||||||
|
$enc = evershelfEncryptGhToken($plain, $newKey);
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = '# GitHub Issues (migrated from legacy source — encrypted at rest)';
|
||||||
|
$lines[] = 'GH_ISSUE_TOKEN_ENC=' . $enc;
|
||||||
|
$lines[] = 'GH_ISSUE_TOKEN_KEY=' . $newKey;
|
||||||
|
$changed = true;
|
||||||
|
echo "Migrated GitHub token to GH_ISSUE_TOKEN_ENC\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($vars['API_TOKEN']) && empty($vars['SETTINGS_TOKEN'])) {
|
||||||
|
$token = bin2hex(random_bytes(24));
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = '# API access token — required for all API calls when set (also used by kiosk/HA)';
|
||||||
|
$lines[] = 'API_TOKEN=' . $token;
|
||||||
|
$changed = true;
|
||||||
|
echo "Generated API_TOKEN (save this for your devices): {$token}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
file_put_contents($envFile, implode("\n", $lines) . "\n");
|
||||||
|
chmod($envFile, 0640);
|
||||||
|
echo "Updated .env\n";
|
||||||
|
} else {
|
||||||
|
echo "No migration needed\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Re-apply stock hints and 5% use-all rule to an archived recipe.
|
||||||
|
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
|
||||||
|
*/
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$id = (int)($argv[1] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php <recipe_id>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
fwrite(STDERR, "Recipe {$id} not found\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipe = json_decode($row['recipe_json'], true);
|
||||||
|
if (!is_array($recipe)) {
|
||||||
|
fwrite(STDERR, "Invalid recipe JSON for id {$id}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
|
||||||
|
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
|
||||||
|
FROM inventory i
|
||||||
|
JOIN products p ON p.id = i.product_id
|
||||||
|
WHERE i.quantity > 0
|
||||||
|
ORDER BY days_left ASC, p.name ASC
|
||||||
|
");
|
||||||
|
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
|
||||||
|
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||||
|
|
||||||
|
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
|
||||||
|
$upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
|
||||||
|
|
||||||
|
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
|
||||||
|
foreach ($recipe['ingredients'] ?? [] as $ing) {
|
||||||
|
if (empty($ing['from_pantry'])) {
|
||||||
|
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
|
||||||
|
echo sprintf(
|
||||||
|
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
|
||||||
|
$ing['name'] ?? '?',
|
||||||
|
$ing['qty'] ?? '?',
|
||||||
|
$ing['stock_have'] ?? 0,
|
||||||
|
$ing['stock_unit'] ?? '',
|
||||||
|
$ing['stock_remain'] ?? 0,
|
||||||
|
$ing['stock_unit'] ?? '',
|
||||||
|
$useAll
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sync translation files: ensure all locales have the same keys as it.json (reference)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent / 'translations'
|
||||||
|
REF = 'it.json'
|
||||||
|
LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json']
|
||||||
|
|
||||||
|
# New keys added across all locales (nested path -> value per locale)
|
||||||
|
NEW_KEYS: dict[str, dict[str, str]] = {
|
||||||
|
'dashboard.banner_prediction_confirmed': {
|
||||||
|
'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni',
|
||||||
|
'en': '✅ Confirmed — forecasts will recalculate from your next entries',
|
||||||
|
'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet',
|
||||||
|
'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements',
|
||||||
|
'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros',
|
||||||
|
},
|
||||||
|
'dashboard.banner_anomaly_explain_fail': {
|
||||||
|
'it': 'Impossibile ottenere spiegazione AI',
|
||||||
|
'en': 'Could not get AI explanation',
|
||||||
|
'de': 'KI-Erklärung konnte nicht abgerufen werden',
|
||||||
|
'fr': 'Impossible d\'obtenir l\'explication IA',
|
||||||
|
'es': 'No se pudo obtener la explicación de IA',
|
||||||
|
},
|
||||||
|
'dashboard.banner_anomaly_dismissed': {
|
||||||
|
'it': 'Anomalia ignorata',
|
||||||
|
'en': 'Anomaly dismissed',
|
||||||
|
'de': 'Anomalie ignoriert',
|
||||||
|
'fr': 'Anomalie ignorée',
|
||||||
|
'es': 'Anomalía descartada',
|
||||||
|
},
|
||||||
|
'error.copy_failed': {
|
||||||
|
'it': 'Copia negli appunti non riuscita',
|
||||||
|
'en': 'Copy to clipboard failed',
|
||||||
|
'de': 'Kopieren in die Zwischenablage fehlgeschlagen',
|
||||||
|
'fr': 'Échec de la copie dans le presse-papiers',
|
||||||
|
'es': 'Error al copiar al portapapeles',
|
||||||
|
},
|
||||||
|
'error.invalid_quantity': {
|
||||||
|
'it': 'Quantità non valida',
|
||||||
|
'en': 'Invalid quantity',
|
||||||
|
'de': 'Ungültige Menge',
|
||||||
|
'fr': 'Quantité invalide',
|
||||||
|
'es': 'Cantidad no válida',
|
||||||
|
},
|
||||||
|
'dashboard.banner_finished_restore_prompt': {
|
||||||
|
'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})',
|
||||||
|
'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})',
|
||||||
|
'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})',
|
||||||
|
'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})',
|
||||||
|
'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})',
|
||||||
|
},
|
||||||
|
'time.just_now': {
|
||||||
|
'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora',
|
||||||
|
},
|
||||||
|
'time.seconds_ago': {
|
||||||
|
'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s',
|
||||||
|
},
|
||||||
|
'time.minutes_ago': {
|
||||||
|
'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min',
|
||||||
|
},
|
||||||
|
'time.hours_ago': {
|
||||||
|
'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h',
|
||||||
|
},
|
||||||
|
'time.days_ago': {
|
||||||
|
'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d',
|
||||||
|
},
|
||||||
|
'use.locations_short': {
|
||||||
|
'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones',
|
||||||
|
},
|
||||||
|
'move.moved_simple': {
|
||||||
|
'it': '📦 Spostato in {location}',
|
||||||
|
'en': '📦 Moved to {location}',
|
||||||
|
'de': '📦 Nach {location} verschoben',
|
||||||
|
'fr': '📦 Déplacé vers {location}',
|
||||||
|
'es': '📦 Movido a {location}',
|
||||||
|
},
|
||||||
|
'product.history_badge': {
|
||||||
|
'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial',
|
||||||
|
},
|
||||||
|
'ai.conservation_hint': {
|
||||||
|
'it': '🤖 AI: conserva in {location}',
|
||||||
|
'en': '🤖 AI: store in {location}',
|
||||||
|
'de': '🤖 KI: lagere in {location}',
|
||||||
|
'fr': '🤖 IA : conserve dans {location}',
|
||||||
|
'es': '🤖 IA: conserva en {location}',
|
||||||
|
},
|
||||||
|
'settings.kiosk_update_required': {
|
||||||
|
'it': '⚠️ Aggiorna il kiosk per usare questa funzione',
|
||||||
|
'en': '⚠️ Update the kiosk app to use this feature',
|
||||||
|
'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen',
|
||||||
|
'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction',
|
||||||
|
'es': '⚠️ Actualiza la app kiosk para usar esta función',
|
||||||
|
},
|
||||||
|
'shopping.bring_names_migrated': {
|
||||||
|
'it': '🔄 {n} nomi generalizzati in Bring!',
|
||||||
|
'en': '🔄 {n} names generalized in Bring!',
|
||||||
|
'de': '🔄 {n} Namen in Bring! verallgemeinert',
|
||||||
|
'fr': '🔄 {n} noms généralisés dans Bring !',
|
||||||
|
'es': '🔄 {n} nombres generalizados en Bring!',
|
||||||
|
},
|
||||||
|
'scan.mode_shopping_activated': {
|
||||||
|
'it': '🛒 Modalità Spesa attivata!',
|
||||||
|
'en': '🛒 Shopping mode activated!',
|
||||||
|
'de': '🛒 Einkaufsmodus aktiviert!',
|
||||||
|
'fr': '🛒 Mode courses activé !',
|
||||||
|
'es': '🛒 ¡Modo compras activado!',
|
||||||
|
},
|
||||||
|
'settings.scale.discover_scanning': {
|
||||||
|
'it': '🔍 Scansione rete locale per gateway bilancia…',
|
||||||
|
'en': '🔍 Scanning local network for scale gateway…',
|
||||||
|
'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…',
|
||||||
|
'fr': '🔍 Recherche du gateway balance sur le réseau local…',
|
||||||
|
'es': '🔍 Buscando pasarela de báscula en la red local…',
|
||||||
|
},
|
||||||
|
'settings.scale.discover_found': {
|
||||||
|
'it': '✅ Gateway trovato: {url}{more}',
|
||||||
|
'en': '✅ Gateway found: {url}{more}',
|
||||||
|
'de': '✅ Gateway gefunden: {url}{more}',
|
||||||
|
'fr': '✅ Gateway trouvé : {url}{more}',
|
||||||
|
'es': '✅ Pasarela encontrada: {url}{more}',
|
||||||
|
},
|
||||||
|
'settings.scale.discover_not_found': {
|
||||||
|
'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.',
|
||||||
|
'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.',
|
||||||
|
'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.',
|
||||||
|
'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.',
|
||||||
|
'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.',
|
||||||
|
},
|
||||||
|
'settings.scale.discover_failed': {
|
||||||
|
'it': '❌ Ricerca fallita: {error}',
|
||||||
|
'en': '❌ Discovery failed: {error}',
|
||||||
|
'de': '❌ Suche fehlgeschlagen: {error}',
|
||||||
|
'fr': '❌ Échec de la recherche : {error}',
|
||||||
|
'es': '❌ Búsqueda fallida: {error}',
|
||||||
|
},
|
||||||
|
'settings.scale.discover_auto': {
|
||||||
|
'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto',
|
||||||
|
},
|
||||||
|
'settings.scale.unknown_device': {
|
||||||
|
'it': 'Dispositivo sconosciuto',
|
||||||
|
'en': 'Unknown device',
|
||||||
|
'de': 'Unbekanntes Gerät',
|
||||||
|
'fr': 'Appareil inconnu',
|
||||||
|
'es': 'Dispositivo desconocido',
|
||||||
|
},
|
||||||
|
'product.from_history': {
|
||||||
|
'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)',
|
||||||
|
},
|
||||||
|
'recipes.ing_stock_line': {
|
||||||
|
'it': 'Hai {have} · restano {remain} dopo l\'uso',
|
||||||
|
'en': 'You have {have} · {remain} left after use',
|
||||||
|
'de': 'Du hast {have} · {remain} bleiben nach Gebrauch',
|
||||||
|
'fr': 'Vous avez {have} · il reste {remain} après usage',
|
||||||
|
'es': 'Tienes {have} · quedan {remain} después del uso',
|
||||||
|
},
|
||||||
|
'recipes.ing_use_all_note': {
|
||||||
|
'it': 'uso totale (<5% della confezione intera)',
|
||||||
|
'en': 'use all (<5% of full package left)',
|
||||||
|
'de': 'alles verwenden (<5% der Vollpackung)',
|
||||||
|
'fr': 'tout utiliser (<5% du conditionnement entier)',
|
||||||
|
'es': 'usar todo (<5% del envase completo)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# fr/es gaps filled with proper translations (flat key -> value)
|
||||||
|
FR_FILL: dict[str, str] = {
|
||||||
|
'action.related_stock_title': 'Aussi à la maison',
|
||||||
|
'dashboard.banner_expired_action_modify': 'Modifier',
|
||||||
|
'dashboard.banner_expired_action_vacuum': 'Mettre sous vide',
|
||||||
|
'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.',
|
||||||
|
'scan.stock_in_pantry': 'Déjà à la maison :',
|
||||||
|
'scanner.expiry_found': 'Date trouvée',
|
||||||
|
'scanner.expiry_raw_label': 'Lu',
|
||||||
|
'scanner.expiry_read_fail': 'Impossible de lire la date.',
|
||||||
|
'settings.info.act_new_products': 'Nouveaux produits',
|
||||||
|
'settings.info.act_restock': 'Réapprovisionnements',
|
||||||
|
'settings.info.act_title': 'Activité mensuelle',
|
||||||
|
'settings.info.act_tx_month': 'Mouvements',
|
||||||
|
'settings.info.act_tx_year': 'Mouvements annuels',
|
||||||
|
'settings.info.act_use': 'Utilisations',
|
||||||
|
'settings.info.ai_calls': 'Appels',
|
||||||
|
'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.',
|
||||||
|
'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système',
|
||||||
|
'settings.info.ai_title': 'Gemini AI — Utilisation des tokens',
|
||||||
|
'settings.info.bring_days': 'jeton expire dans {n} jours',
|
||||||
|
'settings.info.bring_expired': 'jeton expiré',
|
||||||
|
'settings.info.by_action': 'Répartition par fonction',
|
||||||
|
'settings.info.by_model': 'Répartition par modèle',
|
||||||
|
'settings.info.cache_entries': 'produits',
|
||||||
|
'settings.info.calls_unit': 'appels',
|
||||||
|
'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.',
|
||||||
|
'settings.info.currency_title': 'Devise',
|
||||||
|
'settings.info.db_size': 'Base de données',
|
||||||
|
'settings.info.est_cost': 'Coût est.',
|
||||||
|
'settings.info.input_tok': 'Tokens entrée',
|
||||||
|
'settings.info.inv_active': 'Actifs',
|
||||||
|
'settings.info.inv_expired': 'Expirés',
|
||||||
|
'settings.info.inv_expiring': 'Expirent (7j)',
|
||||||
|
'settings.info.inv_finished': 'Terminés',
|
||||||
|
'settings.info.inv_products': 'Produits totaux',
|
||||||
|
'settings.info.inv_title': 'Inventaire',
|
||||||
|
'settings.info.last_backup': 'Dernière sauvegarde',
|
||||||
|
'settings.info.loading': 'Chargement…',
|
||||||
|
'settings.info.log_level': 'Niveau de log',
|
||||||
|
'settings.info.log_size': 'Logs',
|
||||||
|
'settings.info.output_tok': 'Tokens sortie',
|
||||||
|
'settings.info.price_cache': 'Cache prix',
|
||||||
|
'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
|
||||||
|
'settings.info.system_title': 'Système',
|
||||||
|
'settings.info.tab': 'Info',
|
||||||
|
'settings.info.total_tokens': 'Tokens totaux',
|
||||||
|
'settings.info.year_label': 'Année {year}',
|
||||||
|
'settings.tab_general': 'Général',
|
||||||
|
'settings.tts.test_sound_btn': '🔔 Test sonore',
|
||||||
|
'shopping.pantry_hint': 'Déjà à la maison : {qty}',
|
||||||
|
'startup.check_db_legacy': 'Ancienne BD (dispensa.db)',
|
||||||
|
'startup.check_scale': 'Passerelle balance',
|
||||||
|
'startup.check_tts': 'URL synthèse vocale',
|
||||||
|
'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :',
|
||||||
|
'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.',
|
||||||
|
'toast.vacuum_sealed': '{name} enregistré sous vide',
|
||||||
|
}
|
||||||
|
|
||||||
|
ES_FILL = {
|
||||||
|
'action.related_stock_title': 'También en casa',
|
||||||
|
'dashboard.banner_expired_action_modify': 'Editar',
|
||||||
|
'dashboard.banner_expired_action_vacuum': 'Poner al vacío',
|
||||||
|
'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.',
|
||||||
|
'scan.stock_in_pantry': 'Ya en despensa:',
|
||||||
|
'scanner.expiry_found': 'Fecha encontrada',
|
||||||
|
'scanner.expiry_raw_label': 'Leído',
|
||||||
|
'scanner.expiry_read_fail': 'No se puede leer la fecha.',
|
||||||
|
'settings.info.act_new_products': 'Productos nuevos',
|
||||||
|
'settings.info.act_restock': 'Reabastecimientos',
|
||||||
|
'settings.info.act_title': 'Actividad mensual',
|
||||||
|
'settings.info.act_tx_month': 'Movimientos',
|
||||||
|
'settings.info.act_tx_year': 'Movimientos anuales',
|
||||||
|
'settings.info.act_use': 'Usos',
|
||||||
|
'settings.info.ai_calls': 'Llamadas',
|
||||||
|
'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.',
|
||||||
|
'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema',
|
||||||
|
'settings.info.ai_title': 'Gemini AI — Uso de tokens',
|
||||||
|
'settings.info.bring_days': 'token expira en {n} días',
|
||||||
|
'settings.info.bring_expired': 'token expirado',
|
||||||
|
'settings.info.by_action': 'Desglose por función',
|
||||||
|
'settings.info.by_model': 'Desglose por modelo',
|
||||||
|
'settings.info.cache_entries': 'productos',
|
||||||
|
'settings.info.calls_unit': 'llamadas',
|
||||||
|
'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.',
|
||||||
|
'settings.info.currency_title': 'Moneda',
|
||||||
|
'settings.info.db_size': 'Base de datos',
|
||||||
|
'settings.info.est_cost': 'Coste est.',
|
||||||
|
'settings.info.input_tok': 'Tokens de entrada',
|
||||||
|
'settings.info.inv_active': 'Activos',
|
||||||
|
'settings.info.inv_expired': 'Caducados',
|
||||||
|
'settings.info.inv_expiring': 'Caducan (7d)',
|
||||||
|
'settings.info.inv_finished': 'Agotados',
|
||||||
|
'settings.info.inv_products': 'Productos totales',
|
||||||
|
'settings.info.inv_title': 'Inventario',
|
||||||
|
'settings.info.last_backup': 'Última copia',
|
||||||
|
'settings.info.loading': 'Cargando…',
|
||||||
|
'settings.info.log_level': 'Nivel de log',
|
||||||
|
'settings.info.log_size': 'Logs',
|
||||||
|
'settings.info.output_tok': 'Tokens de salida',
|
||||||
|
'settings.info.price_cache': 'Caché de precios',
|
||||||
|
'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.',
|
||||||
|
'settings.info.system_title': 'Sistema',
|
||||||
|
'settings.info.tab': 'Info',
|
||||||
|
'settings.info.total_tokens': 'Tokens totales',
|
||||||
|
'settings.info.year_label': 'Año {year}',
|
||||||
|
'settings.tab_general': 'General',
|
||||||
|
'settings.tts.test_sound_btn': '🔔 Prueba de sonido',
|
||||||
|
'shopping.pantry_hint': 'Ya en casa: {qty}',
|
||||||
|
'startup.check_db_legacy': 'BD antigua (dispensa.db)',
|
||||||
|
'startup.check_scale': 'Pasarela báscula',
|
||||||
|
'startup.check_tts': 'URL texto a voz',
|
||||||
|
'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:',
|
||||||
|
'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.',
|
||||||
|
'toast.vacuum_sealed': '{name} guardado al vacío',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(obj: dict, prefix: str = '') -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
key = f'{prefix}.{k}' if prefix else k
|
||||||
|
if isinstance(v, dict):
|
||||||
|
out.update(flatten(v, key))
|
||||||
|
else:
|
||||||
|
out[key] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_nested(root: dict, dotted: str, value: str) -> None:
|
||||||
|
parts = dotted.split('.')
|
||||||
|
d = root
|
||||||
|
for p in parts[:-1]:
|
||||||
|
d = d.setdefault(p, {})
|
||||||
|
d[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ref = json.loads((ROOT / REF).read_text(encoding='utf-8'))
|
||||||
|
ref_flat = flatten(ref)
|
||||||
|
en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8')))
|
||||||
|
|
||||||
|
for fname in LOCALES:
|
||||||
|
lang = fname.replace('.json', '')
|
||||||
|
path = ROOT / fname
|
||||||
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
flat = flatten(data)
|
||||||
|
|
||||||
|
# Fill missing keys from reference (Italian text as last resort via en)
|
||||||
|
for key, ref_val in ref_flat.items():
|
||||||
|
if key not in flat:
|
||||||
|
if lang == 'fr' and key in FR_FILL:
|
||||||
|
val = FR_FILL[key]
|
||||||
|
elif lang == 'es' and key in ES_FILL:
|
||||||
|
val = ES_FILL[key]
|
||||||
|
elif lang == 'en':
|
||||||
|
val = en_flat.get(key, ref_val)
|
||||||
|
else:
|
||||||
|
val = en_flat.get(key, ref_val)
|
||||||
|
set_nested(data, key, val)
|
||||||
|
flat[key] = val
|
||||||
|
|
||||||
|
# Inject new keys
|
||||||
|
for key, per_lang in NEW_KEYS.items():
|
||||||
|
set_nested(data, key, per_lang[lang if lang in per_lang else 'en'])
|
||||||
|
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||||
|
print(f'Updated {fname}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Full Bring! sync: recompute smart shopping, migrate names, dedupe generics,
|
||||||
|
* fix specs, remove obsolete items, add missing critical/high.
|
||||||
|
*
|
||||||
|
* Usage: php scripts/sync-shopping-bring.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/index.php';
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] Starting full Bring! sync…\n";
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
bringSyncFull($db, true);
|
||||||
|
$json = ob_get_clean();
|
||||||
|
$result = json_decode($json, true);
|
||||||
|
|
||||||
|
if (!$result || empty($result['success'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . ($result['error'] ?? $json) . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Smart items: ' . ($result['smart_items'] ?? '?') . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Migrate: ' . json_encode($result['migrate'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Dedupe: ' . json_encode($result['dedupe'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Specs: ' . json_encode($result['specs'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Cleanup: ' . json_encode($result['cleanup'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Auto-add: ' . json_encode($result['auto_add'] ?? [], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
if (!empty($result['dedupe_final'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Dedupe (final): ' . json_encode($result['dedupe_final'], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
}
|
||||||
|
if (!empty($result['cache_restored'])) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Cache restored: ' . $result['cache_restored'] . " items\n";
|
||||||
|
}
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . "] Done.\n";
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Triage resolved auto-report bugs only (English comments).
|
||||||
|
* Feature/enhancement backlog issues are never bulk-closed here.
|
||||||
|
* Usage: php scripts/triage-open-issues.php [--dry-run]
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('CRON_MODE', true);
|
||||||
|
require_once __DIR__ . '/../api/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/github.php';
|
||||||
|
require_once __DIR__ . '/../api/lib/constants.php';
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv ?? [], true);
|
||||||
|
$repo = GH_REPO;
|
||||||
|
$token = _ghToken();
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghApi(string $token, string $method, string $url, array $payload = []): array {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = [
|
||||||
|
'Authorization: token ' . $token,
|
||||||
|
'Accept: application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version: 2022-11-28',
|
||||||
|
'User-Agent: EverShelf-Triage/1.0',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
];
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
]);
|
||||||
|
if ($method === 'PATCH') {
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
} elseif ($method === 'POST') {
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
}
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentIssue(string $token, string $repo, int $num, string $body, bool $dryRun): bool {
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "[dry-run] comment #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$r = ghApi($token, 'POST', "https://api.github.com/repos/$repo/issues/$num/comments", ['body' => $body]);
|
||||||
|
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||||
|
echo "OK comment #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fwrite(STDERR, "FAIL comment #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeIssue(string $token, string $repo, int $num, bool $dryRun): bool {
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "[dry-run] close #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$r = ghApi($token, 'PATCH', "https://api.github.com/repos/$repo/issues/$num", ['state' => 'closed']);
|
||||||
|
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||||
|
echo "OK close #$num\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fwrite(STDERR, "FAIL close #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bugs = [
|
||||||
|
198 => 'Fixed in develop: `PRAGMA busy_timeout` raised to 10s and `dbWithRetry()` on `updateInventory` retries SQLITE_BUSY when cron and PWA write in parallel.',
|
||||||
|
199 => 'Duplicate of #198 — same event (`inventory_update` → database locked). Fix: retry + longer busy_timeout.',
|
||||||
|
196 => 'Fixed in v1.7.38+: `saveProduct` handles duplicate barcodes (merge or 409 JSON) instead of HTTP 500.',
|
||||||
|
197 => 'PWA side-effect of PHP crash #196 — fixed with duplicate barcode handling in `saveProduct`.',
|
||||||
|
195 => 'Fixed: `EverLog::request()` always receives strings — `(string)($_SERVER[\'REQUEST_METHOD\'] ?? \'GET\')`.',
|
||||||
|
193 => 'Same root cause as #195 (TypeError when method was null from CLI).',
|
||||||
|
194 => 'Fixed: `_applySpesaScanUI` referenced `currentPage` → corrected to `_currentPageId`.',
|
||||||
|
192 => 'Fixed: TDZ on `enriched` in `renderShoppingItems`.',
|
||||||
|
191 => 'Fixed: TDZ on `setProgress` / `barEl` in `_runStartupCheck`.',
|
||||||
|
134 => 'Auto-report for non-writable Docker volume. Mitigations: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile chown.',
|
||||||
|
184 => 'Related to #134: SQLite readonly when `data/` is not writable.',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($bugs as $num => $msg) {
|
||||||
|
commentIssue($token, $repo, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._", $dryRun);
|
||||||
|
closeIssue($token, $repo, $num, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done.\n";
|
||||||
+409
-25
@@ -32,6 +32,7 @@
|
|||||||
"reset_default": "↺ Standard wiederherstellen",
|
"reset_default": "↺ Standard wiederherstellen",
|
||||||
"save_info": "💾 Info speichern",
|
"save_info": "💾 Info speichern",
|
||||||
"retry": "🔄 Erneut versuchen",
|
"retry": "🔄 Erneut versuchen",
|
||||||
|
"next": "Weiter →",
|
||||||
"yes_short": "Ja",
|
"yes_short": "Ja",
|
||||||
"no_short": "Nein"
|
"no_short": "Nein"
|
||||||
},
|
},
|
||||||
@@ -113,6 +114,8 @@
|
|||||||
"banner_expired_action_finished": "Habe ich verbraucht!",
|
"banner_expired_action_finished": "Habe ich verbraucht!",
|
||||||
"banner_expired_action_throw": "Habe ich weggeworfen",
|
"banner_expired_action_throw": "Habe ich weggeworfen",
|
||||||
"banner_expired_action_edit": "Datum korrigieren",
|
"banner_expired_action_edit": "Datum korrigieren",
|
||||||
|
"banner_expired_action_modify": "Bearbeiten",
|
||||||
|
"banner_expired_action_vacuum": "Vakuumieren",
|
||||||
"banner_anomaly_action_edit": "Bestand korrigieren",
|
"banner_anomaly_action_edit": "Bestand korrigieren",
|
||||||
"banner_anomaly_action_dismiss": "Menge ist korrekt",
|
"banner_anomaly_action_dismiss": "Menge ist korrekt",
|
||||||
"banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
|
"banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
|
||||||
@@ -141,14 +144,22 @@
|
|||||||
"banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
|
"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_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_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_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
|
||||||
"banner_finished_check": "Kannst du nachschauen?",
|
"banner_finished_check": "Kannst du nachschauen?",
|
||||||
|
"banner_finished_action_restore": "{qty} {unit} wiederherstellen",
|
||||||
"banner_anomaly_phantom_title": "mehr Bestand als erwartet",
|
"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_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
|
||||||
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
||||||
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
||||||
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
||||||
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
||||||
|
"banner_dup_loss_title": "Prüfung Doppelabbuchung: {name}",
|
||||||
|
"banner_dup_loss_detail": "Mögliche doppelte Buchung in {location}: zwei schnelle Abgänge ({qty_pair}) in ~{seconds}s. Bitte prüfen und ggf. korrigieren.",
|
||||||
|
"banner_dup_loss_action_fix": "Menge korrigieren",
|
||||||
|
"banner_dup_loss_action_open": "Produktkarte öffnen",
|
||||||
|
"banner_dup_loss_action_done": "Bereits geprüft",
|
||||||
|
"banner_dup_loss_toast_done": "Prüfung als erledigt markiert",
|
||||||
"consumed": "Verbraucht: {n} ({pct}%)",
|
"consumed": "Verbraucht: {n} ({pct}%)",
|
||||||
"wasted": "Weggeworfen: {n} ({pct}%)",
|
"wasted": "Weggeworfen: {n} ({pct}%)",
|
||||||
"more_opened": "und {n} weitere geöffnet...",
|
"more_opened": "und {n} weitere geöffnet...",
|
||||||
@@ -156,7 +167,11 @@
|
|||||||
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
|
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
|
||||||
"banner_explain_title": "Gemini um eine Erklärung bitten",
|
"banner_explain_title": "Gemini um eine Erklärung bitten",
|
||||||
"banner_explain_btn": "Erklären",
|
"banner_explain_btn": "Erklären",
|
||||||
"banner_analyzing": "🤖 Analysiere…"
|
"banner_analyzing": "🤖 Analysiere…",
|
||||||
|
"banner_prediction_confirmed": "✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet",
|
||||||
|
"banner_anomaly_explain_fail": "KI-Erklärung konnte nicht abgerufen werden",
|
||||||
|
"banner_anomaly_dismissed": "Anomalie ignoriert",
|
||||||
|
"banner_finished_restore_prompt": "Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})"
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"title": "Vorrat",
|
"title": "Vorrat",
|
||||||
@@ -185,6 +200,7 @@
|
|||||||
"mode_shopping": "🛒 Einkaufsmodus",
|
"mode_shopping": "🛒 Einkaufsmodus",
|
||||||
"mode_shopping_end": "✅ Einkauf beenden",
|
"mode_shopping_end": "✅ Einkauf beenden",
|
||||||
"spesa_btn": "🛒 Einkauf",
|
"spesa_btn": "🛒 Einkauf",
|
||||||
|
"spesa_camera_hint": "Barcode mit der Kamera erfassen. Kein Barcode? Tippe unten auf «Mit KI erkennen».",
|
||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"tab_barcode": "Barcode",
|
"tab_barcode": "Barcode",
|
||||||
"tab_name": "Name",
|
"tab_name": "Name",
|
||||||
@@ -211,13 +227,53 @@
|
|||||||
"barcode_acquired": "🔖 Barcode gescannt: {code}",
|
"barcode_acquired": "🔖 Barcode gescannt: {code}",
|
||||||
"scan_barcode": "🔖 Barcode scannen",
|
"scan_barcode": "🔖 Barcode scannen",
|
||||||
"create_named": "{name} erstellen",
|
"create_named": "{name} erstellen",
|
||||||
"new_without_barcode": "Neues Produkt ohne Barcode"
|
"new_without_barcode": "Neues Produkt ohne Barcode",
|
||||||
|
"stock_in_pantry": "Bereits im Vorrat:",
|
||||||
|
"status_ready": "Kamera auf Barcode richten",
|
||||||
|
"status_scanning": "Scanne...",
|
||||||
|
"status_partial": "Erkannt: {code} — prüfe...",
|
||||||
|
"status_invalid": "Ungültig: {code} — versuche erneut",
|
||||||
|
"status_confirmed": "Bestätigt!",
|
||||||
|
"status_parallel": "Kombinierter Scan aktiv...",
|
||||||
|
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
|
||||||
|
"status_digit_ocr": "Lese Ziffern unter dem Barcode...",
|
||||||
|
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"method_local_ocr": "Ziffern-OCR",
|
||||||
|
"method_zbar": "ZBar",
|
||||||
|
"local_ocr_found": "Code aus Ziffern: {code}",
|
||||||
|
"ai_fallback_searching": "KI identifiziert Produkt...",
|
||||||
|
"ai_fallback_found": "Produkt von KI erkannt",
|
||||||
|
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
|
||||||
|
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
|
||||||
|
"ai_overlay_label": "Gemini Vision",
|
||||||
|
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
|
||||||
|
"ai_retry_btn": "Mit KI erneut versuchen",
|
||||||
|
"ai_manual_btn": "🤖 Mit KI erkennen",
|
||||||
|
"ai_not_recognized": "KI: Produkt nicht erkannt. Erneut versuchen oder manuell eingeben.",
|
||||||
|
"ai_match_title": "Produkt von KI erkannt",
|
||||||
|
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
|
||||||
|
"ai_match_existing": "Aktuell in der Vorratskammer",
|
||||||
|
"ai_match_finished": "Aufgebraucht / leer",
|
||||||
|
"ai_match_catalog": "Im Katalog (ohne Bestand)",
|
||||||
|
"ai_match_finished_badge": "aufgebraucht",
|
||||||
|
"ai_match_finished_hint": "Produkt aufgebraucht — Menge auffüllen",
|
||||||
|
"ai_match_merged_existing": "Mit vorhandenem Katalogprodukt verknüpft",
|
||||||
|
"ai_match_none": "Keine ähnlichen Produkte — du kannst ein neues anlegen.",
|
||||||
|
"ai_match_use_btn": "Nutzen",
|
||||||
|
"ai_match_create_btn": "➕ Neu anlegen: {name}",
|
||||||
|
"ai_match_add_btn": "{name} hinzufugen",
|
||||||
|
"ai_match_action_hint": "Tippe auf den gruenen Button, um dieses Produkt hinzuzufuegen",
|
||||||
|
"ai_match_or_similar": "Oder waehle ein aehnliches Produkt:",
|
||||||
|
"ai_detected_label": "KI erkannt",
|
||||||
|
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Was möchtest du tun?",
|
"title": "Was möchtest du tun?",
|
||||||
"add_btn": "📥 HINZUFÜGEN",
|
"add_btn": "📥 HINZUFÜGEN",
|
||||||
"add_sub": "in Vorrat/Kühlschrank",
|
"add_sub": "in Vorrat/Kühlschrank",
|
||||||
"use_btn": "📤 VERWENDEN / VERBRAUCHEN",
|
"use_btn": "VERWENDEN",
|
||||||
"use_sub": "aus Vorrat/Kühlschrank",
|
"use_sub": "aus Vorrat/Kühlschrank",
|
||||||
"have_title": "📦 Schon auf Lager!",
|
"have_title": "📦 Schon auf Lager!",
|
||||||
"add_more_sub": "weitere Menge",
|
"add_more_sub": "weitere Menge",
|
||||||
@@ -225,7 +281,8 @@
|
|||||||
"throw_btn": "🗑️ ENTSORGEN",
|
"throw_btn": "🗑️ ENTSORGEN",
|
||||||
"throw_sub": "wegwerfen",
|
"throw_sub": "wegwerfen",
|
||||||
"edit_sub": "Ablauf, Ort…",
|
"edit_sub": "Ablauf, Ort…",
|
||||||
"create_recipe_btn": "Rezept"
|
"create_recipe_btn": "Rezept",
|
||||||
|
"related_stock_title": "Auch zuhause"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"title": "Zum Vorrat hinzufügen",
|
"title": "Zum Vorrat hinzufügen",
|
||||||
@@ -249,8 +306,9 @@
|
|||||||
"hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
|
"hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
|
||||||
"scan_expiry_title": "📷 Ablaufdatum scannen",
|
"scan_expiry_title": "📷 Ablaufdatum scannen",
|
||||||
"product_added": "✅ {name} hinzugefügt!{qty}",
|
"product_added": "✅ {name} hinzugefügt!{qty}",
|
||||||
|
"duplicate_recent_confirm": "Du hast «{name}» gerade hinzugefuegt ({when}).\n\nDie Menge ist bereits {total}.\n\nUm {qty} erhoehen?",
|
||||||
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
|
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
|
||||||
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
|
"history_badge_tip": "Durchschnitt der letzten {n} Einträge — wird bei jedem Kauf aktualisiert",
|
||||||
"vacuum_question": "Vakuumiert?",
|
"vacuum_question": "Vakuumiert?",
|
||||||
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
|
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
|
||||||
},
|
},
|
||||||
@@ -283,14 +341,17 @@
|
|||||||
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
||||||
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
|
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
|
||||||
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
|
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
|
||||||
|
"disambiguation_one_conf": "<strong>1 Packung</strong> aufgebraucht ({qty})",
|
||||||
"disambiguation_all": "🗑️ ALLES verbraucht ({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!",
|
"error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!",
|
||||||
"use_all_confirm_title": "✅ Alles aufbrauchen",
|
"use_all_confirm_title": "✅ Alles aufbrauchen",
|
||||||
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
|
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
|
||||||
"use_all_confirm_btn": "✅ Ja, aufgebraucht",
|
"use_all_confirm_btn": "✅ Ja, aufgebraucht",
|
||||||
"throw_all_confirm_title": "🗑️ Alles entsorgen",
|
"throw_all_confirm_title": "🗑️ Alles entsorgen",
|
||||||
"throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?",
|
"throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?",
|
||||||
"throw_all_confirm_btn": "🗑️ Ja, entsorgen"
|
"throw_all_confirm_btn": "🗑️ Ja, entsorgen",
|
||||||
|
"locations_short": "Orte"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title_new": "Neues Produkt",
|
"title_new": "Neues Produkt",
|
||||||
@@ -300,6 +361,8 @@
|
|||||||
"name_label": "🏷️ Produktname *",
|
"name_label": "🏷️ Produktname *",
|
||||||
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
|
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
|
||||||
"brand_label": "🏢 Marke",
|
"brand_label": "🏢 Marke",
|
||||||
|
"allergens_label": "Allergene:",
|
||||||
|
"ingredients_summary": "📋 Zutaten",
|
||||||
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
|
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
|
||||||
"category_label": "📂 Kategorie",
|
"category_label": "📂 Kategorie",
|
||||||
"unit_label": "📏 Maßeinheit",
|
"unit_label": "📏 Maßeinheit",
|
||||||
@@ -330,7 +393,9 @@
|
|||||||
"weight_label": "Gewicht",
|
"weight_label": "Gewicht",
|
||||||
"origin_label": "Herkunft",
|
"origin_label": "Herkunft",
|
||||||
"labels_label": "Etiketten",
|
"labels_label": "Etiketten",
|
||||||
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
|
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:",
|
||||||
|
"history_badge": "📊 Verlauf",
|
||||||
|
"from_history": " (Ø letzte 3)"
|
||||||
},
|
},
|
||||||
"products": {
|
"products": {
|
||||||
"title": "📦 Alle Produkte",
|
"title": "📦 Alle Produkte",
|
||||||
@@ -357,12 +422,16 @@
|
|||||||
"loading_msg": "Rezept wird vorbereitet...",
|
"loading_msg": "Rezept wird vorbereitet...",
|
||||||
"start_cooking": "👨🍳 Kochmodus",
|
"start_cooking": "👨🍳 Kochmodus",
|
||||||
"regenerate": "🔄 Noch eins generieren",
|
"regenerate": "🔄 Noch eins generieren",
|
||||||
|
"regen_choice_title": "Was möchtest du mit diesem Rezept machen?",
|
||||||
|
"regen_replace": "🔄 Neues generieren (dieses verwerfen)",
|
||||||
|
"regen_save_new": "💾 Im Archiv speichern & neues generieren",
|
||||||
"close_btn": "✅ Schließen",
|
"close_btn": "✅ Schließen",
|
||||||
"ingredients_title": "🧾 Zutaten",
|
"ingredients_title": "🧾 Zutaten",
|
||||||
"tools_title": "Benötigte Geräte",
|
"tools_title": "Benötigte Geräte",
|
||||||
"steps_title": "👨🍳 Zubereitung",
|
"steps_title": "👨🍳 Zubereitung",
|
||||||
"no_steps": "Keine Zubereitungsschritte verfügbar",
|
"no_steps": "Keine Zubereitungsschritte verfügbar",
|
||||||
"generate_error": "Fehler bei der Generierung",
|
"generate_error": "Fehler bei der Generierung",
|
||||||
|
"stream_interrupted": "Generierung unterbrochen (unvollstaendige Antwort vom Server). Protokolle pruefen oder erneut versuchen.",
|
||||||
"persons_short": "Pers.",
|
"persons_short": "Pers.",
|
||||||
"use_ingredient_title": "Zutat verwenden",
|
"use_ingredient_title": "Zutat verwenden",
|
||||||
"recipe_qty_label": "Rezept",
|
"recipe_qty_label": "Rezept",
|
||||||
@@ -376,7 +445,21 @@
|
|||||||
"scale_wait_stable": "10s stabiles Gewicht für Auto-Ausfüllen abwarten…",
|
"scale_wait_stable": "10s stabiles Gewicht für Auto-Ausfüllen abwarten…",
|
||||||
"ingredient_scaled_toast": "📦 Zutat vom Vorrat abgezogen!",
|
"ingredient_scaled_toast": "📦 Zutat vom Vorrat abgezogen!",
|
||||||
"finished_added_bring_toast": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt!",
|
"finished_added_bring_toast": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt!",
|
||||||
"load_error": "Fehler beim Laden"
|
"load_error": "Fehler beim Laden",
|
||||||
|
"favorite": "Zu Favoriten hinzufügen",
|
||||||
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
|
"adjust_persons": "Personen",
|
||||||
|
"nutrition_title": "Nährwerte (pro Portion)",
|
||||||
|
"nutrition_kcal": "Kalorien",
|
||||||
|
"nutrition_protein": "Protein",
|
||||||
|
"nutrition_carbs": "Kohlenhydrate",
|
||||||
|
"nutrition_fat": "Fett",
|
||||||
|
"nutrition_per_serving": "Geschätzte Werte pro Portion",
|
||||||
|
"storage_title": "Aufbewahrung von Resten",
|
||||||
|
"storage_days": "{n} Tage",
|
||||||
|
"storage_immediately": "Am besten sofort verzehren",
|
||||||
|
"ing_stock_line": "Du hast {have} · {remain} bleiben nach Gebrauch",
|
||||||
|
"ing_use_all_note": "alles verwenden (<5% der Vollpackung)"
|
||||||
},
|
},
|
||||||
"shopping": {
|
"shopping": {
|
||||||
"title": "🛒 Einkaufsliste",
|
"title": "🛒 Einkaufsliste",
|
||||||
@@ -436,6 +519,18 @@
|
|||||||
"item_removed": "✅ {name} von der Liste entfernt!",
|
"item_removed": "✅ {name} von der Liste entfernt!",
|
||||||
"urgency_spec_critical": "⚡ Dringend",
|
"urgency_spec_critical": "⚡ Dringend",
|
||||||
"urgency_spec_high": "🟠 Bald",
|
"urgency_spec_high": "🟠 Bald",
|
||||||
|
"urgency_spec_medium": "🟡 Bald",
|
||||||
|
"urgency_spec_low": "🔵 Prognose",
|
||||||
|
"family_sibling_title": "Ähnlich in {location}",
|
||||||
|
"family_sibling_check": "Prüfen: {name}",
|
||||||
|
"family_sibling_stock": "Du solltest haben: {qty}",
|
||||||
|
"family_sibling_location": "Standort: {location}",
|
||||||
|
"family_sibling_qty": "Menge: {qty}",
|
||||||
|
"family_sibling_purchased": "Gekauft am {date}",
|
||||||
|
"family_sibling_question": "Ist die Menge noch korrekt?",
|
||||||
|
"family_sibling_prompt": "Du hast auch {name}: {qty} auf Lager. Menge bestätigen?",
|
||||||
|
"family_sibling_yes": "Ja, passt",
|
||||||
|
"family_sibling_no": "Nein, aktualisieren",
|
||||||
"bring_add_n": "{n} zu Bring! hinzufügen",
|
"bring_add_n": "{n} zu Bring! hinzufügen",
|
||||||
"bring_add_selected": "Ausgewählte zu Bring! hinzufügen",
|
"bring_add_selected": "Ausgewählte zu Bring! hinzufügen",
|
||||||
"bring_adding": "Wird hinzugefügt...",
|
"bring_adding": "Wird hinzugefügt...",
|
||||||
@@ -463,6 +558,7 @@
|
|||||||
"remove_error": "Fehler beim Entfernen",
|
"remove_error": "Fehler beim Entfernen",
|
||||||
"btn_fetch_prices": "Preise suchen",
|
"btn_fetch_prices": "Preise suchen",
|
||||||
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
||||||
|
"price_total_short": "geschätzte Ausgaben",
|
||||||
"price_loading": "Preise werden gesucht…",
|
"price_loading": "Preise werden gesucht…",
|
||||||
"price_not_found": "Preis n/v",
|
"price_not_found": "Preis n/v",
|
||||||
"suggest_loading": "Analyse läuft...",
|
"suggest_loading": "Analyse läuft...",
|
||||||
@@ -471,7 +567,9 @@
|
|||||||
"priority_medium": "Mittel",
|
"priority_medium": "Mittel",
|
||||||
"priority_low": "Niedrig",
|
"priority_low": "Niedrig",
|
||||||
"smart_last_update": "Aktualisiert {time}",
|
"smart_last_update": "Aktualisiert {time}",
|
||||||
"names_already_updated": "Alle Namen sind bereits aktuell"
|
"names_already_updated": "Alle Namen sind bereits aktuell",
|
||||||
|
"pantry_hint": "Bereits zuhause: {qty}",
|
||||||
|
"bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert"
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"title": "🤖 KI-Identifikation",
|
"title": "🤖 KI-Identifikation",
|
||||||
@@ -482,7 +580,8 @@
|
|||||||
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
|
"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",
|
"fields_filled": "✅ Felder von KI ausgefüllt",
|
||||||
"use_data": "✅ KI-Daten verwenden",
|
"use_data": "✅ KI-Daten verwenden",
|
||||||
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)"
|
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)",
|
||||||
|
"conservation_hint": "🤖 KI: lagere in {location}"
|
||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"title": "📒 Verlauf",
|
"title": "📒 Verlauf",
|
||||||
@@ -500,7 +599,8 @@
|
|||||||
"undo_success": "↩ Vorgang rückgängig gemacht für {name}",
|
"undo_success": "↩ Vorgang rückgängig gemacht für {name}",
|
||||||
"already_undone": "Vorgang bereits rückgängig gemacht",
|
"already_undone": "Vorgang bereits rückgängig gemacht",
|
||||||
"too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden",
|
"too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden",
|
||||||
"undo_error": "Fehler beim Rückgängigmachen"
|
"undo_error": "Fehler beim Rückgängigmachen",
|
||||||
|
"recipe_prefix": "Rezept"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "Gemini Chef",
|
"title": "Gemini Chef",
|
||||||
@@ -532,14 +632,16 @@
|
|||||||
"prev": "◀ Zurück",
|
"prev": "◀ Zurück",
|
||||||
"next": "Weiter ▶",
|
"next": "Weiter ▶",
|
||||||
"ingredient_used": "✔️ Abgezogen",
|
"ingredient_used": "✔️ Abgezogen",
|
||||||
"ingredient_use_btn": "📦 Verwenden",
|
"ingredient_use_btn": "Usa",
|
||||||
"ingredient_deduct_title": "Von Vorrat abziehen",
|
"ingredient_deduct_title": "Von Vorrat abziehen",
|
||||||
"timer_expired_tts": "Timer {label} abgelaufen!",
|
"timer_expired_tts": "Timer {label} abgelaufen!",
|
||||||
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
|
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
|
||||||
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
|
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
|
||||||
"expires_chip": "läuft ab {date}",
|
"expires_chip": "läuft ab {date}",
|
||||||
"finish": "✅ Fertig",
|
"finish": "✅ Fertig",
|
||||||
"step_fallback": "Schritt {n}"
|
"step_fallback": "Schritt {n}",
|
||||||
|
"zerowaste_label": "♻️ Abfall",
|
||||||
|
"zerowaste_tip_title": "Zero-Waste-Tipp"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "⚙️ Einstellungen",
|
"title": "⚙️ Einstellungen",
|
||||||
@@ -643,7 +745,10 @@
|
|||||||
"back": "📱 Rückkamera (Standard)",
|
"back": "📱 Rückkamera (Standard)",
|
||||||
"front": "🤳 Frontkamera",
|
"front": "🤳 Frontkamera",
|
||||||
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
||||||
"detect_btn": "🔄 Kameras erkennen"
|
"detect_btn": "🔄 Kameras erkennen",
|
||||||
|
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
|
||||||
|
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini.",
|
||||||
|
"ai_manual_hint": "Wenn der Barcode nicht lesbar ist, nutze den Button «Mit KI erkennen» unter der Kamera. Erfordert konfiguriertes Gemini."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 HTTPS-Zertifikat",
|
"title": "🔒 HTTPS-Zertifikat",
|
||||||
@@ -681,6 +786,7 @@
|
|||||||
"extra_fields_label": "➕ Zusätzliche Felder (JSON)",
|
"extra_fields_label": "➕ Zusätzliche Felder (JSON)",
|
||||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||||
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
|
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
|
||||||
|
"test_sound_btn": "🔔 Klangtest ausführen",
|
||||||
"test_btn": "🔊 Testansage senden",
|
"test_btn": "🔊 Testansage senden",
|
||||||
"voices_loading": "Stimmen werden geladen…",
|
"voices_loading": "Stimmen werden geladen…",
|
||||||
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
|
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
|
||||||
@@ -688,7 +794,12 @@
|
|||||||
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
|
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
|
||||||
"url_missing": "⚠️ Endpunkt-URL fehlt.",
|
"url_missing": "⚠️ Endpunkt-URL fehlt.",
|
||||||
"test_sending": "⏳ Wird gesendet…",
|
"test_sending": "⏳ Wird gesendet…",
|
||||||
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat."
|
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat.",
|
||||||
|
"heard_question": "Hast du die Stimme gehört?",
|
||||||
|
"heard_yes": "Ja, ich habe es gehört",
|
||||||
|
"heard_no": "Nein, ich habe nichts gehört",
|
||||||
|
"test_ok_kiosk": "TTS funktioniert.",
|
||||||
|
"test_fail_steps": "Prüfe: 1) Medienvolume ist nicht 0; 2) Google Text-to-Speech installiert und aktualisiert; 3) Deutsches Sprachpaket in den Android TTS-Einstellungen heruntergeladen."
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "🌐 Sprache",
|
"title": "🌐 Sprache",
|
||||||
@@ -726,7 +837,13 @@
|
|||||||
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
|
"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_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",
|
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
|
||||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — automatische Heuristik für 100+ Modelle</li></ul>"
|
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — automatische Heuristik für 100+ Modelle</li></ul>",
|
||||||
|
"discover_scanning": "🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…",
|
||||||
|
"discover_found": "✅ Gateway gefunden: {url}{more}",
|
||||||
|
"discover_not_found": "❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.",
|
||||||
|
"discover_failed": "❌ Suche fehlgeschlagen: {error}",
|
||||||
|
"discover_auto": "🔍 Auto",
|
||||||
|
"unknown_device": "Unbekanntes Gerät"
|
||||||
},
|
},
|
||||||
"kiosk": {
|
"kiosk": {
|
||||||
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
||||||
@@ -743,7 +860,178 @@
|
|||||||
},
|
},
|
||||||
"saved": "✅ Konfiguration gespeichert!",
|
"saved": "✅ Konfiguration gespeichert!",
|
||||||
"saved_local": "✅ Konfiguration lokal gespeichert",
|
"saved_local": "✅ Konfiguration lokal gespeichert",
|
||||||
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
|
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}",
|
||||||
|
"theme": {
|
||||||
|
"title": "🌙 Erscheinungsbild",
|
||||||
|
"hint": "Wähle das Interface-Design.",
|
||||||
|
"label": "🌙 Design",
|
||||||
|
"off": "☀️ Hell",
|
||||||
|
"on": "🌙 Dunkel",
|
||||||
|
"auto": "🔄 Automatisch (Tageszeit)"
|
||||||
|
},
|
||||||
|
"zerowaste": {
|
||||||
|
"card_title": "♻️ Zero-Waste-Tipps",
|
||||||
|
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
|
||||||
|
"label": "Tipps beim Kochen anzeigen"
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"tab": "Backup",
|
||||||
|
"local_title": "Lokales Backup",
|
||||||
|
"local_hint": "Täglicher Datenbank-Snapshot. Konfiguriere, wie viele Tage Backups aufbewahrt werden.",
|
||||||
|
"enabled": "Tägliches automatisches Backup aktivieren",
|
||||||
|
"retention_days": "Aufbewahrung (Tage)",
|
||||||
|
"retention_info": "Backups werden aufbewahrt für",
|
||||||
|
"backup_now": "Jetzt sichern",
|
||||||
|
"backing_up": "Sicherung läuft…",
|
||||||
|
"backed_up": "Sicherung abgeschlossen",
|
||||||
|
"backup_error": "Sicherungsfehler",
|
||||||
|
"last_backup": "Letztes Backup",
|
||||||
|
"no_backup_yet": "Noch kein Backup erstellt",
|
||||||
|
"list_empty": "Keine Backups verfügbar",
|
||||||
|
"restore_btn": "Wiederherstellen",
|
||||||
|
"restore_confirm": "Backup wiederherstellen",
|
||||||
|
"delete_btn": "Löschen",
|
||||||
|
"delete_confirm": "Backup löschen",
|
||||||
|
"gdrive_title": "Google Drive",
|
||||||
|
"gdrive_hint": "Backups automatisch via OAuth 2.0 auf Google Drive hochladen. Keine externen Bibliotheken erforderlich.",
|
||||||
|
"gdrive_enabled": "Google Drive Backup aktivieren",
|
||||||
|
"gdrive_folder_id": "Drive-Ordner-ID",
|
||||||
|
"gdrive_folder_id_hint": "Kopiere die ID aus der Drive-Ordner-URL: …/folders/<strong>ID</strong>",
|
||||||
|
"gdrive_retention_days": "Drive-Aufbewahrung (Tage, 0=alles behalten)",
|
||||||
|
"gdrive_test": "Verbindung testen",
|
||||||
|
"gdrive_ok": "Verbindung erfolgreich!",
|
||||||
|
"gdrive_error": "Verbindung fehlgeschlagen",
|
||||||
|
"gdrive_push_now": "Jetzt auf Drive hochladen",
|
||||||
|
"gdrive_pushing": "Wird hochgeladen…",
|
||||||
|
"gdrive_pushed": "Auf Drive hochgeladen",
|
||||||
|
"gdrive_wizard_hint": "Optional: täglich automatisch via OAuth 2.0 auf Google Drive sichern.",
|
||||||
|
"gdrive_skip": "Überspringen — später in Einstellungen konfigurieren",
|
||||||
|
"gdrive_client_id": "Client-ID",
|
||||||
|
"gdrive_client_secret": "Client-Secret",
|
||||||
|
"gdrive_redirect_uri_hint": "Füge <strong>http://localhost</strong> als autorisierten Weiterleitungs-URI in der Google Cloud Console hinzu. Funktioniert auf jedem Server, auch ohne öffentliche Domain.",
|
||||||
|
"gdrive_code_title": "Autorisierungs-URL oder Code einfügen",
|
||||||
|
"gdrive_code_hint": "Nach der Autorisierung öffnet der Browser http://localhost und zeigt möglicherweise einen Verbindungsfehler — das ist normal. Kopiere die URL aus der Adressleiste (z.B. <code>http://localhost/?code=4%2F0A...</code>) und füge sie hier ein.",
|
||||||
|
"gdrive_code_submit": "Bestätigen",
|
||||||
|
"gdrive_code_empty": "Bitte zuerst die URL oder den Autorisierungscode einfügen",
|
||||||
|
"gdrive_redirect_uri_label": "Redirect-URI (in Google Cloud Console eintragen):",
|
||||||
|
"gdrive_oauth_authorize": "Mit Google autorisieren",
|
||||||
|
"gdrive_oauth_authorized": "Autorisiert",
|
||||||
|
"gdrive_oauth_not_authorized": "Noch nicht autorisiert",
|
||||||
|
"gdrive_oauth_window_opened": "Browserfenster geöffnet — autorisieren und zurückkehren",
|
||||||
|
"gdrive_oauth_how_to": "OAuth 2.0 einrichten (Schritt für Schritt)",
|
||||||
|
"gdrive_oauth_steps": "<li>Gehe zu <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> und wähle dein Projekt</li><li>Aktiviere die <strong>Google Drive API</strong>: <em>APIs & Dienste → APIs aktivieren → Google Drive API</em></li><li>Gehe zu <em>APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID</em></li><li>Anwendungstyp: <strong>Webanwendung</strong>; füge die unten angezeigte URL als <em>Autorisierter Weiterleitungs-URI</em> hinzu</li><li>Kopiere <strong>Client-ID</strong> und <strong>Client-Secret</strong> in die Felder oben und speichere</li><li>Klicke auf <strong>Mit Google autorisieren</strong>: melde dich an und erteile den Zugriff</li><li>Das Fenster schließt sich automatisch und Backups sind bereit</li>"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"tab": "Info",
|
||||||
|
"ai_title": "Gemini AI — Token-Nutzung",
|
||||||
|
"ai_hint": "Monatlicher Verbrauch und geschätzte Kosten für den aktuellen API-Schlüssel.",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"total_tokens": "Token gesamt",
|
||||||
|
"est_cost": "Gesch. Kosten",
|
||||||
|
"input_tok": "Eingabe-Token",
|
||||||
|
"output_tok": "Ausgabe-Token",
|
||||||
|
"ai_calls": "Aufrufe",
|
||||||
|
"by_action": "Aufschlüsselung nach Funktion",
|
||||||
|
"by_model": "Aufschlüsselung nach Modell",
|
||||||
|
"pricing_note": "Gemini Referenzpreise: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||||
|
"system_title": "System",
|
||||||
|
"db_size": "Datenbank",
|
||||||
|
"log_size": "Protokolle",
|
||||||
|
"log_level": "Log-Level",
|
||||||
|
"ai_overview": "KI-Nutzungsübersicht, Inventar und Systemstatus",
|
||||||
|
"calls_unit": "Aufrufe",
|
||||||
|
"inv_title": "Inventar",
|
||||||
|
"inv_active": "Aktiv",
|
||||||
|
"inv_products": "Produkte gesamt",
|
||||||
|
"inv_expiring": "Ablaufend (7T)",
|
||||||
|
"inv_expired": "Abgelaufen",
|
||||||
|
"inv_finished": "Leer",
|
||||||
|
"act_title": "Monatliche Aktivität",
|
||||||
|
"act_tx_month": "Bewegungen",
|
||||||
|
"act_restock": "Einkäufe",
|
||||||
|
"act_use": "Verbrauch",
|
||||||
|
"act_new_products": "Neue Produkte",
|
||||||
|
"act_tx_year": "Jährl. Bewegungen",
|
||||||
|
"price_cache": "Preiscache",
|
||||||
|
"cache_entries": "Produkte",
|
||||||
|
"last_backup": "Letztes Backup",
|
||||||
|
"bring_days": "Token läuft in {n} Tagen ab",
|
||||||
|
"bring_expired": "Token abgelaufen",
|
||||||
|
"year_label": "Jahr {year}",
|
||||||
|
"currency_title": "Währung",
|
||||||
|
"currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird."
|
||||||
|
},
|
||||||
|
"tab_general": "Allgemein",
|
||||||
|
"shopping": {
|
||||||
|
"tab": "Einkaufsliste",
|
||||||
|
"title": "Einkaufsliste",
|
||||||
|
"hint": "Konfiguriere die integrierte Einkaufsliste oder verbinde Bring!.",
|
||||||
|
"enable_label": "Einkaufsliste aktivieren",
|
||||||
|
"mode_label": "Anbieter",
|
||||||
|
"mode_internal": "Intern (ohne Bring!)",
|
||||||
|
"mode_bring": "Bring! (externe App)",
|
||||||
|
"bring_section_title": "Bring!-Konfiguration",
|
||||||
|
"ai_section_title": "KI-Unterstützung",
|
||||||
|
"smart_suggestions_label": "KI-Vorschläge",
|
||||||
|
"forecast_label": "Prognose für bald leere Produkte",
|
||||||
|
"auto_add_label": "Automatisch hinzufügen wenn",
|
||||||
|
"auto_add_suffix": "im Lager verbleibend (0 = nur wenn leer)"
|
||||||
|
},
|
||||||
|
"ha": {
|
||||||
|
"tab": "Home Assistant",
|
||||||
|
"title": "Home Assistant",
|
||||||
|
"hint": "Verbinde EverShelf mit Home Assistant für Automationen, Push-Benachrichtigungen und REST-Sensoren.",
|
||||||
|
"enabled": "Home Assistant-Integration aktivieren",
|
||||||
|
"connection_title": "Verbindung",
|
||||||
|
"url_label": "Home Assistant URL",
|
||||||
|
"url_placeholder": "http://192.168.1.50:8123",
|
||||||
|
"url_hint": "Basis-URL deiner Home Assistant-Instanz (z.B. http://homeassistant.local:8123).",
|
||||||
|
"token_label": "Long-Lived Access Token",
|
||||||
|
"token_hint": "Erstelle unter HA-Profil → Sicherheit → Langlebige Zugangstoken.",
|
||||||
|
"token_placeholder": "eyJhbGci...",
|
||||||
|
"token_saved": "Token gespeichert (aus Sicherheitsgründen verborgen)",
|
||||||
|
"test_btn": "Verbindung testen",
|
||||||
|
"test_ok": "Verbunden mit {version}",
|
||||||
|
"test_fail": "Verbindung fehlgeschlagen: {error}",
|
||||||
|
"test_bad_token": "HA erreichbar, aber Token ist ungültig",
|
||||||
|
"testing": "Teste…",
|
||||||
|
"error_no_url": "Bitte zuerst die Home Assistant URL eingeben.",
|
||||||
|
"tts_title": "TTS auf Smart Speaker",
|
||||||
|
"tts_hint": "Rezeptschritte auf einem Home Assistant Media Player vorlesen.",
|
||||||
|
"tts_entity_label": "Media Player Entity ID",
|
||||||
|
"tts_entity_placeholder": "media_player.wohnzimmer",
|
||||||
|
"tts_entity_hint": "Entity-ID des HA-Media-Players. Zu finden unter HA: Entwicklertools → Zustände.",
|
||||||
|
"tts_platform_label": "TTS-Plattform",
|
||||||
|
"tts_platform_speak": "tts.speak (empfohlen)",
|
||||||
|
"tts_platform_notify": "notify.* (Benachrichtigungsdienst)",
|
||||||
|
"tts_apply_btn": "HA-Voreinstellung auf TTS-Tab anwenden",
|
||||||
|
"tts_apply_hint": "Füllt den TTS-Tab mit der Home Assistant URL und dem Token aus.",
|
||||||
|
"tts_preset_applied": "HA-Voreinstellung auf TTS-Tab angewendet.",
|
||||||
|
"webhook_title": "Webhook-Automationen",
|
||||||
|
"webhook_hint": "Sende Daten an Home Assistant, wenn Ereignisse in der Vorratskammer auftreten.",
|
||||||
|
"webhook_id_label": "Webhook-ID",
|
||||||
|
"webhook_id_placeholder": "evershelf_webhook_abc123",
|
||||||
|
"webhook_id_hint": "ID des in HA erstellten Webhooks. Kopiere aus: HA → Einstellungen → Automationen → Erstellen → Webhook-Auslöser.",
|
||||||
|
"webhook_events_label": "Benachrichtige bei diesen Ereignissen",
|
||||||
|
"event_expiry": "Ablaufende Produkte (täglich)",
|
||||||
|
"event_shopping": "Artikel zur Einkaufsliste hinzugefügt",
|
||||||
|
"event_stock": "Lagerbestand aktualisiert",
|
||||||
|
"expiry_days_label": "Ablaufwarnung im Voraus (Tage)",
|
||||||
|
"expiry_days_hint": "Sende die Ablaufwarnung N Tage vor dem Ablaufdatum.",
|
||||||
|
"webhook_help": "In HA: Einstellungen → Automationen → Automation erstellen → Auslöser: Webhook → ID kopieren.",
|
||||||
|
"notify_title": "Push-Benachrichtigungen",
|
||||||
|
"notify_hint": "Sende Push-Benachrichtigungen über einen Home Assistant notify-Dienst.",
|
||||||
|
"notify_service_label": "Notify-Dienst",
|
||||||
|
"notify_service_placeholder": "notify.mobile_app_mein_handy",
|
||||||
|
"notify_service_hint": "Name des HA-notify-Dienstes (z.B. notify.mobile_app_phone). Leer lassen zum Deaktivieren.",
|
||||||
|
"sensor_title": "REST-Sensoren",
|
||||||
|
"sensor_hint": "Zur configuration.yaml hinzufügen, um EverShelf-Sensoren in Home Assistant zu erstellen.",
|
||||||
|
"sensor_copy_btn": "YAML kopieren",
|
||||||
|
"sensor_copied": "YAML in die Zwischenablage kopiert!",
|
||||||
|
"save_btn": "HA-Einstellungen speichern",
|
||||||
|
"ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren."
|
||||||
|
},
|
||||||
|
"kiosk_update_required": "⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen"
|
||||||
},
|
},
|
||||||
"expiry": {
|
"expiry": {
|
||||||
"today": "HEUTE",
|
"today": "HEUTE",
|
||||||
@@ -815,7 +1103,9 @@
|
|||||||
"thrown_away": "🗑️ {name} weggeworfen!",
|
"thrown_away": "🗑️ {name} weggeworfen!",
|
||||||
"thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen",
|
"thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen",
|
||||||
"finished_all": "📤 {name} aufgebraucht!",
|
"finished_all": "📤 {name} aufgebraucht!",
|
||||||
|
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
|
||||||
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
|
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
|
||||||
|
"ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt",
|
||||||
"appliance_added": "Gerät hinzugefügt",
|
"appliance_added": "Gerät hinzugefügt",
|
||||||
"item_added": "{name} hinzugefügt"
|
"item_added": "{name} hinzugefügt"
|
||||||
},
|
},
|
||||||
@@ -865,6 +1155,7 @@
|
|||||||
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
||||||
"barcode_empty": "Barcode eingeben",
|
"barcode_empty": "Barcode eingeben",
|
||||||
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
||||||
|
"barcode_checksum": "Ungültiger EAN-Prüfziffer — bitte die Barcode-Ziffern prüfen",
|
||||||
"min_chars": "Mindestens 2 Zeichen eingeben",
|
"min_chars": "Mindestens 2 Zeichen eingeben",
|
||||||
"not_in_inventory": "Produkt nicht im Bestand",
|
"not_in_inventory": "Produkt nicht im Bestand",
|
||||||
"appliance_exists": "Gerät bereits vorhanden",
|
"appliance_exists": "Gerät bereits vorhanden",
|
||||||
@@ -876,13 +1167,27 @@
|
|||||||
"server_retry": "Erneut versuchen",
|
"server_retry": "Erneut versuchen",
|
||||||
"unknown": "Unbekannter Fehler",
|
"unknown": "Unbekannter Fehler",
|
||||||
"prefix": "Fehler",
|
"prefix": "Fehler",
|
||||||
"no_inventory_entry": "Kein Inventareintrag gefunden"
|
"no_inventory_entry": "Kein Inventareintrag gefunden",
|
||||||
|
"offline_title": "Keine Verbindung",
|
||||||
|
"offline_subtitle": "Die App kann den Server nicht erreichen. Überprüfe deine WLAN-Verbindung.",
|
||||||
|
"offline_checking": "Verbindung prüfen…",
|
||||||
|
"offline_restored": "Verbindung wiederhergestellt!",
|
||||||
|
"offline_continue": "Im Offline-Modus fortfahren",
|
||||||
|
"offline_reading_cache": "Lese aus lokalem Cache",
|
||||||
|
"offline_ops_pending": "{n} Aktionen ausstehend",
|
||||||
|
"offline_synced": "{n} Aktionen synchronisiert",
|
||||||
|
"offline_ai_disabled": "Offline nicht verfügbar",
|
||||||
|
"offline_cache_ready": "Offline — {n} Produkte im Cache",
|
||||||
|
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
|
||||||
|
"invalid_quantity": "Ungültige Menge"
|
||||||
},
|
},
|
||||||
|
"confirm_placeholder_search": null,
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||||
"kiosk_exit": "Kioskmodus verlassen?",
|
"kiosk_exit": "Kioskmodus verlassen?",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"proceed": "Bestätigen"
|
"proceed": "Bestätigen",
|
||||||
|
"discard_one": "1 Stück wegwerfen"
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"dispensa": "Vorratskammer",
|
"dispensa": "Vorratskammer",
|
||||||
@@ -894,7 +1199,8 @@
|
|||||||
"unknown_hint": "Produktname und Informationen eingeben",
|
"unknown_hint": "Produktname und Informationen eingeben",
|
||||||
"label_name": "🏷️ Produktname",
|
"label_name": "🏷️ Produktname",
|
||||||
"choose_location_title": "Welchen Ort?",
|
"choose_location_title": "Welchen Ort?",
|
||||||
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
|
"choose_location_hint": "Wähle den zu bearbeitenden Ort:",
|
||||||
|
"confirm_large_qty": "Du setzt die Menge auf {qty} {unit}. Das scheint ungewöhnlich hoch zu sein. Bestätigen?"
|
||||||
},
|
},
|
||||||
"screensaver": {
|
"screensaver": {
|
||||||
"recipe_btn": "Rezepte",
|
"recipe_btn": "Rezepte",
|
||||||
@@ -974,7 +1280,10 @@
|
|||||||
"retake_btn": "🔄 Erneut aufnehmen",
|
"retake_btn": "🔄 Erneut aufnehmen",
|
||||||
"camera_error_hint": "Stelle sicher, dass du HTTPS verwendest und Kameraberechtigungen erteilt hast.<br>Du kannst den Barcode manuell eingeben oder die KI-Identifikation verwenden.",
|
"camera_error_hint": "Stelle sicher, dass du HTTPS verwendest und Kameraberechtigungen erteilt hast.<br>Du kannst den Barcode manuell eingeben oder die KI-Identifikation verwenden.",
|
||||||
"no_barcode": "Kein Barcode",
|
"no_barcode": "Kein Barcode",
|
||||||
"save_new_btn": "🆕 Keines davon — als neu speichern"
|
"save_new_btn": "🆕 Keines davon — als neu speichern",
|
||||||
|
"expiry_found": "Datum gefunden",
|
||||||
|
"expiry_read_fail": "Datum konnte nicht gelesen werden.",
|
||||||
|
"expiry_raw_label": "Erkannt"
|
||||||
},
|
},
|
||||||
"lowstock": {
|
"lowstock": {
|
||||||
"title": "⚠️ Wird knapp!",
|
"title": "⚠️ Wird knapp!",
|
||||||
@@ -991,8 +1300,9 @@
|
|||||||
"thing_rest": "den Rest",
|
"thing_rest": "den Rest",
|
||||||
"stay_btn": "Nein, bleibt in {location}",
|
"stay_btn": "Nein, bleibt in {location}",
|
||||||
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
||||||
"vacuum_restore": "🫙 Vakuum wiederherstellen",
|
"vacuum_restore": "Vakuum wiederherstellen",
|
||||||
"vacuum_seal_rest": "🔒 Rest vakuumieren"
|
"vacuum_seal_rest": "Rest vakuumieren",
|
||||||
|
"moved_simple": "📦 Nach {location} verschoben"
|
||||||
},
|
},
|
||||||
"nova": {
|
"nova": {
|
||||||
"1": "Unverarbeitet",
|
"1": "Unverarbeitet",
|
||||||
@@ -1045,7 +1355,13 @@
|
|||||||
"source": "Basierend auf {n} Produkten in deiner Vorratskammer · EverShelf",
|
"source": "Basierend auf {n} Produkten in deiner Vorratskammer · EverShelf",
|
||||||
"products_count": "Produkte",
|
"products_count": "Produkte",
|
||||||
"today_title": "🥗 Deine Vorratskammer heute",
|
"today_title": "🥗 Deine Vorratskammer heute",
|
||||||
"products_n": "{n} Produkte"
|
"products_n": "{n} Produkte",
|
||||||
|
"macros_title": "Geschätzte Makronährstoffe",
|
||||||
|
"macros_proteins": "Proteine",
|
||||||
|
"macros_carbs": "Kohlenhydrate",
|
||||||
|
"macros_fat": "Fett",
|
||||||
|
"macros_fiber": "Ballaststoffe",
|
||||||
|
"macros_source": "Schätzung basierend auf {n} Vorratsprodukten"
|
||||||
},
|
},
|
||||||
"facts": {
|
"facts": {
|
||||||
"greeting_morning": "Guten Morgen",
|
"greeting_morning": "Guten Morgen",
|
||||||
@@ -1178,5 +1494,73 @@
|
|||||||
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
|
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
|
||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
"github": "GitHub-Repository"
|
"github": "GitHub-Repository"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "Inventar exportieren",
|
||||||
|
"hint": "Lade das aktuelle Inventar als CSV herunter oder öffne die druckfertige Version (PDF).",
|
||||||
|
"btn_csv": "CSV herunterladen",
|
||||||
|
"btn_pdf": "PDF / Drucken",
|
||||||
|
"btn_title": "Exportieren"
|
||||||
|
},
|
||||||
|
"startup": {
|
||||||
|
"connecting": "Serververbindung wird hergestellt...",
|
||||||
|
"check_php_memory": "PHP-Speicher",
|
||||||
|
"check_php_timeout": "PHP-Timeout",
|
||||||
|
"check_php_upload": "PHP-Upload",
|
||||||
|
"check_data_dir": "Datenverzeichnis",
|
||||||
|
"check_rate_limits": "Rate-Limits-Verzeichnis",
|
||||||
|
"check_backups": "Backup-Verzeichnis",
|
||||||
|
"check_write_test": "Schreibtest",
|
||||||
|
"check_disk_space": "Speicherplatz",
|
||||||
|
"check_db_legacy": "Legacy-DB (dispensa.db)",
|
||||||
|
"check_db_connect": "Datenbankverbindung",
|
||||||
|
"check_db_tables": "Datenbanktabellen",
|
||||||
|
"check_db_integrity": "Datenbankintegrität",
|
||||||
|
"check_db_wal": "WAL-Modus",
|
||||||
|
"check_db_size": "Datenbankgröße",
|
||||||
|
"check_db_rows": "Inventardaten",
|
||||||
|
"check_env": ".env-Datei",
|
||||||
|
"check_gemini": "Gemini-AI-Schlüssel",
|
||||||
|
"check_bring_creds": "Bring!-Anmeldedaten",
|
||||||
|
"check_bring_token": "Bring!-Token",
|
||||||
|
"check_tts": "Text-to-Speech-URL",
|
||||||
|
"check_scale": "Waagen-Gateway",
|
||||||
|
"check_curl_ssl": "cURL-SSL",
|
||||||
|
"check_internet": "Internetverbindung",
|
||||||
|
"fresh_install": "Neuinstallation",
|
||||||
|
"warnings_found": "Warnungen",
|
||||||
|
"all_ok": "System OK",
|
||||||
|
"critical_error_short": "Kritischer Fehler",
|
||||||
|
"critical_error": "Kritischer Fehler: Die App kann nicht gestartet werden. Prüfe die Serverlogs.",
|
||||||
|
"critical_error_intro": "Die App kann aufgrund folgender Probleme nicht gestartet werden:",
|
||||||
|
"error_network": "Server nicht erreichbar.",
|
||||||
|
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"syncing_local": "Lokale Daten synchronisieren...",
|
||||||
|
"sync_done": "Lokale Daten aktualisiert",
|
||||||
|
"token_required": "API-Token erforderlich",
|
||||||
|
"token_autoconfig": "Zugriff wird konfiguriert...",
|
||||||
|
"token_prompt_title": "🔒 API-Token",
|
||||||
|
"token_prompt_hint": "Geben Sie den API_TOKEN-Wert aus der .env-Datei des Servers ein.",
|
||||||
|
"token_prompt_btn": "Weiter"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Monatsstatistik",
|
||||||
|
"consumed": "Produkte verbraucht",
|
||||||
|
"trend_up": "+{pct}% vs. {prev}",
|
||||||
|
"trend_down": "-{pct}% vs. {prev}",
|
||||||
|
"trend_same": "gleiches Tempo wie letzten Monat",
|
||||||
|
"added": "hinzugefügt",
|
||||||
|
"wasted": "verschwendet",
|
||||||
|
"top_used": "meistbenutzt",
|
||||||
|
"top_cats": "Hauptkategorien",
|
||||||
|
"source": "Transaktionsverlauf · aktueller Monat"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"just_now": "gerade eben",
|
||||||
|
"seconds_ago": "vor {n}s",
|
||||||
|
"minutes_ago": "vor {n} min",
|
||||||
|
"hours_ago": "vor {n} h",
|
||||||
|
"days_ago": "vor {n} T"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+412
-24
@@ -32,6 +32,7 @@
|
|||||||
"reset_default": "↺ Reset to default",
|
"reset_default": "↺ Reset to default",
|
||||||
"save_info": "💾 Save information",
|
"save_info": "💾 Save information",
|
||||||
"retry": "🔄 Retry",
|
"retry": "🔄 Retry",
|
||||||
|
"next": "Next →",
|
||||||
"yes_short": "Yes",
|
"yes_short": "Yes",
|
||||||
"no_short": "No"
|
"no_short": "No"
|
||||||
},
|
},
|
||||||
@@ -113,6 +114,8 @@
|
|||||||
"banner_expired_action_finished": "I finished it!",
|
"banner_expired_action_finished": "I finished it!",
|
||||||
"banner_expired_action_throw": "I threw it away",
|
"banner_expired_action_throw": "I threw it away",
|
||||||
"banner_expired_action_edit": "Fix date",
|
"banner_expired_action_edit": "Fix date",
|
||||||
|
"banner_expired_action_modify": "Edit",
|
||||||
|
"banner_expired_action_vacuum": "Put in vacuum seal",
|
||||||
"banner_anomaly_action_edit": "Fix inventory",
|
"banner_anomaly_action_edit": "Fix inventory",
|
||||||
"banner_anomaly_action_dismiss": "Quantity is correct",
|
"banner_anomaly_action_dismiss": "Quantity is correct",
|
||||||
"banner_no_expiry_title": "Missing expiry: {name}",
|
"banner_no_expiry_title": "Missing expiry: {name}",
|
||||||
@@ -141,14 +144,22 @@
|
|||||||
"banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
|
"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_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_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_expected": "According to records you should still have {qty} {unit}.",
|
||||||
"banner_finished_check": "Can you check?",
|
"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_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_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?",
|
||||||
"banner_anomaly_untracked_title": "stock not recorded as an entry",
|
"banner_anomaly_untracked_title": "stock not recorded as an entry",
|
||||||
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
||||||
"banner_anomaly_ghost_title": "you have less stock than expected",
|
"banner_anomaly_ghost_title": "you have less stock than expected",
|
||||||
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
||||||
|
"banner_dup_loss_title": "Double-consume check: {name}",
|
||||||
|
"banner_dup_loss_detail": "Possible duplicate entry in {location}: two close out events ({qty_pair}) in ~{seconds}s. Please verify and fix if needed.",
|
||||||
|
"banner_dup_loss_action_fix": "Fix quantity",
|
||||||
|
"banner_dup_loss_action_open": "Open product card",
|
||||||
|
"banner_dup_loss_action_done": "Already checked",
|
||||||
|
"banner_dup_loss_toast_done": "Check marked as reviewed",
|
||||||
"consumed": "Consumed: {n} ({pct}%)",
|
"consumed": "Consumed: {n} ({pct}%)",
|
||||||
"wasted": "Wasted: {n} ({pct}%)",
|
"wasted": "Wasted: {n} ({pct}%)",
|
||||||
"more_opened": "and {n} more opened...",
|
"more_opened": "and {n} more opened...",
|
||||||
@@ -156,7 +167,11 @@
|
|||||||
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
|
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
|
||||||
"banner_explain_title": "Ask Gemini for an explanation",
|
"banner_explain_title": "Ask Gemini for an explanation",
|
||||||
"banner_explain_btn": "Explain",
|
"banner_explain_btn": "Explain",
|
||||||
"banner_analyzing": "🤖 Analyzing…"
|
"banner_analyzing": "🤖 Analyzing…",
|
||||||
|
"banner_prediction_confirmed": "✅ Confirmed — forecasts will recalculate from your next entries",
|
||||||
|
"banner_anomaly_explain_fail": "Could not get AI explanation",
|
||||||
|
"banner_anomaly_dismissed": "Anomaly dismissed",
|
||||||
|
"banner_finished_restore_prompt": "How many {unit} of {name} do you still have? (system estimate: {qty})"
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"title": "Pantry",
|
"title": "Pantry",
|
||||||
@@ -185,6 +200,7 @@
|
|||||||
"mode_shopping": "🛒 Shopping Mode",
|
"mode_shopping": "🛒 Shopping Mode",
|
||||||
"mode_shopping_end": "✅ End shopping",
|
"mode_shopping_end": "✅ End shopping",
|
||||||
"spesa_btn": "🛒 Shopping",
|
"spesa_btn": "🛒 Shopping",
|
||||||
|
"spesa_camera_hint": "Point the camera at the barcode. No barcode? Tap «Identify with AI» below.",
|
||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"tab_barcode": "Barcode",
|
"tab_barcode": "Barcode",
|
||||||
"tab_name": "Name",
|
"tab_name": "Name",
|
||||||
@@ -211,13 +227,53 @@
|
|||||||
"barcode_acquired": "🔖 Barcode scanned: {code}",
|
"barcode_acquired": "🔖 Barcode scanned: {code}",
|
||||||
"scan_barcode": "🔖 Scan Barcode",
|
"scan_barcode": "🔖 Scan Barcode",
|
||||||
"create_named": "Create {name}",
|
"create_named": "Create {name}",
|
||||||
"new_without_barcode": "New product without barcode"
|
"new_without_barcode": "New product without barcode",
|
||||||
|
"stock_in_pantry": "Already in pantry:",
|
||||||
|
"status_ready": "Point camera at barcode",
|
||||||
|
"status_scanning": "Scanning...",
|
||||||
|
"status_partial": "Detected: {code} — verifying...",
|
||||||
|
"status_invalid": "Invalid: {code} — retrying",
|
||||||
|
"status_confirmed": "Confirmed!",
|
||||||
|
"status_parallel": "Using combined scan methods...",
|
||||||
|
"status_ocr_searching": "Reading the barcode digits...",
|
||||||
|
"status_digit_ocr": "Reading numbers below the barcode...",
|
||||||
|
"status_ai_visual_searching": "Now trying to recognize the product...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"method_local_ocr": "Digit OCR",
|
||||||
|
"method_zbar": "ZBar",
|
||||||
|
"local_ocr_found": "Code from digits: {code}",
|
||||||
|
"ai_fallback_searching": "AI identifying product...",
|
||||||
|
"ai_fallback_found": "Product identified by AI",
|
||||||
|
"ai_fallback_not_found": "AI: product not recognized",
|
||||||
|
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
|
||||||
|
"ai_overlay_label": "Gemini Vision",
|
||||||
|
"ai_overlay_msg": "Gemini Vision is analyzing the product...",
|
||||||
|
"ai_retry_btn": "Retry with AI",
|
||||||
|
"ai_manual_btn": "🤖 Identify with AI",
|
||||||
|
"ai_not_recognized": "AI could not recognize the product. Try again or add manually.",
|
||||||
|
"ai_match_title": "Product recognized by AI",
|
||||||
|
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
|
||||||
|
"ai_match_existing": "Currently in pantry",
|
||||||
|
"ai_match_finished": "Finished / depleted",
|
||||||
|
"ai_match_catalog": "In catalog (no stock)",
|
||||||
|
"ai_match_finished_badge": "depleted",
|
||||||
|
"ai_match_finished_hint": "Finished product — restock the quantity",
|
||||||
|
"ai_match_merged_existing": "Linked to an existing catalog product",
|
||||||
|
"ai_match_none": "No similar products found — you can create a new one.",
|
||||||
|
"ai_match_use_btn": "Use",
|
||||||
|
"ai_match_create_btn": "➕ Create new: {name}",
|
||||||
|
"ai_match_add_btn": "Add {name}",
|
||||||
|
"ai_match_action_hint": "Tap the green button to add this product",
|
||||||
|
"ai_match_or_similar": "Or pick a similar product:",
|
||||||
|
"ai_detected_label": "AI detected",
|
||||||
|
"mode_shopping_activated": "🛒 Shopping mode activated!"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "What do you want to do?",
|
"title": "What do you want to do?",
|
||||||
"add_btn": "📥 ADD",
|
"add_btn": "📥 ADD",
|
||||||
"add_sub": "to pantry/fridge",
|
"add_sub": "to pantry/fridge",
|
||||||
"use_btn": "📤 USE / CONSUME",
|
"use_btn": "USE",
|
||||||
"use_sub": "from pantry/fridge",
|
"use_sub": "from pantry/fridge",
|
||||||
"have_title": "📦 Already in stock!",
|
"have_title": "📦 Already in stock!",
|
||||||
"add_more_sub": "add more",
|
"add_more_sub": "add more",
|
||||||
@@ -225,7 +281,8 @@
|
|||||||
"throw_btn": "🗑️ DISCARD",
|
"throw_btn": "🗑️ DISCARD",
|
||||||
"throw_sub": "throw away",
|
"throw_sub": "throw away",
|
||||||
"edit_sub": "expiry, location…",
|
"edit_sub": "expiry, location…",
|
||||||
"create_recipe_btn": "Recipe"
|
"create_recipe_btn": "Recipe",
|
||||||
|
"related_stock_title": "Also at home"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"title": "Add to Pantry",
|
"title": "Add to Pantry",
|
||||||
@@ -249,8 +306,9 @@
|
|||||||
"hint_modify": "📝 You can change the date or scan it with the camera",
|
"hint_modify": "📝 You can change the date or scan it with the camera",
|
||||||
"scan_expiry_title": "📷 Scan Expiry Date",
|
"scan_expiry_title": "📷 Scan Expiry Date",
|
||||||
"product_added": "✅ {name} added!{qty}",
|
"product_added": "✅ {name} added!{qty}",
|
||||||
|
"duplicate_recent_confirm": "You just added \"{name}\" ({when}).\n\nQuantity is already {total}.\n\nIncrease it by {qty}?",
|
||||||
"suffix_freezer_vacuum": "(freezer + vacuum sealed)",
|
"suffix_freezer_vacuum": "(freezer + vacuum sealed)",
|
||||||
"history_badge_tip": "Average from {n} previous entries",
|
"history_badge_tip": "Average of the last {n} entries — updates with each new purchase",
|
||||||
"vacuum_question": "Vacuum sealed?",
|
"vacuum_question": "Vacuum sealed?",
|
||||||
"vacuum_saved": "🔒 Vacuum sealed!"
|
"vacuum_saved": "🔒 Vacuum sealed!"
|
||||||
},
|
},
|
||||||
@@ -283,14 +341,17 @@
|
|||||||
"toast_bring": "🛒 Product finished → added to Bring!",
|
"toast_bring": "🛒 Product finished → added to Bring!",
|
||||||
"toast_opened_finished": "🔓 Opened package of {name} finished!",
|
"toast_opened_finished": "🔓 Opened package of {name} finished!",
|
||||||
"disambiguation_hint": "What do you mean by \"all done\"?",
|
"disambiguation_hint": "What do you mean by \"all done\"?",
|
||||||
|
"disambiguation_one_conf": "Finished <strong>1 package</strong> ({qty})",
|
||||||
"disambiguation_all": "🗑️ Finish EVERYTHING ({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!",
|
"error_exceeds_stock": "⚠️ You cannot use more than you have available!",
|
||||||
"use_all_confirm_title": "✅ Finish everything",
|
"use_all_confirm_title": "✅ Finish everything",
|
||||||
"use_all_confirm_msg": "Confirm that you have finished the product:",
|
"use_all_confirm_msg": "Confirm that you have finished the product:",
|
||||||
"use_all_confirm_btn": "✅ Yes, finished",
|
"use_all_confirm_btn": "✅ Yes, finished",
|
||||||
"throw_all_confirm_title": "🗑️ Discard everything",
|
"throw_all_confirm_title": "🗑️ Discard everything",
|
||||||
"throw_all_confirm_msg": "Do you really want to throw away the whole product?",
|
"throw_all_confirm_msg": "Do you really want to throw away the whole product?",
|
||||||
"throw_all_confirm_btn": "🗑️ Yes, discard"
|
"throw_all_confirm_btn": "🗑️ Yes, discard",
|
||||||
|
"locations_short": "places"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title_new": "New Product",
|
"title_new": "New Product",
|
||||||
@@ -300,6 +361,8 @@
|
|||||||
"name_label": "🏷️ Product Name *",
|
"name_label": "🏷️ Product Name *",
|
||||||
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
|
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
|
||||||
"brand_label": "🏢 Brand",
|
"brand_label": "🏢 Brand",
|
||||||
|
"allergens_label": "Allergens:",
|
||||||
|
"ingredients_summary": "📋 Ingredients",
|
||||||
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
|
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
|
||||||
"category_label": "📂 Category",
|
"category_label": "📂 Category",
|
||||||
"unit_label": "📏 Unit of measure",
|
"unit_label": "📏 Unit of measure",
|
||||||
@@ -330,7 +393,9 @@
|
|||||||
"weight_label": "Weight",
|
"weight_label": "Weight",
|
||||||
"origin_label": "Origin",
|
"origin_label": "Origin",
|
||||||
"labels_label": "Labels",
|
"labels_label": "Labels",
|
||||||
"select_variant": "Select the exact variant or use AI data:"
|
"select_variant": "Select the exact variant or use AI data:",
|
||||||
|
"history_badge": "📊 history",
|
||||||
|
"from_history": " (last 3 avg)"
|
||||||
},
|
},
|
||||||
"products": {
|
"products": {
|
||||||
"title": "📦 All Products",
|
"title": "📦 All Products",
|
||||||
@@ -357,12 +422,21 @@
|
|||||||
"loading_msg": "Preparing your recipe...",
|
"loading_msg": "Preparing your recipe...",
|
||||||
"start_cooking": "👨🍳 Cooking Mode",
|
"start_cooking": "👨🍳 Cooking Mode",
|
||||||
"regenerate": "🔄 Generate another one",
|
"regenerate": "🔄 Generate another one",
|
||||||
|
"regen_choice_title": "What do you want to do with this recipe?",
|
||||||
|
"regen_replace": "🔄 Generate another (discard this one)",
|
||||||
|
"regen_save_new": "💾 Save to archive & generate a new one",
|
||||||
"close_btn": "✅ Close",
|
"close_btn": "✅ Close",
|
||||||
"ingredients_title": "🧾 Ingredients",
|
"ingredients_title": "🧾 Ingredients",
|
||||||
|
"shopping_suggestions_intro": "For an alternative version you'd need (not in pantry — optional):",
|
||||||
|
"shopping_suggestions_add": "Add to shopping list",
|
||||||
|
"shopping_suggestions_added": "Added to shopping list",
|
||||||
|
"unit_for_input": "Unit of measure",
|
||||||
|
"enter_in": "Enter value in",
|
||||||
"tools_title": "Equipment needed",
|
"tools_title": "Equipment needed",
|
||||||
"steps_title": "👨🍳 Steps",
|
"steps_title": "👨🍳 Steps",
|
||||||
"no_steps": "No steps available",
|
"no_steps": "No steps available",
|
||||||
"generate_error": "Generation error",
|
"generate_error": "Generation error",
|
||||||
|
"stream_interrupted": "Generation interrupted (incomplete server response). Check logs or try again.",
|
||||||
"persons_short": "serv.",
|
"persons_short": "serv.",
|
||||||
"use_ingredient_title": "Use ingredient",
|
"use_ingredient_title": "Use ingredient",
|
||||||
"recipe_qty_label": "Recipe",
|
"recipe_qty_label": "Recipe",
|
||||||
@@ -376,7 +450,21 @@
|
|||||||
"scale_wait_stable": "Wait 10s of stable weight for auto-fill…",
|
"scale_wait_stable": "Wait 10s of stable weight for auto-fill…",
|
||||||
"ingredient_scaled_toast": "📦 Ingredient deducted from pantry!",
|
"ingredient_scaled_toast": "📦 Ingredient deducted from pantry!",
|
||||||
"finished_added_bring_toast": "🛒 Finished product → added to Bring!",
|
"finished_added_bring_toast": "🛒 Finished product → added to Bring!",
|
||||||
"load_error": "Loading error"
|
"load_error": "Loading error",
|
||||||
|
"favorite": "Add to favourites",
|
||||||
|
"unfavorite": "Remove from favourites",
|
||||||
|
"adjust_persons": "Persons",
|
||||||
|
"nutrition_title": "Nutritional values (per serving)",
|
||||||
|
"nutrition_kcal": "Calories",
|
||||||
|
"nutrition_protein": "Protein",
|
||||||
|
"nutrition_carbs": "Carbs",
|
||||||
|
"nutrition_fat": "Fat",
|
||||||
|
"nutrition_per_serving": "Estimated values per serving",
|
||||||
|
"storage_title": "How to store leftovers",
|
||||||
|
"storage_days": "{n} days",
|
||||||
|
"storage_immediately": "Best eaten immediately",
|
||||||
|
"ing_stock_line": "You have {have} · {remain} left after use",
|
||||||
|
"ing_use_all_note": "use all (<5% of full package left)"
|
||||||
},
|
},
|
||||||
"shopping": {
|
"shopping": {
|
||||||
"title": "🛒 Shopping List",
|
"title": "🛒 Shopping List",
|
||||||
@@ -436,6 +524,18 @@
|
|||||||
"item_removed": "✅ {name} removed from list!",
|
"item_removed": "✅ {name} removed from list!",
|
||||||
"urgency_spec_critical": "⚡ Urgent",
|
"urgency_spec_critical": "⚡ Urgent",
|
||||||
"urgency_spec_high": "🟠 Soon",
|
"urgency_spec_high": "🟠 Soon",
|
||||||
|
"urgency_spec_medium": "🟡 Soon",
|
||||||
|
"urgency_spec_low": "🔵 Forecast",
|
||||||
|
"family_sibling_title": "Similar in {location}",
|
||||||
|
"family_sibling_check": "Check: {name}",
|
||||||
|
"family_sibling_stock": "You should have: {qty}",
|
||||||
|
"family_sibling_location": "Location: {location}",
|
||||||
|
"family_sibling_qty": "Quantity: {qty}",
|
||||||
|
"family_sibling_purchased": "Purchased on {date}",
|
||||||
|
"family_sibling_question": "Is the quantity still correct?",
|
||||||
|
"family_sibling_prompt": "You also have {name}: {qty} in stock. Confirm the quantity?",
|
||||||
|
"family_sibling_yes": "Yes, all good",
|
||||||
|
"family_sibling_no": "No, update",
|
||||||
"bring_add_n": "Add {n} to Bring!",
|
"bring_add_n": "Add {n} to Bring!",
|
||||||
"bring_add_selected": "Add selected to Bring!",
|
"bring_add_selected": "Add selected to Bring!",
|
||||||
"bring_adding": "Adding...",
|
"bring_adding": "Adding...",
|
||||||
@@ -463,6 +563,7 @@
|
|||||||
"remove_error": "Removal error",
|
"remove_error": "Removal error",
|
||||||
"btn_fetch_prices": "Find prices",
|
"btn_fetch_prices": "Find prices",
|
||||||
"price_total_label": "💰 Estimated total:",
|
"price_total_label": "💰 Estimated total:",
|
||||||
|
"price_total_short": "estimated total",
|
||||||
"price_loading": "Looking up prices…",
|
"price_loading": "Looking up prices…",
|
||||||
"price_not_found": "price n/a",
|
"price_not_found": "price n/a",
|
||||||
"suggest_loading": "Analyzing...",
|
"suggest_loading": "Analyzing...",
|
||||||
@@ -471,7 +572,9 @@
|
|||||||
"priority_medium": "Medium",
|
"priority_medium": "Medium",
|
||||||
"priority_low": "Low",
|
"priority_low": "Low",
|
||||||
"smart_last_update": "Updated {time}",
|
"smart_last_update": "Updated {time}",
|
||||||
"names_already_updated": "All names are already up to date"
|
"names_already_updated": "All names are already up to date",
|
||||||
|
"pantry_hint": "Already at home: {qty}",
|
||||||
|
"bring_names_migrated": "🔄 {n} names generalized in Bring!"
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"title": "🤖 AI Identification",
|
"title": "🤖 AI Identification",
|
||||||
@@ -482,7 +585,8 @@
|
|||||||
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
|
"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",
|
"fields_filled": "✅ Fields filled by AI",
|
||||||
"use_data": "✅ Use AI data",
|
"use_data": "✅ Use AI data",
|
||||||
"use_data_no_barcode": "✅ Use AI data (no barcode)"
|
"use_data_no_barcode": "✅ Use AI data (no barcode)",
|
||||||
|
"conservation_hint": "🤖 AI: store in {location}"
|
||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"title": "📒 Operations Log",
|
"title": "📒 Operations Log",
|
||||||
@@ -533,14 +637,16 @@
|
|||||||
"prev": "◀ Previous",
|
"prev": "◀ Previous",
|
||||||
"next": "Next ▶",
|
"next": "Next ▶",
|
||||||
"ingredient_used": "✔️ Deducted",
|
"ingredient_used": "✔️ Deducted",
|
||||||
"ingredient_use_btn": "📦 Use",
|
"ingredient_use_btn": "Use",
|
||||||
"ingredient_deduct_title": "Deduct from pantry",
|
"ingredient_deduct_title": "Deduct from pantry",
|
||||||
"timer_expired_tts": "Timer {label} expired!",
|
"timer_expired_tts": "Timer {label} expired!",
|
||||||
"timer_warning_tts": "Heads up! {label}: 10 seconds left!",
|
"timer_warning_tts": "Heads up! {label}: 10 seconds left!",
|
||||||
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
|
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
|
||||||
"expires_chip": "exp. {date}",
|
"expires_chip": "exp. {date}",
|
||||||
"finish": "✅ Finish",
|
"finish": "✅ Finish",
|
||||||
"step_fallback": "Step {n}"
|
"step_fallback": "Step {n}",
|
||||||
|
"zerowaste_label": "♻️ Scrap",
|
||||||
|
"zerowaste_tip_title": "Zero-waste tip"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "⚙️ Settings",
|
"title": "⚙️ Settings",
|
||||||
@@ -644,7 +750,10 @@
|
|||||||
"back": "📱 Rear (default)",
|
"back": "📱 Rear (default)",
|
||||||
"front": "🤳 Front",
|
"front": "🤳 Front",
|
||||||
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
||||||
"detect_btn": "🔄 Detect cameras"
|
"detect_btn": "🔄 Detect cameras",
|
||||||
|
"ai_fallback_label": "AI visual identification (5s fallback)",
|
||||||
|
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured.",
|
||||||
|
"ai_manual_hint": "If the barcode cannot be read, use the «Identify with AI» button below the camera. Requires Gemini configured."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 HTTPS Certificate",
|
"title": "🔒 HTTPS Certificate",
|
||||||
@@ -682,6 +791,7 @@
|
|||||||
"extra_fields_label": "➕ Extra fields (JSON)",
|
"extra_fields_label": "➕ Extra fields (JSON)",
|
||||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||||
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
|
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
|
||||||
|
"test_sound_btn": "🔔 Run Sound Test",
|
||||||
"test_btn": "🔊 Send Test Voice",
|
"test_btn": "🔊 Send Test Voice",
|
||||||
"voices_loading": "Loading voices…",
|
"voices_loading": "Loading voices…",
|
||||||
"voice_not_supported": "Voice not supported by this browser",
|
"voice_not_supported": "Voice not supported by this browser",
|
||||||
@@ -689,7 +799,12 @@
|
|||||||
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
|
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
|
||||||
"url_missing": "⚠️ Endpoint URL missing.",
|
"url_missing": "⚠️ Endpoint URL missing.",
|
||||||
"test_sending": "⏳ Sending…",
|
"test_sending": "⏳ Sending…",
|
||||||
"test_ok": "✅ Response {code} — check that the speaker has spoken."
|
"test_ok": "✅ Response {code} — check that the speaker has spoken.",
|
||||||
|
"heard_question": "Did you hear the voice?",
|
||||||
|
"heard_yes": "Yes, I heard it",
|
||||||
|
"heard_no": "No, I didn't hear it",
|
||||||
|
"test_ok_kiosk": "TTS is working.",
|
||||||
|
"test_fail_steps": "Check: 1) media volume is not 0; 2) Google Text-to-Speech is installed and updated; 3) Italian voice package is downloaded in Android TTS settings."
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "🌐 Language",
|
"title": "🌐 Language",
|
||||||
@@ -727,7 +842,13 @@
|
|||||||
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
|
"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_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",
|
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
|
||||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — automatic heuristic for 100+ models</li></ul>"
|
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — automatic heuristic for 100+ models</li></ul>",
|
||||||
|
"discover_scanning": "🔍 Scanning local network for scale gateway…",
|
||||||
|
"discover_found": "✅ Gateway found: {url}{more}",
|
||||||
|
"discover_not_found": "❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.",
|
||||||
|
"discover_failed": "❌ Discovery failed: {error}",
|
||||||
|
"discover_auto": "🔍 Auto",
|
||||||
|
"unknown_device": "Unknown device"
|
||||||
},
|
},
|
||||||
"kiosk": {
|
"kiosk": {
|
||||||
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
||||||
@@ -744,7 +865,178 @@
|
|||||||
},
|
},
|
||||||
"saved": "✅ Configuration saved!",
|
"saved": "✅ Configuration saved!",
|
||||||
"saved_local": "✅ Configuration saved locally",
|
"saved_local": "✅ Configuration saved locally",
|
||||||
"saved_local_error": "⚠️ Saved locally, server error: {error}"
|
"saved_local_error": "⚠️ Saved locally, server error: {error}",
|
||||||
|
"theme": {
|
||||||
|
"title": "🌙 Appearance",
|
||||||
|
"hint": "Choose the interface theme.",
|
||||||
|
"label": "🌙 Theme",
|
||||||
|
"off": "☀️ Light",
|
||||||
|
"on": "🌙 Dark",
|
||||||
|
"auto": "🔄 Automatic (time of day)"
|
||||||
|
},
|
||||||
|
"zerowaste": {
|
||||||
|
"card_title": "♻️ Zero-waste tips",
|
||||||
|
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
|
||||||
|
"label": "Show tips during cooking"
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"tab": "Backup",
|
||||||
|
"local_title": "Local Backup",
|
||||||
|
"local_hint": "Daily database snapshot. Configure how many days of backups to keep.",
|
||||||
|
"enabled": "Enable daily automatic backup",
|
||||||
|
"retention_days": "Retention (days)",
|
||||||
|
"retention_info": "Backups are kept for",
|
||||||
|
"backup_now": "Backup Now",
|
||||||
|
"backing_up": "Backing up…",
|
||||||
|
"backed_up": "Backup complete",
|
||||||
|
"backup_error": "Backup error",
|
||||||
|
"last_backup": "Last backup",
|
||||||
|
"no_backup_yet": "No backup has been created yet",
|
||||||
|
"list_empty": "No backups available",
|
||||||
|
"restore_btn": "Restore",
|
||||||
|
"restore_confirm": "Restore backup",
|
||||||
|
"delete_btn": "Delete",
|
||||||
|
"delete_confirm": "Delete backup",
|
||||||
|
"gdrive_title": "Google Drive",
|
||||||
|
"gdrive_hint": "Automatically back up to Google Drive via OAuth 2.0. No external libraries required.",
|
||||||
|
"gdrive_enabled": "Enable Google Drive backup",
|
||||||
|
"gdrive_folder_id": "Drive Folder ID",
|
||||||
|
"gdrive_folder_id_hint": "Copy the ID from the Drive folder URL: …/folders/<strong>ID</strong>",
|
||||||
|
"gdrive_retention_days": "Drive retention (days, 0=keep all)",
|
||||||
|
"gdrive_test": "Test Connection",
|
||||||
|
"gdrive_ok": "Connection successful!",
|
||||||
|
"gdrive_error": "Connection failed",
|
||||||
|
"gdrive_push_now": "Upload to Drive Now",
|
||||||
|
"gdrive_pushing": "Uploading…",
|
||||||
|
"gdrive_pushed": "Uploaded to Drive",
|
||||||
|
"gdrive_wizard_hint": "Optional: automatically back up to Google Drive daily via OAuth 2.0.",
|
||||||
|
"gdrive_skip": "Skip — configure later in Settings",
|
||||||
|
"gdrive_client_id": "Client ID",
|
||||||
|
"gdrive_client_secret": "Client Secret",
|
||||||
|
"gdrive_redirect_uri_hint": "Add <strong>http://localhost</strong> as an authorized redirect URI in Google Cloud Console. This works on any server, even without a public domain.",
|
||||||
|
"gdrive_code_title": "Paste the authorization URL or code",
|
||||||
|
"gdrive_code_hint": "After authorizing, the browser will open http://localhost and may show a connection error — that is expected. Copy the URL from the address bar (e.g. <code>http://localhost/?code=4%2F0A...</code>) and paste it here.",
|
||||||
|
"gdrive_code_submit": "Submit",
|
||||||
|
"gdrive_code_empty": "Paste the URL or authorization code first",
|
||||||
|
"gdrive_redirect_uri_label": "Redirect URI (add this in Google Cloud Console):",
|
||||||
|
"gdrive_oauth_authorize": "Authorize with Google",
|
||||||
|
"gdrive_oauth_authorized": "Authorized",
|
||||||
|
"gdrive_oauth_not_authorized": "Not authorized yet",
|
||||||
|
"gdrive_oauth_window_opened": "Browser window opened — authorize and come back",
|
||||||
|
"gdrive_oauth_how_to": "How to set up OAuth 2.0 (step by step)",
|
||||||
|
"gdrive_oauth_steps": "<li>Go to <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> and select your project</li><li>Enable the <strong>Google Drive API</strong>: <em>APIs & Services → Enable APIs → Google Drive API</em></li><li>Go to <em>APIs & Services → Credentials → Create Credentials → OAuth client ID</em></li><li>Application type: <strong>Web application</strong>; add <strong>http://localhost</strong> as an <em>Authorized redirect URI</em></li><li>Copy the <strong>Client ID</strong> and <strong>Client Secret</strong> into the fields above and save</li><li>Click <strong>Authorize with Google</strong>, sign in and grant access</li><li>The browser will open <code>http://localhost</code> (a connection error is expected): copy the URL from the address bar and paste it in the field that appears below</li>"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"tab": "Info",
|
||||||
|
"ai_title": "Gemini AI — Token Usage",
|
||||||
|
"ai_hint": "Monthly consumption and estimated cost for the current API key.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"total_tokens": "Total tokens",
|
||||||
|
"est_cost": "Est. cost",
|
||||||
|
"input_tok": "Input tokens",
|
||||||
|
"output_tok": "Output tokens",
|
||||||
|
"ai_calls": "Calls",
|
||||||
|
"by_action": "Breakdown by function",
|
||||||
|
"by_model": "Breakdown by model",
|
||||||
|
"pricing_note": "Gemini reference pricing: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||||
|
"system_title": "System",
|
||||||
|
"db_size": "Database",
|
||||||
|
"log_size": "Logs",
|
||||||
|
"log_level": "Log level",
|
||||||
|
"ai_overview": "AI usage overview, inventory and system status",
|
||||||
|
"calls_unit": "calls",
|
||||||
|
"inv_title": "Inventory",
|
||||||
|
"inv_active": "Active",
|
||||||
|
"inv_products": "Total products",
|
||||||
|
"inv_expiring": "Expiring (7d)",
|
||||||
|
"inv_expired": "Expired",
|
||||||
|
"inv_finished": "Finished",
|
||||||
|
"act_title": "Monthly activity",
|
||||||
|
"act_tx_month": "Movements",
|
||||||
|
"act_restock": "Restocks",
|
||||||
|
"act_use": "Usages",
|
||||||
|
"act_new_products": "New products",
|
||||||
|
"act_tx_year": "Yearly movements",
|
||||||
|
"price_cache": "Price cache",
|
||||||
|
"cache_entries": "products",
|
||||||
|
"last_backup": "Last backup",
|
||||||
|
"bring_days": "token expires in {n} days",
|
||||||
|
"bring_expired": "token expired",
|
||||||
|
"year_label": "Year {year}",
|
||||||
|
"currency_title": "Currency",
|
||||||
|
"currency_hint": "The currency used for all costs and prices in the app."
|
||||||
|
},
|
||||||
|
"tab_general": "General",
|
||||||
|
"shopping": {
|
||||||
|
"tab": "Shopping list",
|
||||||
|
"title": "Shopping list",
|
||||||
|
"hint": "Configure the built-in shopping list or connect Bring!.",
|
||||||
|
"enable_label": "Enable shopping list",
|
||||||
|
"mode_label": "Provider",
|
||||||
|
"mode_internal": "Built-in (no Bring!)",
|
||||||
|
"mode_bring": "Bring! (external app)",
|
||||||
|
"bring_section_title": "Bring! configuration",
|
||||||
|
"ai_section_title": "AI assistance",
|
||||||
|
"smart_suggestions_label": "AI suggestions",
|
||||||
|
"forecast_label": "Forecast low-stock products",
|
||||||
|
"auto_add_label": "Auto-add to list when",
|
||||||
|
"auto_add_suffix": "remaining in stock (0 = only when empty)"
|
||||||
|
},
|
||||||
|
"ha": {
|
||||||
|
"tab": "Home Assistant",
|
||||||
|
"title": "Home Assistant",
|
||||||
|
"hint": "Connect EverShelf to Home Assistant for automations, push notifications and REST sensors.",
|
||||||
|
"enabled": "Enable Home Assistant integration",
|
||||||
|
"connection_title": "Connection",
|
||||||
|
"url_label": "Home Assistant URL",
|
||||||
|
"url_placeholder": "http://192.168.1.50:8123",
|
||||||
|
"url_hint": "Base URL of your Home Assistant instance (e.g. http://homeassistant.local:8123).",
|
||||||
|
"token_label": "Long-Lived Access Token",
|
||||||
|
"token_hint": "Generate from HA Profile → Security → Long-Lived Access Tokens.",
|
||||||
|
"token_placeholder": "eyJhbGci...",
|
||||||
|
"token_saved": "Token saved (hidden for security)",
|
||||||
|
"test_btn": "Test connection",
|
||||||
|
"test_ok": "Connected to {version}",
|
||||||
|
"test_fail": "Connection failed: {error}",
|
||||||
|
"test_bad_token": "HA reachable but token is invalid",
|
||||||
|
"testing": "Testing…",
|
||||||
|
"error_no_url": "Please enter the Home Assistant URL first.",
|
||||||
|
"tts_title": "TTS on Smart Speaker",
|
||||||
|
"tts_hint": "Read recipe steps aloud on a Home Assistant media player.",
|
||||||
|
"tts_entity_label": "Media player entity ID",
|
||||||
|
"tts_entity_placeholder": "media_player.living_room",
|
||||||
|
"tts_entity_hint": "Entity ID of the HA media player. Find it in HA: Developer Tools → States.",
|
||||||
|
"tts_platform_label": "TTS platform",
|
||||||
|
"tts_platform_speak": "tts.speak (recommended)",
|
||||||
|
"tts_platform_notify": "notify.* (notification service)",
|
||||||
|
"tts_apply_btn": "Apply HA preset to TTS tab",
|
||||||
|
"tts_apply_hint": "Pre-fills the TTS tab with the Home Assistant URL and token.",
|
||||||
|
"tts_preset_applied": "HA preset applied to TTS tab.",
|
||||||
|
"webhook_title": "Webhook Automations",
|
||||||
|
"webhook_hint": "Send data to Home Assistant when pantry events occur. Create an HA automation with a Webhook trigger and paste the generated ID here.",
|
||||||
|
"webhook_id_label": "Webhook ID",
|
||||||
|
"webhook_id_placeholder": "evershelf_webhook_abc123",
|
||||||
|
"webhook_id_hint": "ID of the webhook created in HA. Copy from: HA → Settings → Automations → Create → Webhook Trigger.",
|
||||||
|
"webhook_events_label": "Notify on these events",
|
||||||
|
"event_expiry": "Expiring products (daily)",
|
||||||
|
"event_shopping": "Item added to shopping list",
|
||||||
|
"event_stock": "Stock level updated",
|
||||||
|
"expiry_days_label": "Expiry lead time (days)",
|
||||||
|
"expiry_days_hint": "Send the expiry alert N days before the expiry date.",
|
||||||
|
"webhook_help": "In HA: Settings → Automations → Create automation → Trigger: Webhook → copy the generated ID above.",
|
||||||
|
"notify_title": "Push Notifications",
|
||||||
|
"notify_hint": "Send push notifications to your phone via a Home Assistant notify service.",
|
||||||
|
"notify_service_label": "Notify service",
|
||||||
|
"notify_service_placeholder": "notify.mobile_app_my_phone",
|
||||||
|
"notify_service_hint": "HA notify service name (e.g. notify.mobile_app_phone). Leave empty to disable.",
|
||||||
|
"sensor_title": "REST Sensors",
|
||||||
|
"sensor_hint": "Add to configuration.yaml to create EverShelf sensors in Home Assistant.",
|
||||||
|
"sensor_copy_btn": "Copy YAML",
|
||||||
|
"sensor_copied": "YAML copied to clipboard!",
|
||||||
|
"save_btn": "Save HA settings",
|
||||||
|
"ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors."
|
||||||
|
},
|
||||||
|
"kiosk_update_required": "⚠️ Update the kiosk app to use this feature"
|
||||||
},
|
},
|
||||||
"expiry": {
|
"expiry": {
|
||||||
"today": "TODAY",
|
"today": "TODAY",
|
||||||
@@ -816,7 +1108,9 @@
|
|||||||
"thrown_away": "🗑️ {name} thrown away!",
|
"thrown_away": "🗑️ {name} thrown away!",
|
||||||
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
|
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
|
||||||
"finished_all": "📤 {name} finished!",
|
"finished_all": "📤 {name} finished!",
|
||||||
|
"vacuum_sealed": "{name} saved as vacuum sealed",
|
||||||
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
||||||
|
"ghost_restored": "✅ {name}: restored {qty} {unit} to inventory",
|
||||||
"appliance_added": "Appliance added",
|
"appliance_added": "Appliance added",
|
||||||
"item_added": "{name} added"
|
"item_added": "{name} added"
|
||||||
},
|
},
|
||||||
@@ -866,6 +1160,7 @@
|
|||||||
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
||||||
"barcode_empty": "Enter a barcode",
|
"barcode_empty": "Enter a barcode",
|
||||||
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
||||||
|
"barcode_checksum": "Invalid EAN checksum — please check the barcode digits",
|
||||||
"min_chars": "Type at least 2 characters",
|
"min_chars": "Type at least 2 characters",
|
||||||
"not_in_inventory": "Product not in inventory",
|
"not_in_inventory": "Product not in inventory",
|
||||||
"appliance_exists": "Appliance already exists",
|
"appliance_exists": "Appliance already exists",
|
||||||
@@ -877,13 +1172,27 @@
|
|||||||
"server_retry": "Retry",
|
"server_retry": "Retry",
|
||||||
"unknown": "Unknown error",
|
"unknown": "Unknown error",
|
||||||
"prefix": "Error",
|
"prefix": "Error",
|
||||||
"no_inventory_entry": "No inventory entry found"
|
"no_inventory_entry": "No inventory entry found",
|
||||||
|
"offline_title": "No connection",
|
||||||
|
"offline_subtitle": "The app cannot reach the server. Check your Wi-Fi connection.",
|
||||||
|
"offline_checking": "Checking connection…",
|
||||||
|
"offline_restored": "Connection restored!",
|
||||||
|
"offline_continue": "Continue in offline mode",
|
||||||
|
"offline_reading_cache": "Reading from local cache",
|
||||||
|
"offline_ops_pending": "{n} operations pending",
|
||||||
|
"offline_synced": "{n} operations synced",
|
||||||
|
"offline_ai_disabled": "Not available offline",
|
||||||
|
"offline_cache_ready": "Offline — {n} items cached",
|
||||||
|
"copy_failed": "Copy to clipboard failed",
|
||||||
|
"invalid_quantity": "Invalid quantity"
|
||||||
},
|
},
|
||||||
|
"confirm_placeholder_search": null,
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"remove_item": "Do you really want to remove this product from inventory?",
|
"remove_item": "Do you really want to remove this product from inventory?",
|
||||||
"kiosk_exit": "Exit kiosk mode?",
|
"kiosk_exit": "Exit kiosk mode?",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"proceed": "Confirm"
|
"proceed": "Confirm",
|
||||||
|
"discard_one": "Discard 1 piece"
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"dispensa": "Pantry",
|
"dispensa": "Pantry",
|
||||||
@@ -895,7 +1204,8 @@
|
|||||||
"unknown_hint": "Enter the product name and information",
|
"unknown_hint": "Enter the product name and information",
|
||||||
"label_name": "🏷️ Product name",
|
"label_name": "🏷️ Product name",
|
||||||
"choose_location_title": "Which location?",
|
"choose_location_title": "Which location?",
|
||||||
"choose_location_hint": "Choose the location to edit:"
|
"choose_location_hint": "Choose the location to edit:",
|
||||||
|
"confirm_large_qty": "You are setting the quantity to {qty} {unit}. This seems unusually high. Confirm?"
|
||||||
},
|
},
|
||||||
"screensaver": {
|
"screensaver": {
|
||||||
"recipe_btn": "Recipes",
|
"recipe_btn": "Recipes",
|
||||||
@@ -975,7 +1285,10 @@
|
|||||||
"retake_btn": "🔄 Retake",
|
"retake_btn": "🔄 Retake",
|
||||||
"camera_error_hint": "Ensure you use HTTPS and have granted camera permissions.<br>You can enter the barcode manually or use AI identification.",
|
"camera_error_hint": "Ensure you use HTTPS and have granted camera permissions.<br>You can enter the barcode manually or use AI identification.",
|
||||||
"no_barcode": "No barcode",
|
"no_barcode": "No barcode",
|
||||||
"save_new_btn": "🆕 None of these — save as new"
|
"save_new_btn": "🆕 None of these — save as new",
|
||||||
|
"expiry_found": "Date found",
|
||||||
|
"expiry_read_fail": "Cannot read the date.",
|
||||||
|
"expiry_raw_label": "Read"
|
||||||
},
|
},
|
||||||
"lowstock": {
|
"lowstock": {
|
||||||
"title": "⚠️ Running low!",
|
"title": "⚠️ Running low!",
|
||||||
@@ -992,8 +1305,9 @@
|
|||||||
"thing_rest": "rest",
|
"thing_rest": "rest",
|
||||||
"stay_btn": "No, stay in {location}",
|
"stay_btn": "No, stay in {location}",
|
||||||
"moved_toast": "📦 Opened package moved to {location}",
|
"moved_toast": "📦 Opened package moved to {location}",
|
||||||
"vacuum_restore": "🫙 Restore vacuum sealed",
|
"vacuum_restore": "Restore vacuum sealed",
|
||||||
"vacuum_seal_rest": "🔒 Vacuum seal the rest"
|
"vacuum_seal_rest": "Vacuum seal the rest",
|
||||||
|
"moved_simple": "📦 Moved to {location}"
|
||||||
},
|
},
|
||||||
"nova": {
|
"nova": {
|
||||||
"1": "Unprocessed",
|
"1": "Unprocessed",
|
||||||
@@ -1046,7 +1360,13 @@
|
|||||||
"source": "Based on {n} products in your pantry · EverShelf",
|
"source": "Based on {n} products in your pantry · EverShelf",
|
||||||
"products_count": "products",
|
"products_count": "products",
|
||||||
"today_title": "🥗 Your pantry today",
|
"today_title": "🥗 Your pantry today",
|
||||||
"products_n": "{n} products"
|
"products_n": "{n} products",
|
||||||
|
"macros_title": "Estimated Macronutrients",
|
||||||
|
"macros_proteins": "Proteins",
|
||||||
|
"macros_carbs": "Carbohydrates",
|
||||||
|
"macros_fat": "Fat",
|
||||||
|
"macros_fiber": "Fibre",
|
||||||
|
"macros_source": "Estimate based on {n} pantry products"
|
||||||
},
|
},
|
||||||
"facts": {
|
"facts": {
|
||||||
"greeting_morning": "Good morning",
|
"greeting_morning": "Good morning",
|
||||||
@@ -1179,5 +1499,73 @@
|
|||||||
"report_bug_error": "Could not send the report. Check your connection.",
|
"report_bug_error": "Could not send the report. Check your connection.",
|
||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
"github": "GitHub Repository"
|
"github": "GitHub Repository"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "Export inventory",
|
||||||
|
"hint": "Download the current inventory as CSV or open a print-ready version (PDF).",
|
||||||
|
"btn_csv": "Download CSV",
|
||||||
|
"btn_pdf": "PDF / Print",
|
||||||
|
"btn_title": "Export"
|
||||||
|
},
|
||||||
|
"startup": {
|
||||||
|
"connecting": "Connecting to server...",
|
||||||
|
"check_php_memory": "PHP memory",
|
||||||
|
"check_php_timeout": "PHP timeout",
|
||||||
|
"check_php_upload": "PHP upload",
|
||||||
|
"check_data_dir": "Data directory",
|
||||||
|
"check_rate_limits": "Rate limits dir",
|
||||||
|
"check_backups": "Backup dir",
|
||||||
|
"check_write_test": "Disk write test",
|
||||||
|
"check_disk_space": "Disk space",
|
||||||
|
"check_db_legacy": "Legacy DB (dispensa.db)",
|
||||||
|
"check_db_connect": "Database connection",
|
||||||
|
"check_db_tables": "Database tables",
|
||||||
|
"check_db_integrity": "Database integrity",
|
||||||
|
"check_db_wal": "WAL mode",
|
||||||
|
"check_db_size": "Database size",
|
||||||
|
"check_db_rows": "Inventory data",
|
||||||
|
"check_env": ".env file",
|
||||||
|
"check_gemini": "Gemini AI key",
|
||||||
|
"check_bring_creds": "Bring! credentials",
|
||||||
|
"check_bring_token": "Bring! token",
|
||||||
|
"check_tts": "Text-to-Speech URL",
|
||||||
|
"check_scale": "Scale gateway",
|
||||||
|
"check_curl_ssl": "cURL SSL",
|
||||||
|
"check_internet": "Internet connection",
|
||||||
|
"fresh_install": "fresh install",
|
||||||
|
"warnings_found": "warnings found",
|
||||||
|
"all_ok": "System OK",
|
||||||
|
"critical_error_short": "Critical error",
|
||||||
|
"critical_error": "Critical error: the app cannot start. Check your server logs.",
|
||||||
|
"critical_error_intro": "The app cannot start due to the following issues:",
|
||||||
|
"error_network": "Cannot reach the server.",
|
||||||
|
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"syncing_local": "Syncing local data...",
|
||||||
|
"sync_done": "Local data synced",
|
||||||
|
"token_required": "API token required",
|
||||||
|
"token_autoconfig": "Configuring access...",
|
||||||
|
"token_prompt_title": "🔒 API Token",
|
||||||
|
"token_prompt_hint": "Enter the API_TOKEN value from the server .env file.",
|
||||||
|
"token_prompt_btn": "Continue"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Monthly Stats",
|
||||||
|
"consumed": "products used",
|
||||||
|
"trend_up": "+{pct}% vs {prev}",
|
||||||
|
"trend_down": "-{pct}% vs {prev}",
|
||||||
|
"trend_same": "same pace as last month",
|
||||||
|
"added": "added",
|
||||||
|
"wasted": "wasted",
|
||||||
|
"top_used": "top used",
|
||||||
|
"top_cats": "Top categories",
|
||||||
|
"source": "Transaction history · current month"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"just_now": "just now",
|
||||||
|
"seconds_ago": "{n}s ago",
|
||||||
|
"minutes_ago": "{n} min ago",
|
||||||
|
"hours_ago": "{n} h ago",
|
||||||
|
"days_ago": "{n} d ago"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+412
-24
@@ -32,6 +32,7 @@
|
|||||||
"reset_default": "↺ Ripristina default",
|
"reset_default": "↺ Ripristina default",
|
||||||
"save_info": "💾 Salva informazioni",
|
"save_info": "💾 Salva informazioni",
|
||||||
"retry": "🔄 Riprova",
|
"retry": "🔄 Riprova",
|
||||||
|
"next": "Avanti →",
|
||||||
"yes_short": "Sì",
|
"yes_short": "Sì",
|
||||||
"no_short": "No"
|
"no_short": "No"
|
||||||
},
|
},
|
||||||
@@ -113,6 +114,8 @@
|
|||||||
"banner_expired_action_finished": "L'ho finito!",
|
"banner_expired_action_finished": "L'ho finito!",
|
||||||
"banner_expired_action_throw": "L'ho buttato",
|
"banner_expired_action_throw": "L'ho buttato",
|
||||||
"banner_expired_action_edit": "Correggi data",
|
"banner_expired_action_edit": "Correggi data",
|
||||||
|
"banner_expired_action_modify": "Modifica",
|
||||||
|
"banner_expired_action_vacuum": "Metti sottovuoto",
|
||||||
"banner_anomaly_action_edit": "Correggi inventario",
|
"banner_anomaly_action_edit": "Correggi inventario",
|
||||||
"banner_anomaly_action_dismiss": "La quantità è giusta",
|
"banner_anomaly_action_dismiss": "La quantità è giusta",
|
||||||
"banner_no_expiry_title": "Scadenza mancante: {name}",
|
"banner_no_expiry_title": "Scadenza mancante: {name}",
|
||||||
@@ -141,14 +144,22 @@
|
|||||||
"banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
|
"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_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_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_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
|
||||||
"banner_finished_check": "Puoi controllare?",
|
"banner_finished_check": "Puoi controllare?",
|
||||||
|
"banner_finished_action_restore": "Ripristina {qty} {unit}",
|
||||||
"banner_anomaly_phantom_title": "hai più scorte del previsto",
|
"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_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?",
|
||||||
"banner_anomaly_untracked_title": "scorte non registrate come entrata",
|
"banner_anomaly_untracked_title": "scorte non registrate come entrata",
|
||||||
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
||||||
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
||||||
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
||||||
|
"banner_dup_loss_title": "Controllo doppio scarico: {name}",
|
||||||
|
"banner_dup_loss_detail": "Possibile doppia registrazione in {location}: due uscite ravvicinate ({qty_pair}) in ~{seconds}s. Verifica se va corretta.",
|
||||||
|
"banner_dup_loss_action_fix": "Correggi quantità",
|
||||||
|
"banner_dup_loss_action_open": "Apri scheda prodotto",
|
||||||
|
"banner_dup_loss_action_done": "Già verificato",
|
||||||
|
"banner_dup_loss_toast_done": "Controllo segnato come verificato",
|
||||||
"consumed": "Consumati: {n} ({pct}%)",
|
"consumed": "Consumati: {n} ({pct}%)",
|
||||||
"wasted": "Buttati: {n} ({pct}%)",
|
"wasted": "Buttati: {n} ({pct}%)",
|
||||||
"more_opened": "e altri {n} prodotti aperti...",
|
"more_opened": "e altri {n} prodotti aperti...",
|
||||||
@@ -156,7 +167,11 @@
|
|||||||
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
|
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
|
||||||
"banner_explain_title": "Chiedi a Gemini una spiegazione",
|
"banner_explain_title": "Chiedi a Gemini una spiegazione",
|
||||||
"banner_explain_btn": "Spiega",
|
"banner_explain_btn": "Spiega",
|
||||||
"banner_analyzing": "🤖 Analizzo…"
|
"banner_analyzing": "🤖 Analizzo…",
|
||||||
|
"banner_prediction_confirmed": "✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni",
|
||||||
|
"banner_anomaly_explain_fail": "Impossibile ottenere spiegazione AI",
|
||||||
|
"banner_anomaly_dismissed": "Anomalia ignorata",
|
||||||
|
"banner_finished_restore_prompt": "Quante {unit} di {name} hai ancora? (stima sistema: {qty})"
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"title": "Dispensa",
|
"title": "Dispensa",
|
||||||
@@ -185,6 +200,7 @@
|
|||||||
"mode_shopping": "🛒 Modalità Spesa",
|
"mode_shopping": "🛒 Modalità Spesa",
|
||||||
"mode_shopping_end": "✅ Fine spesa",
|
"mode_shopping_end": "✅ Fine spesa",
|
||||||
"spesa_btn": "🛒 Spesa",
|
"spesa_btn": "🛒 Spesa",
|
||||||
|
"spesa_camera_hint": "Inquadra il codice con la telecamera. Senza barcode? Premi «Identifica con AI» sotto.",
|
||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"tab_barcode": "Barcode",
|
"tab_barcode": "Barcode",
|
||||||
"tab_name": "Nome",
|
"tab_name": "Nome",
|
||||||
@@ -211,13 +227,53 @@
|
|||||||
"barcode_acquired": "🔖 Barcode acquisito: {code}",
|
"barcode_acquired": "🔖 Barcode acquisito: {code}",
|
||||||
"scan_barcode": "🔖 Scansiona Barcode",
|
"scan_barcode": "🔖 Scansiona Barcode",
|
||||||
"create_named": "Crea {name}",
|
"create_named": "Crea {name}",
|
||||||
"new_without_barcode": "Nuovo prodotto senza barcode"
|
"new_without_barcode": "Nuovo prodotto senza barcode",
|
||||||
|
"stock_in_pantry": "Hai gia in dispensa:",
|
||||||
|
"status_ready": "Inquadra il codice a barre",
|
||||||
|
"status_scanning": "Scansione in corso...",
|
||||||
|
"status_partial": "Letto: {code} — verifico...",
|
||||||
|
"status_invalid": "Non valido: {code} — riprovo",
|
||||||
|
"status_confirmed": "Confermato!",
|
||||||
|
"status_parallel": "Doppia scansione attiva...",
|
||||||
|
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
|
||||||
|
"status_digit_ocr": "Leggo i numeri sotto il codice...",
|
||||||
|
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"method_local_ocr": "OCR numeri",
|
||||||
|
"method_zbar": "ZBar",
|
||||||
|
"local_ocr_found": "Codice dai numeri: {code}",
|
||||||
|
"ai_fallback_searching": "Identificazione AI in corso...",
|
||||||
|
"ai_fallback_found": "Prodotto identificato dall'AI",
|
||||||
|
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
|
||||||
|
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
|
||||||
|
"ai_overlay_label": "Gemini Vision",
|
||||||
|
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
|
||||||
|
"ai_retry_btn": "Riprova con AI",
|
||||||
|
"ai_manual_btn": "🤖 Identifica con AI",
|
||||||
|
"ai_not_recognized": "AI: prodotto non riconosciuto. Riprova o inserisci manualmente.",
|
||||||
|
"ai_match_title": "Prodotto riconosciuto con AI",
|
||||||
|
"ai_match_subtitle": "Scegli un prodotto esistente o creane uno nuovo con il nome rilevato.",
|
||||||
|
"ai_match_existing": "In dispensa adesso",
|
||||||
|
"ai_match_finished": "Finiti / esauriti",
|
||||||
|
"ai_match_catalog": "Nel catalogo (senza scorte)",
|
||||||
|
"ai_match_finished_badge": "esaurito",
|
||||||
|
"ai_match_finished_hint": "Prodotto finito — reintegra la quantità",
|
||||||
|
"ai_match_merged_existing": "Collegato a un prodotto già presente nel catalogo",
|
||||||
|
"ai_match_none": "Nessun prodotto simile trovato — puoi crearne uno nuovo.",
|
||||||
|
"ai_match_use_btn": "Usa",
|
||||||
|
"ai_match_create_btn": "➕ Crea nuovo: {name}",
|
||||||
|
"ai_match_add_btn": "Aggiungi {name}",
|
||||||
|
"ai_match_action_hint": "Tocca il pulsante verde per aggiungere questo prodotto",
|
||||||
|
"ai_match_or_similar": "Oppure scegli un prodotto simile:",
|
||||||
|
"ai_detected_label": "AI ha trovato",
|
||||||
|
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Cosa vuoi fare?",
|
"title": "Cosa vuoi fare?",
|
||||||
"add_btn": "📥 AGGIUNGI",
|
"add_btn": "📥 AGGIUNGI",
|
||||||
"add_sub": "in dispensa/frigo",
|
"add_sub": "in dispensa/frigo",
|
||||||
"use_btn": "📤 USA / CONSUMA",
|
"use_btn": "USA",
|
||||||
"use_sub": "dalla dispensa/frigo",
|
"use_sub": "dalla dispensa/frigo",
|
||||||
"have_title": "📦 Ce l'hai già!",
|
"have_title": "📦 Ce l'hai già!",
|
||||||
"add_more_sub": "altra quantità",
|
"add_more_sub": "altra quantità",
|
||||||
@@ -225,7 +281,8 @@
|
|||||||
"throw_btn": "🗑️ BUTTA",
|
"throw_btn": "🗑️ BUTTA",
|
||||||
"throw_sub": "butta il prodotto",
|
"throw_sub": "butta il prodotto",
|
||||||
"edit_sub": "scadenza, luogo…",
|
"edit_sub": "scadenza, luogo…",
|
||||||
"create_recipe_btn": "Ricetta"
|
"create_recipe_btn": "Ricetta",
|
||||||
|
"related_stock_title": "Hai anche in casa"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"title": "Aggiungi alla Dispensa",
|
"title": "Aggiungi alla Dispensa",
|
||||||
@@ -249,8 +306,9 @@
|
|||||||
"hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera",
|
"hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera",
|
||||||
"scan_expiry_title": "📷 Scansiona Data Scadenza",
|
"scan_expiry_title": "📷 Scansiona Data Scadenza",
|
||||||
"product_added": "✅ {name} aggiunto!{qty}",
|
"product_added": "✅ {name} aggiunto!{qty}",
|
||||||
|
"duplicate_recent_confirm": "Hai appena aggiunto «{name}» ({when}).\n\nLa quantità è già {total}.\n\nVuoi aumentarla di {qty}?",
|
||||||
"suffix_freezer_vacuum": "(freezer + sotto vuoto)",
|
"suffix_freezer_vacuum": "(freezer + sotto vuoto)",
|
||||||
"history_badge_tip": "Media da {n} inserimenti precedenti",
|
"history_badge_tip": "Media degli ultimi {n} inserimenti — si aggiorna ad ogni nuovo acquisto",
|
||||||
"vacuum_question": "Messo sotto vuoto?",
|
"vacuum_question": "Messo sotto vuoto?",
|
||||||
"vacuum_saved": "🔒 Sotto vuoto registrato"
|
"vacuum_saved": "🔒 Sotto vuoto registrato"
|
||||||
},
|
},
|
||||||
@@ -283,14 +341,17 @@
|
|||||||
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||||
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
|
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
|
||||||
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
|
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
|
||||||
|
"disambiguation_one_conf": "Finita <strong>1 confezione</strong> ({qty})",
|
||||||
"disambiguation_all": "🗑️ Finito TUTTO ({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!",
|
"error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!",
|
||||||
"use_all_confirm_title": "✅ Finisci tutto",
|
"use_all_confirm_title": "✅ Finisci tutto",
|
||||||
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
|
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
|
||||||
"use_all_confirm_btn": "✅ Sì, finito",
|
"use_all_confirm_btn": "✅ Sì, finito",
|
||||||
"throw_all_confirm_title": "🗑️ Butta tutto",
|
"throw_all_confirm_title": "🗑️ Butta tutto",
|
||||||
"throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?",
|
"throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?",
|
||||||
"throw_all_confirm_btn": "🗑️ Sì, butta"
|
"throw_all_confirm_btn": "🗑️ Sì, butta",
|
||||||
|
"locations_short": "posti"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
"title_new": "Nuovo Prodotto",
|
"title_new": "Nuovo Prodotto",
|
||||||
@@ -300,6 +361,8 @@
|
|||||||
"name_label": "🏷️ Nome Prodotto *",
|
"name_label": "🏷️ Nome Prodotto *",
|
||||||
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
|
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
|
||||||
"brand_label": "🏢 Marca",
|
"brand_label": "🏢 Marca",
|
||||||
|
"allergens_label": "Allergeni:",
|
||||||
|
"ingredients_summary": "📋 Ingredienti",
|
||||||
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
|
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
|
||||||
"category_label": "📂 Categoria",
|
"category_label": "📂 Categoria",
|
||||||
"unit_label": "📏 Unità di misura",
|
"unit_label": "📏 Unità di misura",
|
||||||
@@ -330,7 +393,9 @@
|
|||||||
"weight_label": "Peso",
|
"weight_label": "Peso",
|
||||||
"origin_label": "Origine",
|
"origin_label": "Origine",
|
||||||
"labels_label": "Etichette",
|
"labels_label": "Etichette",
|
||||||
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
|
"select_variant": "Seleziona la variante esatta o usa i dati AI:",
|
||||||
|
"history_badge": "📊 storico",
|
||||||
|
"from_history": " (media ultimi 3)"
|
||||||
},
|
},
|
||||||
"products": {
|
"products": {
|
||||||
"title": "📦 Tutti i Prodotti",
|
"title": "📦 Tutti i Prodotti",
|
||||||
@@ -357,12 +422,22 @@
|
|||||||
"loading_msg": "Sto preparando la ricetta...",
|
"loading_msg": "Sto preparando la ricetta...",
|
||||||
"start_cooking": "👨🍳 Modalità Cucina",
|
"start_cooking": "👨🍳 Modalità Cucina",
|
||||||
"regenerate": "🔄 Generane un'altra",
|
"regenerate": "🔄 Generane un'altra",
|
||||||
|
"regen_choice_title": "Cosa vuoi fare con questa ricetta?",
|
||||||
|
"regen_replace": "🔄 Genera un'altra (scarta questa)",
|
||||||
|
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
|
||||||
"close_btn": "✅ Chiudi",
|
"close_btn": "✅ Chiudi",
|
||||||
"ingredients_title": "🧾 Ingredienti",
|
"ingredients_title": "🧾 Ingredienti",
|
||||||
|
"shopping_suggestions_intro": "Per una variante servirebbe (non in dispensa — opzionale):",
|
||||||
|
"shopping_suggestions_add": "Aggiungi alla lista spesa",
|
||||||
|
"shopping_suggestions_added": "Aggiunto alla lista spesa",
|
||||||
|
"frozen_badge": "surgelato — dal freezer",
|
||||||
|
"unit_for_input": "Unità di misura",
|
||||||
|
"enter_in": "Inserimento in",
|
||||||
"tools_title": "Strumenti necessari",
|
"tools_title": "Strumenti necessari",
|
||||||
"steps_title": "👨🍳 Procedimento",
|
"steps_title": "👨🍳 Procedimento",
|
||||||
"no_steps": "Nessun procedimento disponibile",
|
"no_steps": "Nessun procedimento disponibile",
|
||||||
"generate_error": "Errore nella generazione",
|
"generate_error": "Errore nella generazione",
|
||||||
|
"stream_interrupted": "Generazione interrotta (risposta incompleta dal server). Controlla i log o riprova.",
|
||||||
"persons_short": "pers.",
|
"persons_short": "pers.",
|
||||||
"use_ingredient_title": "Usa ingrediente",
|
"use_ingredient_title": "Usa ingrediente",
|
||||||
"recipe_qty_label": "Ricetta",
|
"recipe_qty_label": "Ricetta",
|
||||||
@@ -376,7 +451,21 @@
|
|||||||
"scale_wait_stable": "Attendi 10s di stabilità per la compilazione automatica…",
|
"scale_wait_stable": "Attendi 10s di stabilità per la compilazione automatica…",
|
||||||
"ingredient_scaled_toast": "📦 Ingrediente scalato dalla dispensa!",
|
"ingredient_scaled_toast": "📦 Ingrediente scalato dalla dispensa!",
|
||||||
"finished_added_bring_toast": "🛒 Prodotto finito → aggiunto a Bring!",
|
"finished_added_bring_toast": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||||
"load_error": "Errore nel caricamento"
|
"load_error": "Errore nel caricamento",
|
||||||
|
"favorite": "Aggiungi ai preferiti",
|
||||||
|
"unfavorite": "Rimuovi dai preferiti",
|
||||||
|
"adjust_persons": "Persone",
|
||||||
|
"nutrition_title": "Valori nutrizionali (per porzione)",
|
||||||
|
"nutrition_kcal": "Calorie",
|
||||||
|
"nutrition_protein": "Proteine",
|
||||||
|
"nutrition_carbs": "Carboidrati",
|
||||||
|
"nutrition_fat": "Grassi",
|
||||||
|
"nutrition_per_serving": "Valori stimati per porzione",
|
||||||
|
"storage_title": "Come conservare gli avanzi",
|
||||||
|
"storage_days": "{n} giorni",
|
||||||
|
"storage_immediately": "Da consumare subito",
|
||||||
|
"ing_stock_line": "Hai {have} · restano {remain} dopo l'uso",
|
||||||
|
"ing_use_all_note": "uso totale (<5% della confezione intera)"
|
||||||
},
|
},
|
||||||
"shopping": {
|
"shopping": {
|
||||||
"title": "🛒 Lista della Spesa",
|
"title": "🛒 Lista della Spesa",
|
||||||
@@ -436,6 +525,18 @@
|
|||||||
"item_removed": "✅ {name} rimosso dalla lista!",
|
"item_removed": "✅ {name} rimosso dalla lista!",
|
||||||
"urgency_spec_critical": "⚡ Urgente",
|
"urgency_spec_critical": "⚡ Urgente",
|
||||||
"urgency_spec_high": "🟠 Presto",
|
"urgency_spec_high": "🟠 Presto",
|
||||||
|
"urgency_spec_medium": "🟡 A breve",
|
||||||
|
"urgency_spec_low": "🔵 Previsione",
|
||||||
|
"family_sibling_title": "Simile in {location}",
|
||||||
|
"family_sibling_check": "Controlla: {name}",
|
||||||
|
"family_sibling_stock": "Dovresti avere: {qty}",
|
||||||
|
"family_sibling_location": "Si trova in: {location}",
|
||||||
|
"family_sibling_qty": "Quantità: {qty}",
|
||||||
|
"family_sibling_purchased": "Acquistato il {date}",
|
||||||
|
"family_sibling_question": "La quantità è ancora corretta?",
|
||||||
|
"family_sibling_prompt": "Hai anche {name}: ne hai {qty} in dispensa. Confermi la quantità?",
|
||||||
|
"family_sibling_yes": "Sì, tutto ok",
|
||||||
|
"family_sibling_no": "No, aggiorna",
|
||||||
"bring_add_n": "Aggiungi {n} a Bring!",
|
"bring_add_n": "Aggiungi {n} a Bring!",
|
||||||
"bring_add_selected": "Aggiungi selezionati a Bring!",
|
"bring_add_selected": "Aggiungi selezionati a Bring!",
|
||||||
"bring_adding": "Aggiunta in corso...",
|
"bring_adding": "Aggiunta in corso...",
|
||||||
@@ -463,6 +564,7 @@
|
|||||||
"remove_error": "Errore nella rimozione",
|
"remove_error": "Errore nella rimozione",
|
||||||
"btn_fetch_prices": "Cerca i prezzi",
|
"btn_fetch_prices": "Cerca i prezzi",
|
||||||
"price_total_label": "💰 Spesa stimata:",
|
"price_total_label": "💰 Spesa stimata:",
|
||||||
|
"price_total_short": "spesa stimata",
|
||||||
"price_loading": "Ricerca prezzi…",
|
"price_loading": "Ricerca prezzi…",
|
||||||
"price_not_found": "prezzo n/d",
|
"price_not_found": "prezzo n/d",
|
||||||
"suggest_loading": "Analisi in corso...",
|
"suggest_loading": "Analisi in corso...",
|
||||||
@@ -471,7 +573,9 @@
|
|||||||
"priority_medium": "Media",
|
"priority_medium": "Media",
|
||||||
"priority_low": "Bassa",
|
"priority_low": "Bassa",
|
||||||
"smart_last_update": "Aggiornato {time}",
|
"smart_last_update": "Aggiornato {time}",
|
||||||
"names_already_updated": "Tutti i nomi sono già aggiornati"
|
"names_already_updated": "Tutti i nomi sono già aggiornati",
|
||||||
|
"pantry_hint": "Hai gia {qty} in dispensa",
|
||||||
|
"bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!"
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"title": "🤖 Identificazione AI",
|
"title": "🤖 Identificazione AI",
|
||||||
@@ -482,7 +586,8 @@
|
|||||||
"no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
|
"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",
|
"fields_filled": "✅ Campi compilati dall'AI",
|
||||||
"use_data": "✅ Usa dati AI",
|
"use_data": "✅ Usa dati AI",
|
||||||
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)"
|
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)",
|
||||||
|
"conservation_hint": "🤖 AI: conserva in {location}"
|
||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"title": "📒 Storico",
|
"title": "📒 Storico",
|
||||||
@@ -533,14 +638,16 @@
|
|||||||
"prev": "◀ Precedente",
|
"prev": "◀ Precedente",
|
||||||
"next": "Successivo ▶",
|
"next": "Successivo ▶",
|
||||||
"ingredient_used": "✔️ Scalato",
|
"ingredient_used": "✔️ Scalato",
|
||||||
"ingredient_use_btn": "📦 Usa",
|
"ingredient_use_btn": "Usa",
|
||||||
"ingredient_deduct_title": "Scala dalla dispensa",
|
"ingredient_deduct_title": "Scala dalla dispensa",
|
||||||
"timer_expired_tts": "Timer {label} scaduto!",
|
"timer_expired_tts": "Timer {label} scaduto!",
|
||||||
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
|
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
|
||||||
"recipe_done_tts": "Ricetta completata! Buon appetito!",
|
"recipe_done_tts": "Ricetta completata! Buon appetito!",
|
||||||
"expires_chip": "scade {date}",
|
"expires_chip": "scade {date}",
|
||||||
"finish": "✅ Fine",
|
"finish": "✅ Fine",
|
||||||
"step_fallback": "Passo {n}"
|
"step_fallback": "Passo {n}",
|
||||||
|
"zerowaste_label": "♻️ Scarto",
|
||||||
|
"zerowaste_tip_title": "Consiglio anti-spreco"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "⚙️ Configurazione",
|
"title": "⚙️ Configurazione",
|
||||||
@@ -644,7 +751,10 @@
|
|||||||
"back": "📱 Posteriore (default)",
|
"back": "📱 Posteriore (default)",
|
||||||
"front": "🤳 Anteriore",
|
"front": "🤳 Anteriore",
|
||||||
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
||||||
"detect_btn": "🔄 Rileva fotocamere"
|
"detect_btn": "🔄 Rileva fotocamere",
|
||||||
|
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
|
||||||
|
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato.",
|
||||||
|
"ai_manual_hint": "Se il barcode non si legge, usa il pulsante «Identifica con AI» sotto la fotocamera. Richiede Gemini configurato."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 Certificato HTTPS",
|
"title": "🔒 Certificato HTTPS",
|
||||||
@@ -682,6 +792,7 @@
|
|||||||
"extra_fields_label": "➕ Campi extra (JSON)",
|
"extra_fields_label": "➕ Campi extra (JSON)",
|
||||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||||
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
|
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
|
||||||
|
"test_sound_btn": "🔔 Esegui Test Suono",
|
||||||
"test_btn": "🔊 Invia Test Vocale",
|
"test_btn": "🔊 Invia Test Vocale",
|
||||||
"voices_loading": "Caricamento voci…",
|
"voices_loading": "Caricamento voci…",
|
||||||
"voice_not_supported": "Voce non supportata dal browser",
|
"voice_not_supported": "Voce non supportata dal browser",
|
||||||
@@ -689,7 +800,12 @@
|
|||||||
"voices_hint": "Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.",
|
"voices_hint": "Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.",
|
||||||
"url_missing": "⚠️ URL endpoint mancante.",
|
"url_missing": "⚠️ URL endpoint mancante.",
|
||||||
"test_sending": "⏳ Invio in corso…",
|
"test_sending": "⏳ Invio in corso…",
|
||||||
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato."
|
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato.",
|
||||||
|
"heard_question": "Hai sentito la voce?",
|
||||||
|
"heard_yes": "Sì, ho sentito",
|
||||||
|
"heard_no": "No, non ho sentito",
|
||||||
|
"test_ok_kiosk": "TTS funzionante.",
|
||||||
|
"test_fail_steps": "Controlla: 1) volume media del dispositivo non sia 0; 2) Google Text-to-Speech installato e aggiornato; 3) pacchetto vocale italiano scaricato nelle impostazioni TTS Android."
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "🌐 Lingua / Language",
|
"title": "🌐 Lingua / Language",
|
||||||
@@ -727,7 +843,13 @@
|
|||||||
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
|
"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_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",
|
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
|
||||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — heuristica automatica su 100+ modelli</li></ul>"
|
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — heuristica automatica su 100+ modelli</li></ul>",
|
||||||
|
"discover_scanning": "🔍 Scansione rete locale per gateway bilancia…",
|
||||||
|
"discover_found": "✅ Gateway trovato: {url}{more}",
|
||||||
|
"discover_not_found": "❌ Nessun gateway su {subnet}. Avvia l'app Android sulla stessa Wi-Fi.",
|
||||||
|
"discover_failed": "❌ Ricerca fallita: {error}",
|
||||||
|
"discover_auto": "🔍 Auto",
|
||||||
|
"unknown_device": "Dispositivo sconosciuto"
|
||||||
},
|
},
|
||||||
"kiosk": {
|
"kiosk": {
|
||||||
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
||||||
@@ -744,7 +866,178 @@
|
|||||||
},
|
},
|
||||||
"saved": "✅ Configurazione salvata!",
|
"saved": "✅ Configurazione salvata!",
|
||||||
"saved_local": "✅ Configurazione salvata localmente",
|
"saved_local": "✅ Configurazione salvata localmente",
|
||||||
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
|
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}",
|
||||||
|
"theme": {
|
||||||
|
"title": "🌙 Tema / Aspetto",
|
||||||
|
"hint": "Scegli il tema dell interfaccia.",
|
||||||
|
"label": "🌙 Tema",
|
||||||
|
"off": "☀️ Chiaro",
|
||||||
|
"on": "🌙 Scuro",
|
||||||
|
"auto": "🔄 Automatico (orario)"
|
||||||
|
},
|
||||||
|
"zerowaste": {
|
||||||
|
"card_title": "♻️ Suggerimenti zero-waste",
|
||||||
|
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
|
||||||
|
"label": "Mostra suggerimenti durante la cottura"
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"tab": "Backup",
|
||||||
|
"local_title": "Backup Locale",
|
||||||
|
"local_hint": "Snapshot giornaliero del database. Configura quanti giorni di backup conservare.",
|
||||||
|
"enabled": "Backup automatico quotidiano",
|
||||||
|
"retention_days": "Giorni di retention",
|
||||||
|
"retention_info": "I backup vengono conservati per",
|
||||||
|
"backup_now": "Backup Ora",
|
||||||
|
"backing_up": "Backup in corso…",
|
||||||
|
"backed_up": "Backup completato",
|
||||||
|
"backup_error": "Errore backup",
|
||||||
|
"last_backup": "Ultimo backup",
|
||||||
|
"no_backup_yet": "Nessun backup ancora eseguito",
|
||||||
|
"list_empty": "Nessun backup disponibile",
|
||||||
|
"restore_btn": "Ripristina",
|
||||||
|
"restore_confirm": "Ripristinare il backup",
|
||||||
|
"delete_btn": "Elimina",
|
||||||
|
"delete_confirm": "Eliminare il backup",
|
||||||
|
"gdrive_title": "Google Drive",
|
||||||
|
"gdrive_hint": "Backup automatici su Google Drive via OAuth 2.0. Nessuna libreria esterna richiesta.",
|
||||||
|
"gdrive_enabled": "Abilita backup Google Drive",
|
||||||
|
"gdrive_folder_id": "ID Cartella Drive",
|
||||||
|
"gdrive_folder_id_hint": "Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong>",
|
||||||
|
"gdrive_retention_days": "Retention Drive (giorni, 0=tutto)",
|
||||||
|
"gdrive_test": "Testa Connessione",
|
||||||
|
"gdrive_ok": "Connessione riuscita!",
|
||||||
|
"gdrive_error": "Connessione fallita",
|
||||||
|
"gdrive_push_now": "Carica Ora su Drive",
|
||||||
|
"gdrive_pushing": "Upload in corso…",
|
||||||
|
"gdrive_pushed": "Caricato su Drive",
|
||||||
|
"gdrive_wizard_hint": "Opzionale: backup giornaliero automatico su Google Drive via OAuth 2.0.",
|
||||||
|
"gdrive_skip": "Salta — configura dopo in Impostazioni",
|
||||||
|
"gdrive_client_id": "Client ID",
|
||||||
|
"gdrive_client_secret": "Client Secret",
|
||||||
|
"gdrive_redirect_uri_label": "Redirect URI (da aggiungere in Google Cloud Console):",
|
||||||
|
"gdrive_redirect_uri_hint": "Aggiungi <strong>http://localhost</strong> come URI di reindirizzamento autorizzato in Google Cloud Console. Funziona su qualsiasi server, anche senza dominio pubblico.",
|
||||||
|
"gdrive_oauth_authorize": "Autorizza con Google",
|
||||||
|
"gdrive_oauth_authorized": "Autorizzato",
|
||||||
|
"gdrive_oauth_not_authorized": "Non ancora autorizzato",
|
||||||
|
"gdrive_oauth_window_opened": "Finestra aperta — autorizza e torna qui",
|
||||||
|
"gdrive_oauth_how_to": "Come configurare OAuth 2.0 (passo dopo passo)",
|
||||||
|
"gdrive_oauth_steps": "<li>Vai su <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> e seleziona il progetto</li><li>Abilita la <strong>Google Drive API</strong>: <em>API e servizi → Abilita API → Google Drive API</em></li><li>Vai su <em>API e servizi → Credenziali → Crea credenziali → ID client OAuth 2.0</em></li><li>Tipo applicazione: <strong>Applicazione web</strong>; aggiungi <strong>http://localhost</strong> come <em>URI di reindirizzamento autorizzato</em></li><li>Copia <strong>Client ID</strong> e <strong>Client Secret</strong> nei campi qui sopra e salva</li><li>Clicca <strong>Autorizza con Google</strong>, accedi e concedi l'accesso</li><li>Il browser aprirà <code>http://localhost</code> (possibile errore di connessione è normale): copia l'URL dalla barra degli indirizzi e incollalo nel campo che appare qui sotto</li>",
|
||||||
|
"gdrive_code_title": "Incolla l'URL o il codice di autorizzazione",
|
||||||
|
"gdrive_code_hint": "Dopo aver autorizzato, il browser aprirà http://localhost e potrebbe mostrare un errore. Copia l'URL dalla barra degli indirizzi (es. <code>http://localhost/?code=4%2F0A...</code>) e incollalo qui.",
|
||||||
|
"gdrive_code_submit": "Conferma",
|
||||||
|
"gdrive_code_empty": "Incolla prima l'URL o il codice di autorizzazione"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"tab": "Info",
|
||||||
|
"ai_title": "Gemini AI — Utilizzo Token",
|
||||||
|
"ai_hint": "Consumo mensile e costo stimato per la chiave API corrente.",
|
||||||
|
"loading": "Caricamento…",
|
||||||
|
"total_tokens": "Token totali",
|
||||||
|
"est_cost": "Costo stimato",
|
||||||
|
"input_tok": "Token input",
|
||||||
|
"output_tok": "Token output",
|
||||||
|
"ai_calls": "Chiamate",
|
||||||
|
"by_action": "Dettaglio per funzione",
|
||||||
|
"by_model": "Dettaglio per modello",
|
||||||
|
"pricing_note": "Prezzi di riferimento 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": "Database",
|
||||||
|
"log_size": "Log",
|
||||||
|
"log_level": "Livello log",
|
||||||
|
"ai_overview": "Prospetto utilizzo AI, inventario e stato del sistema",
|
||||||
|
"calls_unit": "call",
|
||||||
|
"inv_title": "Inventario",
|
||||||
|
"inv_active": "Attivi",
|
||||||
|
"inv_products": "Prodotti totali",
|
||||||
|
"inv_expiring": "In scadenza (7gg)",
|
||||||
|
"inv_expired": "Scaduti",
|
||||||
|
"inv_finished": "Finiti",
|
||||||
|
"act_title": "Attività del mese",
|
||||||
|
"act_tx_month": "Movimenti",
|
||||||
|
"act_restock": "Acquisti",
|
||||||
|
"act_use": "Consumi",
|
||||||
|
"act_new_products": "Nuovi prodotti",
|
||||||
|
"act_tx_year": "Movimenti anno",
|
||||||
|
"price_cache": "Cache prezzi",
|
||||||
|
"cache_entries": "prodotti",
|
||||||
|
"last_backup": "Ultimo backup",
|
||||||
|
"bring_days": "token scade tra {n} giorni",
|
||||||
|
"bring_expired": "token scaduto",
|
||||||
|
"year_label": "Anno {year}",
|
||||||
|
"currency_title": "Valuta",
|
||||||
|
"currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app."
|
||||||
|
},
|
||||||
|
"tab_general": "Generali",
|
||||||
|
"shopping": {
|
||||||
|
"tab": "Lista spesa",
|
||||||
|
"title": "Lista della spesa",
|
||||||
|
"hint": "Configura la lista della spesa integrata o collega Bring!.",
|
||||||
|
"enable_label": "Abilita lista della spesa",
|
||||||
|
"mode_label": "Provider",
|
||||||
|
"mode_internal": "Interno (senza Bring!)",
|
||||||
|
"mode_bring": "Bring! (app esterna)",
|
||||||
|
"bring_section_title": "Configurazione Bring!",
|
||||||
|
"ai_section_title": "Assistenza AI",
|
||||||
|
"smart_suggestions_label": "Suggerimenti AI",
|
||||||
|
"forecast_label": "Previsione prodotti in esaurimento",
|
||||||
|
"auto_add_label": "Aggiungi automaticamente quando",
|
||||||
|
"auto_add_suffix": "rimasto in magazzino (0 = solo quando esaurito)"
|
||||||
|
},
|
||||||
|
"ha": {
|
||||||
|
"tab": "Home Assistant",
|
||||||
|
"title": "Home Assistant",
|
||||||
|
"hint": "Collega EverShelf a Home Assistant per automazioni, notifiche push e sensori REST.",
|
||||||
|
"enabled": "Abilita integrazione Home Assistant",
|
||||||
|
"connection_title": "Connessione",
|
||||||
|
"url_label": "URL Home Assistant",
|
||||||
|
"url_placeholder": "http://192.168.1.50:8123",
|
||||||
|
"url_hint": "URL del tuo server Home Assistant (es. http://homeassistant.local:8123).",
|
||||||
|
"token_label": "Long-Lived Access Token",
|
||||||
|
"token_hint": "Genera da Profilo HA → Sicurezza → Token di accesso a lungo termine.",
|
||||||
|
"token_placeholder": "eyJhbGci...",
|
||||||
|
"token_saved": "Token salvato (non mostrato per sicurezza)",
|
||||||
|
"test_btn": "Testa connessione",
|
||||||
|
"test_ok": "Connesso a {version}",
|
||||||
|
"test_fail": "Connessione fallita: {error}",
|
||||||
|
"test_bad_token": "HA raggiungibile ma token non valido",
|
||||||
|
"testing": "Test in corso…",
|
||||||
|
"error_no_url": "Inserisci prima l'URL di Home Assistant.",
|
||||||
|
"tts_title": "TTS su Speaker Smart",
|
||||||
|
"tts_hint": "Leggi i passi delle ricette su un media player di Home Assistant.",
|
||||||
|
"tts_entity_label": "Entity ID media player",
|
||||||
|
"tts_entity_placeholder": "media_player.living_room",
|
||||||
|
"tts_entity_hint": "Entity ID del media player su cui vuoi la voce. Puoi trovarlo in HA: Strumenti per sviluppatori → Stati.",
|
||||||
|
"tts_platform_label": "Piattaforma TTS",
|
||||||
|
"tts_platform_speak": "tts.speak (raccomandato)",
|
||||||
|
"tts_platform_notify": "notify.* (servizio notifiche)",
|
||||||
|
"tts_apply_btn": "Applica preset HA al tab TTS",
|
||||||
|
"tts_apply_hint": "Pre-compila il tab TTS con l'URL e il token di Home Assistant.",
|
||||||
|
"tts_preset_applied": "Preset HA applicato al tab TTS.",
|
||||||
|
"webhook_title": "Automazioni Webhook",
|
||||||
|
"webhook_hint": "Invia dati a Home Assistant quando avvengono eventi nella dispensa. Crea un'automazione in HA con trigger Webhook e copia l'ID generato.",
|
||||||
|
"webhook_id_label": "Webhook ID",
|
||||||
|
"webhook_id_placeholder": "evershelf_webhook_abc123",
|
||||||
|
"webhook_id_hint": "ID del webhook creato in HA. Copia da: HA → Impostazioni → Automazioni → Crea → Trigger Webhook.",
|
||||||
|
"webhook_events_label": "Notifica per questi eventi",
|
||||||
|
"event_expiry": "Prodotti in scadenza (giornaliero)",
|
||||||
|
"event_shopping": "Aggiunta alla lista della spesa",
|
||||||
|
"event_stock": "Aggiornamento scorte",
|
||||||
|
"expiry_days_label": "Anticipo scadenze (giorni)",
|
||||||
|
"expiry_days_hint": "Invia la notifica di scadenza N giorni prima della data di scadenza.",
|
||||||
|
"webhook_help": "In HA: Impostazioni → Automazioni → Crea automazione → Trigger: Webhook → copia l'ID generato qui sopra.",
|
||||||
|
"notify_title": "Notifiche Push",
|
||||||
|
"notify_hint": "Invia notifiche push al tuo telefono tramite il servizio notify di Home Assistant.",
|
||||||
|
"notify_service_label": "Servizio notify",
|
||||||
|
"notify_service_placeholder": "notify.mobile_app_mio_telefono",
|
||||||
|
"notify_service_hint": "Nome del servizio notify HA (es. notify.mobile_app_phone). Lascia vuoto per disabilitare.",
|
||||||
|
"sensor_title": "Sensori REST",
|
||||||
|
"sensor_hint": "Aggiungi a configuration.yaml per creare sensori EverShelf in Home Assistant.",
|
||||||
|
"sensor_copy_btn": "Copia YAML",
|
||||||
|
"sensor_copied": "YAML copiato negli appunti!",
|
||||||
|
"save_btn": "Salva impostazioni HA",
|
||||||
|
"ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori."
|
||||||
|
},
|
||||||
|
"kiosk_update_required": "⚠️ Aggiorna il kiosk per usare questa funzione"
|
||||||
},
|
},
|
||||||
"expiry": {
|
"expiry": {
|
||||||
"today": "OGGI",
|
"today": "OGGI",
|
||||||
@@ -816,7 +1109,9 @@
|
|||||||
"thrown_away": "🗑️ {name} buttato!",
|
"thrown_away": "🗑️ {name} buttato!",
|
||||||
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
|
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
|
||||||
"finished_all": "📤 {name} terminato!",
|
"finished_all": "📤 {name} terminato!",
|
||||||
|
"vacuum_sealed": "{name} salvato come sottovuoto",
|
||||||
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
||||||
|
"ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario",
|
||||||
"appliance_added": "Elettrodomestico aggiunto",
|
"appliance_added": "Elettrodomestico aggiunto",
|
||||||
"item_added": "{name} aggiunto"
|
"item_added": "{name} aggiunto"
|
||||||
},
|
},
|
||||||
@@ -866,6 +1161,7 @@
|
|||||||
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
||||||
"barcode_empty": "Inserisci un codice a barre",
|
"barcode_empty": "Inserisci un codice a barre",
|
||||||
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
||||||
|
"barcode_checksum": "Checksum EAN non valido — verifica le cifre del codice",
|
||||||
"min_chars": "Scrivi almeno 2 caratteri",
|
"min_chars": "Scrivi almeno 2 caratteri",
|
||||||
"not_in_inventory": "Prodotto non nell'inventario",
|
"not_in_inventory": "Prodotto non nell'inventario",
|
||||||
"appliance_exists": "Elettrodomestico già presente",
|
"appliance_exists": "Elettrodomestico già presente",
|
||||||
@@ -877,13 +1173,26 @@
|
|||||||
"server_retry": "Riprova",
|
"server_retry": "Riprova",
|
||||||
"unknown": "Errore sconosciuto",
|
"unknown": "Errore sconosciuto",
|
||||||
"prefix": "Errore",
|
"prefix": "Errore",
|
||||||
"no_inventory_entry": "Nessuna voce di inventario trovata"
|
"no_inventory_entry": "Nessuna voce di inventario trovata",
|
||||||
|
"offline_title": "Nessuna connessione",
|
||||||
|
"offline_subtitle": "L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.",
|
||||||
|
"offline_checking": "Verifica connessione…",
|
||||||
|
"offline_restored": "Connessione ripristinata!",
|
||||||
|
"offline_continue": "Continua in modalità offline",
|
||||||
|
"offline_reading_cache": "Lettura dalla cache locale",
|
||||||
|
"offline_ops_pending": "{n} operazioni in attesa",
|
||||||
|
"offline_synced": "{n} operazioni sincronizzate",
|
||||||
|
"offline_ai_disabled": "Non disponibile offline",
|
||||||
|
"offline_cache_ready": "Offline — {n} prodotti in cache",
|
||||||
|
"copy_failed": "Copia negli appunti non riuscita",
|
||||||
|
"invalid_quantity": "Quantità non valida"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||||
"kiosk_exit": "Uscire dalla modalità kiosk?",
|
"kiosk_exit": "Uscire dalla modalità kiosk?",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
"proceed": "Conferma"
|
"proceed": "Conferma",
|
||||||
|
"discard_one": "Butta 1 pezzo"
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"dispensa": "Dispensa",
|
"dispensa": "Dispensa",
|
||||||
@@ -895,7 +1204,8 @@
|
|||||||
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
|
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
|
||||||
"label_name": "🏷️ Nome prodotto",
|
"label_name": "🏷️ Nome prodotto",
|
||||||
"choose_location_title": "Quale modifica?",
|
"choose_location_title": "Quale modifica?",
|
||||||
"choose_location_hint": "Scegli la posizione da modificare:"
|
"choose_location_hint": "Scegli la posizione da modificare:",
|
||||||
|
"confirm_large_qty": "Stai impostando la quantità a {qty} {unit}. Questo sembra un valore insolitamente alto. Confermare?"
|
||||||
},
|
},
|
||||||
"screensaver": {
|
"screensaver": {
|
||||||
"recipe_btn": "Ricette",
|
"recipe_btn": "Ricette",
|
||||||
@@ -975,7 +1285,10 @@
|
|||||||
"retake_btn": "🔄 Riscatta",
|
"retake_btn": "🔄 Riscatta",
|
||||||
"camera_error_hint": "Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.<br>Puoi inserire il barcode manualmente o usare l'identificazione AI.",
|
"camera_error_hint": "Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.<br>Puoi inserire il barcode manualmente o usare l'identificazione AI.",
|
||||||
"no_barcode": "Senza barcode",
|
"no_barcode": "Senza barcode",
|
||||||
"save_new_btn": "🆕 Non è nessuno di questi — salva come nuovo"
|
"save_new_btn": "🆕 Non è nessuno di questi — salva come nuovo",
|
||||||
|
"expiry_found": "Data trovata",
|
||||||
|
"expiry_read_fail": "Non riesco a leggere la data.",
|
||||||
|
"expiry_raw_label": "Letto"
|
||||||
},
|
},
|
||||||
"lowstock": {
|
"lowstock": {
|
||||||
"title": "⚠️ Sta per finire!",
|
"title": "⚠️ Sta per finire!",
|
||||||
@@ -992,8 +1305,9 @@
|
|||||||
"thing_rest": "il resto",
|
"thing_rest": "il resto",
|
||||||
"stay_btn": "No, resta in {location}",
|
"stay_btn": "No, resta in {location}",
|
||||||
"moved_toast": "📦 Confezione aperta spostata in {location}",
|
"moved_toast": "📦 Confezione aperta spostata in {location}",
|
||||||
"vacuum_restore": "🫙 Torna sotto vuoto",
|
"vacuum_restore": "Torna sotto vuoto",
|
||||||
"vacuum_seal_rest": "🔒 Metti sotto vuoto il resto"
|
"vacuum_seal_rest": "Metti sotto vuoto il resto",
|
||||||
|
"moved_simple": "📦 Spostato in {location}"
|
||||||
},
|
},
|
||||||
"nova": {
|
"nova": {
|
||||||
"1": "Non trasformato",
|
"1": "Non trasformato",
|
||||||
@@ -1057,7 +1371,13 @@
|
|||||||
"source": "Basato su {n} prodotti in dispensa · EverShelf",
|
"source": "Basato su {n} prodotti in dispensa · EverShelf",
|
||||||
"products_count": "prodotti",
|
"products_count": "prodotti",
|
||||||
"today_title": "🥗 La tua dispensa oggi",
|
"today_title": "🥗 La tua dispensa oggi",
|
||||||
"products_n": "{n} prodotti"
|
"products_n": "{n} prodotti",
|
||||||
|
"macros_title": "Macronutrienti stimati",
|
||||||
|
"macros_proteins": "Proteine",
|
||||||
|
"macros_carbs": "Carboidrati",
|
||||||
|
"macros_fat": "Grassi",
|
||||||
|
"macros_fiber": "Fibre",
|
||||||
|
"macros_source": "Stima basata su {n} prodotti in dispensa"
|
||||||
},
|
},
|
||||||
"facts": {
|
"facts": {
|
||||||
"greeting_morning": "Buongiorno",
|
"greeting_morning": "Buongiorno",
|
||||||
@@ -1179,5 +1499,73 @@
|
|||||||
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
|
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
|
||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
"github": "Repository GitHub"
|
"github": "Repository GitHub"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"title": "Esporta inventario",
|
||||||
|
"hint": "Scarica l inventario corrente in CSV o apri la versione stampabile (PDF).",
|
||||||
|
"btn_csv": "Scarica CSV",
|
||||||
|
"btn_pdf": "PDF / Stampa",
|
||||||
|
"btn_title": "Esporta"
|
||||||
|
},
|
||||||
|
"startup": {
|
||||||
|
"connecting": "Connessione al server...",
|
||||||
|
"check_php_memory": "Memoria PHP",
|
||||||
|
"check_php_timeout": "Timeout PHP",
|
||||||
|
"check_php_upload": "Upload PHP",
|
||||||
|
"check_data_dir": "Cartella dati",
|
||||||
|
"check_rate_limits": "Dir rate limits",
|
||||||
|
"check_backups": "Dir backup",
|
||||||
|
"check_write_test": "Test scrittura disco",
|
||||||
|
"check_disk_space": "Spazio disco",
|
||||||
|
"check_db_legacy": "DB legacy (dispensa.db)",
|
||||||
|
"check_db_connect": "Connessione database",
|
||||||
|
"check_db_tables": "Tabelle database",
|
||||||
|
"check_db_integrity": "Integrità database",
|
||||||
|
"check_db_wal": "WAL mode",
|
||||||
|
"check_db_size": "Dimensione database",
|
||||||
|
"check_db_rows": "Dati inventario",
|
||||||
|
"check_env": "File .env",
|
||||||
|
"check_gemini": "Chiave Gemini AI",
|
||||||
|
"check_bring_creds": "Credenziali Bring!",
|
||||||
|
"check_bring_token": "Token Bring!",
|
||||||
|
"check_tts": "URL Text-to-Speech",
|
||||||
|
"check_scale": "Gateway bilancia",
|
||||||
|
"check_curl_ssl": "cURL SSL",
|
||||||
|
"check_internet": "Connessione internet",
|
||||||
|
"fresh_install": "nuovo impianto",
|
||||||
|
"warnings_found": "avvisi rilevati",
|
||||||
|
"all_ok": "Sistema OK",
|
||||||
|
"critical_error_short": "Errore critico",
|
||||||
|
"critical_error": "Errore critico: l'app non può avviarsi. Controlla i log del server.",
|
||||||
|
"critical_error_intro": "L'app non può avviarsi a causa dei seguenti problemi:",
|
||||||
|
"error_network": "Impossibile contattare il server.",
|
||||||
|
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
|
||||||
|
"retry": "Riprova",
|
||||||
|
"syncing_local": "Sincronizzazione dati locali...",
|
||||||
|
"sync_done": "Dati locali aggiornati",
|
||||||
|
"token_required": "Token API richiesto",
|
||||||
|
"token_autoconfig": "Configurazione accesso...",
|
||||||
|
"token_prompt_title": "🔒 Token API",
|
||||||
|
"token_prompt_hint": "Inserisci il valore API_TOKEN dal file .env del server.",
|
||||||
|
"token_prompt_btn": "Continua"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Statistiche Mensili",
|
||||||
|
"consumed": "prodotti usati",
|
||||||
|
"trend_up": "+{pct}% rispetto a {prev}",
|
||||||
|
"trend_down": "-{pct}% rispetto a {prev}",
|
||||||
|
"trend_same": "stesso ritmo del mese scorso",
|
||||||
|
"added": "aggiunti",
|
||||||
|
"wasted": "sprecati",
|
||||||
|
"top_used": "più usato",
|
||||||
|
"top_cats": "Categorie principali",
|
||||||
|
"source": "Storico transazioni · mese corrente"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"just_now": "adesso",
|
||||||
|
"seconds_ago": "{n}s fa",
|
||||||
|
"minutes_ago": "{n} min fa",
|
||||||
|
"hours_ago": "{n} h fa",
|
||||||
|
"days_ago": "{n} gg fa"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user