From d13f744aeae4236570bbac98daa8a239f32625b3 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Fri, 10 Apr 2026 06:03:11 +0000 Subject: [PATCH] feat: v1.1.0 - Docker, i18n, setup wizard, rate limiting, OpenAPI New features: - Docker support (Dockerfile + docker-compose.yml) - GitHub Actions CI pipeline (PHP lint, JS lint, Docker build, i18n validation) - Internationalization system with 3 languages (it, en, de) and 347 translation keys - First-run setup wizard (4-step configuration) - File-based API rate limiting (120/15/5 req/min tiers) - OpenAPI 3.1.0 specification for all 43 API endpoints - CONTRIBUTING.md with translation and development guide - Screenshots directory placeholder Modified: - README.md: Docker badges, install instructions, translations section - api/index.php: rate limiting middleware - assets/js/app.js: i18n system, setup wizard, t() function - assets/css/style.css: setup wizard styles - index.html: data-i18n attributes, setup wizard overlay, language settings - .gitignore: rate_limits exclusion --- .dockerignore | 34 ++ .github/workflows/ci.yml | 88 ++++ .gitignore | 1 + CONTRIBUTING.md | 141 +++++++ Dockerfile | 41 ++ README.md | 43 +- api/index.php | 68 +++ assets/css/style.css | 110 +++++ assets/img/screenshots/README.md | 31 ++ assets/js/app.js | 377 +++++++++++++++-- docker-compose.yml | 18 + docs/openapi.yaml | 691 +++++++++++++++++++++++++++++++ index.html | 159 ++++--- translations/de.json | 431 +++++++++++++++++++ translations/en.json | 431 +++++++++++++++++++ translations/it.json | 431 +++++++++++++++++++ 16 files changed, 2993 insertions(+), 102 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 assets/img/screenshots/README.md create mode 100644 docker-compose.yml create mode 100644 docs/openapi.yaml create mode 100644 translations/de.json create mode 100644 translations/en.json create mode 100644 translations/it.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d6122cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Docker runtime files +data/dispensa.db +data/*.db-wal +data/*.db-shm +data/backups/ +data/cron.log +data/smart_shopping_cache.json +data/bring_token.json +data/bring_catalog.json +data/dupliclick_token.json +data/client_debug.log +data/*.crt +data/*.pem + +# Config (mounted as volume) +.env + +# Git +.git +.gitignore + +# Docs (not needed in container) +README.md +CHANGELOG.md +CONTRIBUTING.md +LICENSE +docs/ +*.md + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6ab44b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + lint-php: + name: PHP Syntax Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: pdo_sqlite, curl, mbstring + + - name: Check PHP syntax + run: | + find api/ -name '*.php' -exec php -l {} \; + + lint-js: + name: JavaScript Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check JS syntax + run: | + node -c assets/js/app.js + + docker-build: + name: Docker Build Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t dispensa-test . + + - name: Test container starts + run: | + docker run -d --name test-dispensa -p 8080:80 dispensa-test + sleep 5 + curl -f http://localhost:8080/ || exit 1 + docker stop test-dispensa + + validate-translations: + name: Validate Translation Files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate JSON syntax + run: | + for f in translations/*.json; do + echo "Checking $f..." + python3 -c "import json; json.load(open('$f'))" || exit 1 + done + echo "All translation files valid." + + - name: Check translation completeness + run: | + python3 -c " + import json, sys + base = json.load(open('translations/it.json')) + base_keys = set(base.keys()) + ok = True + import glob + for f in glob.glob('translations/*.json'): + if 'it.json' in f: + continue + lang = json.load(open(f)) + lang_keys = set(lang.keys()) + missing = base_keys - lang_keys + if missing: + print(f'{f}: {len(missing)} missing keys') + for k in sorted(missing)[:10]: + print(f' - {k}') + if len(missing) > 10: + print(f' ... and {len(missing)-10} more') + else: + print(f'{f}: complete ✓') + " diff --git a/.gitignore b/.gitignore index 308f760..36ad6c6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ data/bring_token.json data/bring_catalog.json data/dupliclick_token.json data/client_debug.log +data/rate_limits/ # SSL certificates (local only) data/*.crt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f306e87 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,141 @@ +# Contributing to Dispensa Manager + +Thank you for your interest in contributing! This guide will help you get started. + +## 🚀 Getting Started + +1. **Fork** the repository +2. **Clone** your fork: + ```bash + git clone https://github.com/YOUR_USERNAME/dispensa.git + cd dispensa + ``` +3. **Create a branch** from `develop`: + ```bash + git checkout develop + git checkout -b feature/your-feature-name + ``` +4. **Set up** your development environment: + ```bash + cp .env.example .env + # Edit .env with your API keys + php -S localhost:8080 + ``` + +## 📐 Project Structure + +``` +├── index.html # Single-page app (all HTML) +├── api/ +│ ├── index.php # API router + all endpoint functions +│ ├── database.php # SQLite schema + migrations +│ └── cron_smart_shopping.php +├── assets/ +│ ├── js/app.js # All application JavaScript +│ └── css/style.css # All styles +├── translations/ # i18n translation files +│ ├── it.json # Italian (base language) +│ ├── en.json # English +│ └── ... +└── data/ # Runtime data (gitignored) +``` + +## 🌍 Contributing Translations + +Translations are one of the easiest ways to contribute! Each language is a single JSON file in the `translations/` directory. + +### Adding a new language + +1. Copy `translations/it.json` (the base language) +2. Rename it to your language code (e.g., `fr.json`, `de.json`, `es.json`) +3. Translate all the values (keep the keys unchanged) +4. Submit a Pull Request + +### Translation file format + +```json +{ + "app.title": "Dispensa Manager", + "nav.dashboard": "Dashboard", + "nav.inventory": "Inventario", + ... +} +``` + +**Rules:** +- Keys are in English, dot-separated (`section.key`) +- Values are the translated strings +- Keep `{0}`, `{1}` placeholders — they are filled dynamically +- Don't translate brand names (Bring!, Gemini, etc.) +- The CI pipeline will check your file for missing keys + +### Language codes + +Use [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) two-letter codes: +`it`, `en`, `de`, `fr`, `es`, `pt`, `nl`, `pl`, `ru`, `ja`, `zh`, `ko`, etc. + +## 🔧 Development Guidelines + +### Code Style +- **PHP**: PSR-12 compatible, use type hints where practical +- **JavaScript**: No build tools, vanilla ES6+, single-file architecture +- **CSS**: Mobile-first, use CSS custom properties from `:root` +- **Comments**: English only, concise + +### Commits +- Use descriptive commit messages +- Reference issue numbers when applicable: `Fix #42: barcode scanner timeout` +- Keep commits focused on a single change + +### Branching +- `main` — stable releases only +- `develop` — active development (PRs target here) +- `feature/*` — new features +- `fix/*` — bug fixes +- `i18n/*` — translation contributions + +## 🧪 Testing + +Before submitting a PR: + +```bash +# Check PHP syntax +php -l api/index.php +php -l api/database.php + +# Check JS syntax +node -c assets/js/app.js + +# Validate translation files +python3 -c "import json; json.load(open('translations/it.json'))" + +# Test Docker build +docker build -t dispensa-test . +``` + +## 📝 Pull Request Process + +1. Ensure your code passes all CI checks +2. Update `CHANGELOG.md` if applicable +3. Target the `develop` branch +4. Provide a clear description of your changes +5. Link any related issues + +## 🐛 Reporting Bugs + +Open an issue with: +- Steps to reproduce +- Expected vs. actual behavior +- Browser/device information +- Screenshots if applicable + +## 💡 Feature Requests + +Open an issue with the `enhancement` label. Describe: +- The problem you're trying to solve +- Your proposed solution +- Any alternatives you've considered + +## 📄 License + +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..795f13a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM php:8.2-apache + +# Install required PHP extensions +RUN apt-get update && apt-get install -y \ + libsqlite3-dev \ + libcurl4-openssl-dev \ + && docker-php-ext-install pdo_sqlite curl mbstring \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Enable Apache mod_rewrite +RUN a2enmod rewrite + +# Set working directory +WORKDIR /var/www/html + +# Copy application files +COPY . /var/www/html/ + +# Create data directory with proper permissions +RUN mkdir -p /var/www/html/data/backups \ + && chown -R www-data:www-data /var/www/html/data \ + && chmod -R 775 /var/www/html/data + +# Create .env from example if it doesn't exist (will be overridden by volume mount) +RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.env || true + +# Apache configuration: serve from app root +RUN echo '\n\ + AllowOverride All\n\ + Require all granted\n\ +' > /etc/apache2/conf-available/dispensa.conf \ + && a2enconf dispensa + +# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost/ || exit 1 + +CMD ["apache2-foreground"] diff --git a/README.md b/README.md index 29969bb..db1886a 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,14 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/) [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) +[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) +[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/) + --- @@ -63,6 +67,25 @@ ### Installation +#### Option A: Docker (recommended) + +```bash +# 1. Clone the repository +git clone https://github.com/dadaloop82/dispensa.git +cd dispensa + +# 2. Create configuration file +cp .env.example .env +nano .env + +# 3. Start with Docker Compose +docker compose up -d + +# → Open http://localhost:8080 +``` + +#### Option B: Manual + ```bash # 1. Clone the repository git clone https://github.com/dadaloop82/dispensa.git @@ -248,16 +271,30 @@ The application uses no build tools — edit files directly and refresh. - [ ] Multi-language support (i18n) - [ ] User authentication / multi-user support - [ ] Docker container for easy deployment -- [ ] REST API documentation (OpenAPI/Swagger) +- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml) - [ ] Offline mode with service worker - [ ] Export/import inventory data - [ ] Notification system (Telegram, email) for expiring products --- +## 🌐 Translations + +The app supports multiple languages via JSON translation files in the `translations/` folder. + +| Language | Status | +|----------|--------| +| 🇮🇹 Italian (it) | ✅ Complete (base) | +| 🇬🇧 English (en) | ✅ Complete | +| 🇩🇪 German (de) | ✅ 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! + +--- + ## 🤝 Contributing -Contributions are welcome! Please: +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/my-feature`) diff --git a/api/index.php b/api/index.php index 5c0ba01..678e876 100644 --- a/api/index.php +++ b/api/index.php @@ -52,6 +52,74 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; } +// ===== RATE LIMITING ===== +/** + * Simple file-based rate limiter. + * Limits: 120 req/min general, 15 req/min for AI endpoints, 5 req/min for login. + */ +function checkRateLimit(string $action): void { + $rateLimitDir = __DIR__ . '/../data/rate_limits'; + if (!is_dir($rateLimitDir)) { + mkdir($rateLimitDir, 0755, true); + } + + // Determine limit based on action + $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping']; + $loginActions = ['dupliclick_login']; + + if (in_array($action, $aiActions)) { + $limit = 15; + $window = 60; + $bucket = 'ai'; + } elseif (in_array($action, $loginActions)) { + $limit = 5; + $window = 60; + $bucket = 'login'; + } else { + $limit = 120; + $window = 60; + $bucket = 'general'; + } + + $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + $file = $rateLimitDir . '/' . md5($ip . '_' . $bucket) . '.json'; + + // Clean up old rate limit files periodically (1% chance per request) + if (mt_rand(1, 100) === 1) { + foreach (glob($rateLimitDir . '/*.json') as $f) { + if (filemtime($f) < time() - 300) @unlink($f); + } + } + + $now = time(); + $data = []; + if (file_exists($file)) { + $raw = @file_get_contents($file); + if ($raw) $data = json_decode($raw, true) ?: []; + } + + // Remove entries outside the window + $data = array_values(array_filter($data, function($ts) use ($now, $window) { + return $ts > $now - $window; + })); + + if (count($data) >= $limit) { + http_response_code(429); + header('Retry-After: ' . $window); + echo json_encode(['error' => 'Too many requests. Please try again later.']); + exit; + } + + $data[] = $now; + @file_put_contents($file, json_encode($data), LOCK_EX); +} + +// Apply rate limiting +$rateLimitAction = $_GET['action'] ?? ''; +if ($rateLimitAction) { + checkRateLimit($rateLimitAction); +} + try { $db = getDB(); } catch (Exception $e) { diff --git a/assets/css/style.css b/assets/css/style.css index 1a2973b..ee895da 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -5072,3 +5072,113 @@ body { background: rgba(255,255,255,0.25); transform: scale(0.92); } + +/* ===== SETUP WIZARD ===== */ +.setup-wizard-content { + max-width: 480px; + width: 95%; + margin: auto; + border-radius: 20px; + overflow: hidden; + max-height: 90vh; + display: flex; + flex-direction: column; +} +.setup-header { + background: var(--primary); + color: #fff; + padding: 24px 20px 16px; + text-align: center; +} +.setup-header h2 { + margin: 0 0 12px; + font-size: 1.5rem; +} +.setup-progress { + display: flex; + gap: 8px; + justify-content: center; +} +.setup-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(255,255,255,0.3); + transition: background 0.3s, transform 0.3s; +} +.setup-dot.active { + background: #fff; + transform: scale(1.3); +} +.setup-dot.done { + background: var(--success-light); +} +.setup-body { + padding: 24px 20px; + overflow-y: auto; + flex: 1; +} +.setup-body h3 { + margin: 0 0 8px; + font-size: 1.2rem; +} +.setup-body p { + color: #666; + margin: 0 0 16px; + font-size: 0.9rem; + line-height: 1.5; +} +.setup-body .form-group { + margin-bottom: 16px; +} +.setup-body .form-input { + width: 100%; + box-sizing: border-box; +} +.setup-lang-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px; + margin: 12px 0; +} +.setup-lang-btn { + padding: 14px 12px; + border: 2px solid #e2e8f0; + border-radius: 12px; + background: #fff; + cursor: pointer; + text-align: center; + font-size: 1rem; + transition: border-color 0.2s, background 0.2s; +} +.setup-lang-btn.selected { + border-color: var(--primary); + background: rgba(45,80,22,0.08); +} +.setup-lang-btn:hover { + border-color: var(--primary-light); +} +.setup-footer { + padding: 16px 20px; + display: flex; + justify-content: space-between; + border-top: 1px solid #e2e8f0; + gap: 12px; +} +.setup-footer .btn { + flex: 1; + padding: 12px; + font-size: 1rem; +} +.setup-skip-link { + display: block; + text-align: center; + color: #999; + font-size: 0.8rem; + margin-top: 12px; + cursor: pointer; + text-decoration: underline; +} +.setup-skip-link:hover { + color: #666; +} diff --git a/assets/img/screenshots/README.md b/assets/img/screenshots/README.md new file mode 100644 index 0000000..27659d2 --- /dev/null +++ b/assets/img/screenshots/README.md @@ -0,0 +1,31 @@ +# Screenshots + +Add screenshots here to showcase the app in the README. + +## Recommended screenshots + +Take screenshots of these pages for the README `Screenshots` section: + +1. **dashboard.png** — Main dashboard with stats cards and expiry alerts +2. **inventory.png** — Inventory list with product cards +3. **scan.png** — Barcode scanning page +4. **recipe.png** — Generated recipe with cooking mode +5. **shopping.png** — Shopping list with smart predictions +6. **chat.png** — Gemini Chef AI conversation +7. **settings.png** — Settings page +8. **setup.png** — First-run setup wizard + +## How to add + +1. Take screenshots on a mobile device or using Chrome DevTools device emulation +2. Recommended size: 375×812 (iPhone X viewport) +3. Save as PNG with descriptive names +4. Update the README.md `## Screenshots` section to reference them: + +```markdown +## Screenshots + +| Dashboard | Inventory | Scan | +|:-:|:-:|:-:| +| ![Dashboard](assets/img/screenshots/dashboard.png) | ![Inventory](assets/img/screenshots/inventory.png) | ![Scan](assets/img/screenshots/scan.png) | +``` diff --git a/assets/js/app.js b/assets/js/app.js index bd14c47..33a7f5b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -57,6 +57,123 @@ window.addEventListener('unhandledrejection', function(e) { // ===== CONFIGURATION ===== const API_BASE = 'api/index.php'; + +// ===== i18n TRANSLATION SYSTEM ===== +let _i18nStrings = null; // current language translations (flat) +let _i18nFallback = null; // Italian fallback (flat) +let _currentLang = localStorage.getItem('dispensa_lang') || navigator.language?.slice(0, 2) || 'it'; +const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch' }; +if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'it'; + +// Flatten nested JSON: { a: { b: "x" } } → { "a.b": "x" } +function _flattenI18n(obj, prefix = '') { + const result = {}; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) { + Object.assign(result, _flattenI18n(v, key)); + } else { + result[key] = v; + } + } + return result; +} + +// Translation function: t('toast.thrown_away', {name: 'Latte'}) +function t(key, params) { + let str = (_i18nStrings && _i18nStrings[key]) || (_i18nFallback && _i18nFallback[key]) || key; + if (params) { + for (const [k, v] of Object.entries(params)) { + str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + } + } + return str; +} + +// Load translations from JSON files +async function loadTranslations(lang) { + lang = lang || _currentLang; + try { + // Always load Italian as fallback + if (!_i18nFallback) { + const fbRes = await fetch(`translations/it.json?v=${Date.now()}`); + if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json()); + } + if (lang === 'it') { + _i18nStrings = _i18nFallback; + } else { + const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${Date.now()}`); + if (res.ok) _i18nStrings = _flattenI18n(await res.json()); + else _i18nStrings = _i18nFallback; + } + _currentLang = lang; + localStorage.setItem('dispensa_lang', lang); + _applyI18nToLabels(); + translatePage(); + } catch (e) { + console.warn('i18n: Failed to load translations for', lang, e); + _i18nStrings = _i18nFallback; + } +} + +// Update LOCATIONS / SHOPPING_SECTIONS labels from translations +function _applyI18nToLabels() { + if (!_i18nStrings) return; + for (const key of Object.keys(LOCATIONS)) { + const tKey = `locations.${key}`; + if (_i18nStrings[tKey]) LOCATIONS[key].label = _i18nStrings[tKey]; + } + for (const sec of SHOPPING_SECTIONS) { + const tKey = `shopping_sections.${sec.key}`; + if (_i18nStrings[tKey]) sec.label = _i18nStrings[tKey]; + } +} + +// Translate all elements with data-i18n attributes +function translatePage() { + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + if (key) el.textContent = t(key); + }); + document.querySelectorAll('[data-i18n-html]').forEach(el => { + const key = el.getAttribute('data-i18n-html'); + if (key) el.innerHTML = t(key); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + if (key) el.placeholder = t(key); + }); + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + if (key) el.title = t(key); + }); + // Update HTML lang attribute + document.documentElement.lang = _currentLang; + // Populate language selector if present + _populateLanguageSelector(); +} + +// Populate the language selector dropdown +function _populateLanguageSelector() { + const sel = document.getElementById('setting-language'); + if (!sel) return; + sel.innerHTML = ''; + for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) { + const opt = document.createElement('option'); + opt.value = code; + opt.textContent = name; + if (code === _currentLang) opt.selected = true; + sel.appendChild(opt); + } +} + +// Change language and reload the page +function changeLanguage(lang) { + if (lang === _currentLang) return; + localStorage.setItem('dispensa_lang', lang); + location.reload(); +} + const LOCATIONS = { 'dispensa': { icon: '🗄️', label: 'Dispensa' }, 'frigo': { icon: '🧊', label: 'Frigo' }, @@ -822,21 +939,21 @@ function addAppliance() { const s = getSettings(); if (!s.appliances) s.appliances = []; if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) { - showToast('Elettrodomestico già presente', 'error'); + showToast(t('error.appliance_exists'), 'error'); return; } s.appliances.push(name); saveSettingsToStorage(s); renderAppliances(s.appliances); input.value = ''; - showToast('Elettrodomestico aggiunto', 'success'); + showToast(t('toast.appliance_added'), 'success'); } function addApplianceQuick(name) { const s = getSettings(); if (!s.appliances) s.appliances = []; if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) { - showToast('Già presente', 'error'); + showToast(t('error.already_exists'), 'error'); return; } s.appliances.push(name); @@ -1364,7 +1481,7 @@ function confirmReviewItem(inventoryId) { } }, 300); } - showToast('✓ Quantità confermata', 'success'); + showToast(t('toast.quantity_confirmed'), 'success'); } function editReviewItem(inventoryId, productId) { @@ -1747,7 +1864,7 @@ async function quickUse(productId, location) { } catch (err) { showLoading(false); console.error('quickUse error:', err); - showToast('Errore nel caricamento del prodotto', 'error'); + showToast(t('error.loading'), 'error'); } } @@ -1755,7 +1872,7 @@ async function deleteInventoryItem(id) { if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) { await api('inventory_delete', {}, 'POST', { id }); closeModal(); - showToast('Prodotto rimosso', 'success'); + showToast(t('toast.product_removed'), 'success'); refreshCurrentPage(); } } @@ -1779,7 +1896,7 @@ function editInventoryItem(id) { const item = currentInventory.find(i => i.id === id); if (!item) { closeModal(); - showToast('Prodotto non trovato', 'error'); + showToast(t('error.not_found'), 'error'); return; } @@ -2359,7 +2476,7 @@ function submitManualBarcode() { const input = document.getElementById('manual-barcode-input'); const barcode = (input.value || '').trim(); if (!barcode) { - showToast('Inserisci un codice a barre', 'error'); + showToast(t('error.barcode_empty'), 'error'); input.focus(); return; } @@ -2377,7 +2494,7 @@ async function submitQuickName() { const input = document.getElementById('quick-product-name'); const name = (input.value || '').trim(); if (!name || name.length < 2) { - showToast('Scrivi almeno 2 caratteri', 'error'); + showToast(t('error.min_chars'), 'error'); input.focus(); return; } @@ -2402,7 +2519,7 @@ async function submitQuickName() { } catch (err) { showLoading(false); console.error('Quick name search error:', err); - showToast('Errore nella ricerca', 'error'); + showToast(t('error.search_short'), 'error'); } } @@ -2506,7 +2623,7 @@ async function createQuickProduct(name) { } catch (err) { showLoading(false); console.error('Quick product creation error:', err); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -2794,7 +2911,7 @@ async function submitProduct(e) { } } catch (err) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -3270,7 +3387,7 @@ async function deleteActionInventoryItem(id) { if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) { await api('inventory_delete', {}, 'POST', { id }); closeModal(); - showToast('Prodotto rimosso', 'success'); + showToast(t('toast.product_removed'), 'success'); showProductAction(); // Refresh the action page } } @@ -3371,7 +3488,7 @@ async function throwAll() { } } catch(e) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -3396,7 +3513,7 @@ async function throwPartial() { } } catch(e) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -3412,7 +3529,7 @@ function toggleActionEdit() { async function saveEditedProductInfo() { const name = (document.getElementById('edit-action-name')?.value || '').trim(); if (!name) { - showToast('Inserisci il nome del prodotto', 'error'); + showToast(t('product.name_required'), 'error'); document.getElementById('edit-action-name')?.focus(); return; } @@ -3446,7 +3563,7 @@ async function saveEditedProductInfo() { } } catch (err) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -3915,7 +4032,7 @@ async function submitAdd(e) { } showToast(`✅ ${currentProduct.name} aggiunto!${qtyInfo}`, 'success'); if (result.removed_from_bring) { - setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500); + setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500); } else if (shoppingItems.length > 0 && shoppingListUUID) { // PHP matching may have missed the item (custom name / no catalog match) — // try a client-side fuzzy remove using the already-loaded shoppingItems @@ -3928,7 +4045,7 @@ async function submitAdd(e) { }).then(r => { if (r && r.success) { shoppingItems = shoppingItems.filter(i => i !== match); - setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500); + setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500); } }).catch(() => {}); } @@ -3961,7 +4078,7 @@ async function submitAdd(e) { } } catch (err) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -4384,7 +4501,7 @@ async function addLowStockToBring(productName) { if (data.success && data.added > 0) { showToast('🛒 Aggiunto alla lista della spesa!', 'success'); } else if (data.success && data.skipped > 0) { - showToast('ℹ️ Già nella lista della spesa', 'info'); + showToast(t('shopping.already_in_list_short'), 'info'); } } catch (e) { showToast('Errore nell\'aggiunta a Bring!', 'error'); @@ -4512,7 +4629,7 @@ async function submitUseAll() { } } catch (err) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -4556,7 +4673,7 @@ async function submitUse(e) { } } catch (err) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -4770,12 +4887,12 @@ async function selectAIMatch(idx) { showProductAction(); } else { showLoading(false); - showToast('Errore nel salvataggio', 'error'); + showToast(t('error.save'), 'error'); } } catch (err) { showLoading(false); console.error('AI match select error:', err); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -4804,7 +4921,7 @@ async function saveAIProductDirect() { } } catch (err) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -5062,7 +5179,7 @@ async function selectProductForAction(productId) { showProductAction(); } else { showLoading(false); - showToast('Prodotto non trovato', 'error'); + showToast(t('error.not_found'), 'error'); } } catch (err) { showLoading(false); @@ -5663,7 +5780,7 @@ async function addSmartToBring() { } } catch (e) { showLoading(false); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -6191,7 +6308,7 @@ async function removeBringItem(idx) { if (data.success) { shoppingItems.splice(idx, 1); renderShoppingItems(); - showToast('Rimosso dalla lista', 'success'); + showToast(t('toast.removed_from_list_short'), 'success'); logOperation('bring_manual_remove', { name: item.name }); // Update dashboard shopping count loadShoppingCount(); @@ -6242,7 +6359,7 @@ async function generateSuggestions() { btn.disabled = false; btn.innerHTML = '🤖 Suggerisci cosa comprare'; console.error('Suggestion error:', err); - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -6328,7 +6445,7 @@ async function addSelectedSuggestions() { showToast(data.error || 'Errore', 'error'); } } catch (err) { - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } btn.disabled = false; @@ -7186,7 +7303,7 @@ async function submitRecipeUse(useAll) { console.error('Recipe use error:', err); btn.disabled = false; btn.textContent = '📦 Usa'; - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } _recipeUseContext = null; } @@ -7921,7 +8038,7 @@ async function generateRecipe() { console.error('Recipe error:', err); document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-ask').style.display = ''; - showToast('Errore di connessione', 'error'); + showToast(t('error.connection'), 'error'); } } @@ -8694,6 +8811,198 @@ function initInactivityWatcher() { // ===== INITIALIZATION ===== document.addEventListener('DOMContentLoaded', () => { + // Load translations first, then initialize the app + loadTranslations(_currentLang).then(() => { + _initApp(); + }).catch(() => { + _initApp(); // fallback: initialize even if translations fail + }); +}); + +// ===== SETUP WIZARD ===== +let _setupStep = 0; +const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '' }; + +function _isFirstRun() { + return !localStorage.getItem('dispensa_setup_done'); +} + +function _setupSteps() { + return [ + { + title: '🌐 ' + t('settings.language.label'), + desc: t('settings.language.hint'), + render: () => { + let html = '
'; + for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) { + const sel = code === _setupData.lang ? ' selected' : ''; + html += ``; + } + html += '
'; + return html; + } + }, + { + title: '🤖 Google Gemini AI', + desc: t('settings.gemini.hint'), + render: () => ` +
+ + +

+ → Get a free API key from Google AI Studio +

+
+ ${t('btn.cancel')} — ${_currentLang === 'it' ? 'configura dopo' : 'configure later'} + ` + }, + { + title: '🛒 Bring! Shopping List', + desc: t('settings.bring.hint'), + render: () => ` +
+ + +
+
+ + +
+ ${t('btn.cancel')} — ${_currentLang === 'it' ? 'configura dopo' : 'configure later'} + ` + }, + { + title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : 'All set!'), + desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.' + : _currentLang === 'de' ? 'Die Konfiguration ist abgeschlossen. Du kannst diese Einstellungen jederzeit ändern.' + : 'Setup is complete. You can always change these settings from the Settings page.', + render: () => { + let summary = '
🎉
'; + return summary; + } + } + ]; +} + +function showSetupWizard() { + _setupStep = 0; + document.getElementById('setup-wizard').style.display = ''; + _renderSetupStep(); +} + +function _renderSetupStep() { + const steps = _setupSteps(); + const step = steps[_setupStep]; + + // Progress dots + const dotsHtml = steps.map((_, i) => { + let cls = 'setup-dot'; + if (i < _setupStep) cls += ' done'; + if (i === _setupStep) cls += ' active'; + return `
`; + }).join(''); + document.getElementById('setup-progress').innerHTML = dotsHtml; + + // Body + document.getElementById('setup-body').innerHTML = `

${step.title}

${step.desc}

${step.render()}`; + + // Buttons + const prevBtn = document.getElementById('setup-prev'); + const nextBtn = document.getElementById('setup-next'); + prevBtn.style.display = _setupStep > 0 ? '' : 'none'; + prevBtn.textContent = t('btn.back'); + + if (_setupStep === steps.length - 1) { + nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : '🚀 Start!'; + } else { + nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : 'Next →'; + } +} + +function _setupSelectLang(lang) { + _setupData.lang = lang; + document.querySelectorAll('.setup-lang-btn').forEach(b => b.classList.remove('selected')); + event.target.classList.add('selected'); +} + +function _setupSkipStep() { + _setupStep++; + _renderSetupStep(); +} + +function _setupCollectCurrent() { + if (_setupStep === 1) { + const el = document.getElementById('setup-gemini-key'); + if (el) _setupData.gemini_key = el.value.trim(); + } else if (_setupStep === 2) { + const email = document.getElementById('setup-bring-email'); + const pass = document.getElementById('setup-bring-password'); + if (email) _setupData.bring_email = email.value.trim(); + if (pass) _setupData.bring_password = pass.value.trim(); + } +} + +function setupWizardNav(dir) { + _setupCollectCurrent(); + const steps = _setupSteps(); + + if (dir === 1 && _setupStep === steps.length - 1) { + // Finish wizard + _finishSetup(); + return; + } + + // If language changed, apply it + if (_setupStep === 0 && dir === 1 && _setupData.lang !== _currentLang) { + localStorage.setItem('dispensa_lang', _setupData.lang); + localStorage.setItem('dispensa_setup_step', '1'); + localStorage.setItem('dispensa_setup_data', JSON.stringify(_setupData)); + location.reload(); + return; + } + + _setupStep = Math.max(0, Math.min(steps.length - 1, _setupStep + dir)); + _renderSetupStep(); +} + +async function _finishSetup() { + // Save settings + const s = getSettings(); + if (_setupData.gemini_key) s.gemini_key = _setupData.gemini_key; + if (_setupData.bring_email) s.bring_email = _setupData.bring_email; + if (_setupData.bring_password) s.bring_password = _setupData.bring_password; + saveSettingsToStorage(s); + + // Save server-side settings (.env) + try { + await api('save_settings', {}, 'POST', { + gemini_key: _setupData.gemini_key, + bring_email: _setupData.bring_email, + bring_password: _setupData.bring_password + }); + } catch(e) { /* will work locally */ } + + localStorage.setItem('dispensa_setup_done', '1'); + localStorage.removeItem('dispensa_setup_step'); + localStorage.removeItem('dispensa_setup_data'); + document.getElementById('setup-wizard').style.display = 'none'; +} + +function _initApp() { + // Check for setup wizard resume (after language change) + const resumeStep = localStorage.getItem('dispensa_setup_step'); + const resumeData = localStorage.getItem('dispensa_setup_data'); + if (resumeStep) { + try { Object.assign(_setupData, JSON.parse(resumeData)); } catch(e) {} + _setupStep = parseInt(resumeStep) || 0; + localStorage.removeItem('dispensa_setup_step'); + localStorage.removeItem('dispensa_setup_data'); + document.getElementById('setup-wizard').style.display = ''; + _renderSetupStep(); + } else if (_isFirstRun()) { + showSetupWizard(); + } + // Migrate old session-based flags to time-based if (sessionStorage.getItem('_autoAddedCritical')) { sessionStorage.removeItem('_autoAddedCritical'); @@ -8738,7 +9047,7 @@ document.addEventListener('DOMContentLoaded', () => { // Silent background sync: update urgency specs on Bring and add missing critical items // Runs once at startup (time-gated: max every 10 min) without affecting the UI _backgroundBringSync(); -}); +} /** * Background sync at startup: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fb808bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + dispensa: + build: . + container_name: dispensa + ports: + - "8080:80" + volumes: + # Persist database and runtime data + - dispensa_data:/var/www/html/data + # Mount your local .env configuration + - ./.env:/var/www/html/.env:ro + restart: unless-stopped + environment: + - TZ=Europe/Rome + +volumes: + dispensa_data: + driver: local diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..4e1447e --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,691 @@ +openapi: "3.1.0" +info: + title: Dispensa Manager API + description: | + REST API for Dispensa Manager — a self-hosted pantry management system. + All endpoints use the query parameter `action` to determine the operation. + + **Base URL:** `api/index.php?action={action_name}` + + Rate limits apply: + - General: 120 requests/minute + - AI endpoints: 15 requests/minute + - Login endpoints: 5 requests/minute + version: "1.0.0" + contact: + name: Stimpfl Daniel + email: dadaloop82@gmail.com + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: /api + description: Local server + +paths: + /index.php?action=search_barcode: + get: + summary: Search product by barcode in local database + tags: [Products] + parameters: + - name: barcode + in: query + required: true + schema: + type: string + responses: + "200": + description: Product found + content: + application/json: + schema: + $ref: "#/components/schemas/Product" + "404": + description: Product not found + + /index.php?action=lookup_barcode: + get: + summary: Lookup barcode on Open Food Facts + tags: [Products] + parameters: + - name: barcode + in: query + required: true + schema: + type: string + responses: + "200": + description: Product data from Open Food Facts + + /index.php?action=product_save: + post: + summary: Create or update a product + tags: [Products] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProductInput" + responses: + "200": + description: Product saved + + /index.php?action=product_get: + get: + summary: Get product by ID + tags: [Products] + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + "200": + description: Product details + content: + application/json: + schema: + $ref: "#/components/schemas/Product" + + /index.php?action=product_delete: + post: + summary: Delete a product + tags: [Products] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + responses: + "200": + description: Product deleted + + /index.php?action=products_list: + get: + summary: List all products + tags: [Products] + responses: + "200": + description: Array of all products + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Product" + + /index.php?action=products_search: + get: + summary: Search products by name + tags: [Products] + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + "200": + description: Matching products + + /index.php?action=inventory_list: + get: + summary: List all inventory items + tags: [Inventory] + responses: + "200": + description: Inventory items grouped by product + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/InventoryItem" + + /index.php?action=inventory_add: + post: + summary: Add item to inventory + tags: [Inventory] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [product_id, quantity, location] + properties: + product_id: + type: integer + quantity: + type: number + location: + type: string + enum: [dispensa, frigo, freezer, altro] + expiry_date: + type: string + format: date + conf_size: + type: number + vacuum: + type: boolean + responses: + "200": + description: Item added to inventory + + /index.php?action=inventory_use: + post: + summary: Use/consume items from inventory + tags: [Inventory] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [product_id, quantity, location] + properties: + product_id: + type: integer + quantity: + type: number + location: + type: string + use_all: + type: boolean + responses: + "200": + description: Item consumed + + /index.php?action=inventory_update: + post: + summary: Update an inventory entry + tags: [Inventory] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + quantity: + type: number + location: + type: string + expiry_date: + type: string + format: date + responses: + "200": + description: Inventory entry updated + + /index.php?action=inventory_delete: + post: + summary: Remove an inventory entry + tags: [Inventory] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + responses: + "200": + description: Inventory entry removed + + /index.php?action=inventory_summary: + get: + summary: Get inventory summary (counts per location) + tags: [Inventory] + responses: + "200": + description: Summary object with counts + + /index.php?action=transactions_list: + get: + summary: List operations log + tags: [Log] + parameters: + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + "200": + description: Array of transactions + + /index.php?action=stats: + get: + summary: Get waste/consumption statistics + tags: [Log] + responses: + "200": + description: Statistics for the last 30 days + + /index.php?action=gemini_expiry: + post: + summary: Use AI to read expiry date from image + tags: [AI / Gemini] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + image: + type: string + description: Base64-encoded image + responses: + "200": + description: Parsed expiry date + + /index.php?action=generate_recipe: + post: + summary: Generate a recipe based on available ingredients + tags: [AI / Gemini] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + persons: + type: integer + preferences: + type: object + responses: + "200": + description: Generated recipe + + /index.php?action=gemini_identify: + post: + summary: Identify a product from photo using AI + tags: [AI / Gemini] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + image: + type: string + description: Base64-encoded image + responses: + "200": + description: Identified product data + + /index.php?action=gemini_chat: + post: + summary: Chat with Gemini AI kitchen assistant + tags: [AI / Gemini] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + history: + type: array + items: + type: object + responses: + "200": + description: AI response + + /index.php?action=bring_list: + get: + summary: Get Bring! shopping list + tags: [Bring! Integration] + responses: + "200": + description: Shopping list items + + /index.php?action=bring_add: + post: + summary: Add item to Bring! shopping list + tags: [Bring! Integration] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + spec: + type: string + responses: + "200": + description: Item added + + /index.php?action=bring_remove: + post: + summary: Remove item from Bring! shopping list + tags: [Bring! Integration] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "200": + description: Item removed + + /index.php?action=bring_suggest: + post: + summary: Get AI shopping suggestions + tags: [Bring! Integration] + responses: + "200": + description: AI-generated shopping suggestions + + /index.php?action=smart_shopping: + get: + summary: Get smart shopping predictions + tags: [Bring! Integration] + responses: + "200": + description: Predicted shopping needs + + /index.php?action=save_settings: + post: + summary: Save server-side settings (.env values) + tags: [Settings] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings saved + + /index.php?action=get_settings: + get: + summary: Get server settings (masked passwords) + tags: [Settings] + responses: + "200": + description: Current settings + + /index.php?action=app_settings_get: + get: + summary: Get application settings from database + tags: [Settings] + responses: + "200": + description: App settings object + + /index.php?action=app_settings_save: + post: + summary: Save application settings to database + tags: [Settings] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: App settings saved + + /index.php?action=recipes_list: + get: + summary: List saved recipes + tags: [Recipes] + responses: + "200": + description: Array of saved recipes + + /index.php?action=recipes_save: + post: + summary: Save a recipe + tags: [Recipes] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Recipe saved + + /index.php?action=recipes_delete: + post: + summary: Delete a recipe + tags: [Recipes] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + responses: + "200": + description: Recipe deleted + + /index.php?action=dupliclick_login: + post: + summary: Login to DupliClick (online shopping) + tags: [DupliClick] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + responses: + "200": + description: Login successful + + /index.php?action=dupliclick_search: + get: + summary: Search DupliClick product catalog + tags: [DupliClick] + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + "200": + description: Search results + + /index.php?action=tts_proxy: + post: + summary: Proxy TTS request to external endpoint + tags: [TTS] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + text: + type: string + responses: + "200": + description: TTS request sent + + /index.php?action=expiry_history: + get: + summary: Get expiry scan history for a product + tags: [Inventory] + parameters: + - name: product_id + in: query + required: true + schema: + type: integer + responses: + "200": + description: Expiry history entries + +components: + schemas: + Product: + type: object + properties: + id: + type: integer + name: + type: string + brand: + type: string + barcode: + type: string + category: + type: string + enum: + - latticini + - carne + - pesce + - frutta + - verdura + - pasta + - pane + - surgelati + - bevande + - condimenti + - snack + - conserve + - cereali + - igiene + - pulizia + - altro + unit: + type: string + enum: [pz, conf, g, ml] + default_quantity: + type: number + package_size: + type: number + package_unit: + type: string + notes: + type: string + created_at: + type: string + format: date-time + + ProductInput: + type: object + required: [name] + properties: + id: + type: integer + description: If provided, updates existing product + name: + type: string + brand: + type: string + barcode: + type: string + category: + type: string + unit: + type: string + default_quantity: + type: number + package_size: + type: number + + InventoryItem: + type: object + properties: + id: + type: integer + product_id: + type: integer + product_name: + type: string + quantity: + type: number + location: + type: string + expiry_date: + type: string + format: date + opened: + type: boolean + created_at: + type: string + format: date-time + + responses: + TooManyRequests: + description: Rate limit exceeded + headers: + Retry-After: + schema: + type: integer + content: + application/json: + schema: + type: object + properties: + error: + type: string + +tags: + - name: Products + description: Product catalog management + - name: Inventory + description: Inventory tracking (stock in/out) + - name: Log + description: Operations log and statistics + - name: AI / Gemini + description: Google Gemini AI integration (identification, recipes, chat) + - name: Bring! Integration + description: Bring! shopping list integration + - name: Recipes + description: Recipe storage + - name: Settings + description: Application and server settings + - name: DupliClick + description: DupliClick online shopping integration + - name: TTS + description: Text-to-Speech proxy diff --git a/index.html b/index.html index f331d93..b911b08 100644 --- a/index.html +++ b/index.html @@ -20,12 +20,12 @@
-

🏠 Dispensa

+

🏠 Dispensa

- -
@@ -41,22 +41,22 @@
🗄️ 0 - Dispensa + Dispensa
🧊 0 - Frigo + Frigo
❄️ 0 - Freezer + Freezer
🛒 - - Spesa + Spesa
@@ -65,39 +65,39 @@ @@ -106,18 +106,18 @@
- +
@@ -125,15 +125,15 @@
@@ -147,7 +147,7 @@
- +
@@ -504,11 +504,11 @@
-
@@ -518,12 +518,12 @@
-
Connessione a Bring!...
+
Connessione a Bring!...
@@ -541,7 +541,7 @@ + + +
diff --git a/translations/de.json b/translations/de.json new file mode 100644 index 0000000..bbc7ed5 --- /dev/null +++ b/translations/de.json @@ -0,0 +1,431 @@ +{ + "app": { + "name": "Dispensa Manager", + "loading": "Laden..." + }, + "nav": { + "title": "🏠 Vorratskammer", + "home": "Home", + "inventory": "Vorrat", + "recipes": "Rezepte", + "shopping": "Einkauf", + "log": "Log" + }, + "btn": { + "back": "← Zurück", + "save": "💾 Speichern", + "cancel": "✕ Abbrechen", + "close": "Schließen", + "add": "✅ Hinzufügen", + "delete": "Löschen", + "edit": "✏️ Bearbeiten", + "search": "🔍 Suchen", + "go": "✅ Los", + "toggle_password": "👁️ Anzeigen/Ausblenden", + "load_more": "Mehr laden...", + "save_config": "💾 Konfiguration speichern", + "save_product": "💾 Produkt speichern", + "restart": "↺ Neustart", + "reset_default": "↺ Standard wiederherstellen" + }, + "locations": { + "dispensa": "Vorratskammer", + "frigo": "Kühlschrank", + "freezer": "Gefrierschrank", + "altro": "Sonstiges" + }, + "categories": { + "latticini": "Milchprodukte", + "carne": "Fleisch", + "pesce": "Fisch", + "frutta": "Obst", + "verdura": "Gemüse", + "pasta": "Pasta & Reis", + "pane": "Brot & Backwaren", + "surgelati": "Tiefkühl", + "bevande": "Getränke", + "condimenti": "Gewürze", + "snack": "Snacks & Süßes", + "conserve": "Konserven", + "cereali": "Getreide & Hülsenfrüchte", + "igiene": "Hygiene", + "pulizia": "Reinigung", + "altro": "Sonstiges", + "select": "-- Auswählen --" + }, + "units": { + "pz": "Stk", + "conf": "Pkg", + "g": "g", + "ml": "ml", + "pieces": "Stück", + "grams": "Gramm", + "box": "Packung", + "boxes": "Packungen" + }, + "shopping_sections": { + "frutta_verdura": "Obst & Gemüse", + "carne_pesce": "Fleisch & Fisch", + "latticini": "Milchprodukte & Frisches", + "pane_dolci": "Brot & Süßes", + "pasta": "Pasta & Getreide", + "conserve": "Konserven & Soßen", + "surgelati": "Tiefkühl", + "bevande": "Getränke", + "pulizia_igiene": "Reinigung & Hygiene", + "altro": "Sonstiges" + }, + "dashboard": { + "expired_title": "🚫 Abgelaufen", + "expiring_title": "⏰ Bald ablaufend", + "stats_period": "📊 Letzte 30 Tage", + "opened_title": "📦 Geöffnete Produkte", + "review_title": "🔍 Zu prüfen", + "review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.", + "quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten" + }, + "inventory": { + "title": "Vorrat", + "filter_all": "Alle", + "search_placeholder": "🔍 Produkt suchen...", + "empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!", + "no_items_found": "Keine Bestandseinträge gefunden" + }, + "scan": { + "title": "Produkt scannen", + "mode_shopping": "🛒 Einkaufsmodus", + "mode_shopping_end": "✅ Einkauf beenden", + "zoom": "Zoom", + "barcode_placeholder": "Barcode eingeben...", + "quick_name_divider": "oder Name eingeben", + "quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...", + "manual_entry": "✏️ Manuelle Eingabe", + "ai_identify": "🤖 Mit KI identifizieren", + "hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen", + "debug_toggle": "🐛 Debug Log", + "barcode_acquired": "🔖 Barcode gescannt: {code}", + "scan_barcode": "🔖 Barcode scannen" + }, + "action": { + "title": "Was möchtest du tun?", + "add_btn": "📥 HINZUFÜGEN", + "add_sub": "in Vorrat/Kühlschrank", + "use_btn": "📤 VERWENDEN / VERBRAUCHEN", + "use_sub": "aus Vorrat/Kühlschrank" + }, + "add": { + "title": "Zum Vorrat hinzufügen", + "location_label": "📍 Wohin?", + "quantity_label": "📦 Menge", + "conf_size_label": "📦 Jede Packung enthält:", + "conf_size_placeholder": "z.B. 300", + "vacuum_label": "🫙 Vakuumiert", + "vacuum_hint": "Ablaufdatum wird automatisch verlängert", + "submit": "✅ Hinzufügen" + }, + "use": { + "title": "Verwenden / Verbrauchen", + "location_label": "📍 Woher?", + "quantity_label": "Wie viel hast du benutzt?", + "partial_hint": "Oder genaue Menge angeben:", + "use_all": "🗑️ ALLES verwendet / Aufgebraucht", + "submit": "📤 Diese Menge verwenden", + "available": "📦 Verfügbar:", + "not_in_inventory": "⚠️ Produkt nicht im Bestand.", + "expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!" + }, + "product": { + "title_new": "Neues Produkt", + "title_edit": "Produkt bearbeiten", + "ai_fill": "📷 Foto machen und mit KI identifizieren", + "ai_fill_hint": "KI füllt die Produktfelder automatisch aus", + "name_label": "🏷️ Produktname *", + "name_placeholder": "z.B.: Vollmilch, Penne Nudeln...", + "brand_label": "🏢 Marke", + "brand_placeholder": "z.B.: Barilla, Müller, Knorr...", + "category_label": "📂 Kategorie", + "unit_label": "📏 Maßeinheit", + "default_qty_label": "🔢 Standardmenge", + "conf_size_label": "📦 Jede Packung enthält:", + "conf_size_placeholder": "z.B. 300", + "notes_label": "📝 Notizen", + "notes_placeholder": "z.B.: laktosefrei, bio, nach dem Öffnen im Kühlschrank aufbewahren...", + "barcode_label": "🔖 Barcode", + "barcode_placeholder": "Barcode (falls vorhanden)", + "barcode_hint": "⚠️ Barcode hinzufügen, damit du beim nächsten Einkauf nur scannen musst!", + "submit": "💾 Produkt speichern", + "name_required": "Produktname eingeben", + "conf_size_required": "Packungsinhalt angeben", + "expiry_estimated": "Geschätztes Ablaufdatum:", + "scan_expiry": "Ablaufdatum scannen", + "expiry_hint": "📝 Du kannst das Datum ändern oder mit der Kamera scannen", + "add_batch": "📦 + Charge mit anderem Ablaufdatum", + "package_info": "📦 Packung: {info}", + "edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)", + "not_recognized": "⚠️ Produkt nicht erkannt", + "edit_info": "✏️ Informationen bearbeiten", + "modify_details": "BEARBEITEN\nAblauf, Ort…" + }, + "products": { + "title": "📦 Alle Produkte", + "search_placeholder": "🔍 Produkt suchen...", + "empty": "Keine Produkte in der Datenbank.\nScanne ein Produkt zum Starten!", + "no_category": "Keine Produkte in dieser Kategorie" + }, + "recipes": { + "title": "🍳 Rezepte", + "generate": "✨ Neues Rezept generieren" + }, + "shopping": { + "title": "🛒 Einkaufsliste", + "bring_loading": "Verbindung zu Bring!...", + "tab_to_buy": "🛍️ Zu kaufen", + "tab_forecast": "🧠 Vorhersage", + "total_label": "💰 Geschätzter Gesamtbetrag", + "section_to_buy": "🛍️ Zu kaufen", + "suggestions_title": "💡 KI-Vorschläge", + "suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen", + "search_prices": "🔍 Alle Preise suchen", + "suggest_btn": "🤖 Einkaufsvorschläge", + "smart_title": "🧠 Intelligente Vorhersagen", + "smart_empty": "Keine Vorhersagen verfügbar.
Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.", + "smart_filter_all": "Alle", + "smart_filter_critical": "🔴 Dringend", + "smart_filter_high": "🟠 Bald", + "smart_filter_medium": "🟡 Planen", + "smart_filter_low": "🟢 Vorhersage", + "smart_add": "🛒 Ausgewählte zu Bring! hinzufügen", + "empty": "Einkaufsliste leer!\nNutze den Button unten, um Vorschläge zu generieren.", + "already_in_list": "🛒 \"{name}\" ist bereits in der Einkaufsliste", + "already_in_list_short": "ℹ️ Bereits in der Einkaufsliste", + "add_prompt": "Möchtest du es zur Einkaufsliste hinzufügen?", + "smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus", + "all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.", + "search_complete": "Suche abgeschlossen: {count} Produkte", + "removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt" + }, + "ai": { + "title": "🤖 KI-Identifikation", + "capture": "📸 Foto aufnehmen", + "retake": "🔄 Neu aufnehmen", + "hint": "Mache ein Foto des Produkts und die KI versucht es zu identifizieren", + "identifying": "🤖 Identifiziere Produkt...", + "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\nFüge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.", + "fields_filled": "✅ Felder von KI ausgefüllt" + }, + "log": { + "title": "📒 Operationslog" + }, + "chat": { + "title": "Gemini Chef", + "welcome": "Hallo! Ich bin dein Küchenassistent", + "welcome_desc": "Frag mich, dir einen Saft, einen Snack, ein schnelles Gericht zu machen... Ich kenne deinen Vorrat, deine Geräte und deine Vorlieben!", + "suggestion_snack": "🍿 Schneller Snack", + "suggestion_juice": "🥤 Saft/Smoothie", + "suggestion_light": "🥗 Etwas Leichtes", + "suggestion_expiry": "⏰ Ablaufende nutzen", + "clear": "Neues Gespräch", + "placeholder": "Frag etwas..." + }, + "cooking": { + "close": "Schließen", + "tts_btn": "Vorlesen", + "restart": "↺ Neustart", + "replay": "🔊 Nochmal", + "timer": "⏱️ {time} · Timer", + "prev": "◀ Zurück", + "next": "Weiter ▶" + }, + "settings": { + "title": "⚙️ Einstellungen", + "tab_api": "API Keys", + "tab_bring": "Bring!", + "tab_recipe": "Rezepte", + "tab_mealplan": "Wochenplan", + "tab_appliances": "Geräte", + "tab_spesa": "Online-Einkauf", + "tab_camera": "Kamera", + "tab_security": "Sicherheit", + "tab_tts": "Sprache (TTS)", + "tab_language": "Sprache", + "gemini": { + "title": "🤖 Google Gemini AI", + "hint": "API-Schlüssel für Produkterkennung, Ablaufdaten und Rezepte.", + "key_label": "Gemini API Key" + }, + "bring": { + "title": "🛒 Bring! Einkaufsliste", + "hint": "Zugangsdaten für die Bring! Einkaufslisten-Integration.", + "email_label": "📧 Bring! E-Mail", + "password_label": "🔒 Bring! Passwort" + }, + "recipe": { + "title": "🍳 Rezept-Einstellungen", + "hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.", + "persons_label": "👥 Standard-Portionen", + "options_label": "🎯 Standard-Rezeptoptionen", + "fast": "⚡ Schnelles Gericht", + "light": "🥗 Leichte Mahlzeit", + "expiry": "⏰ Ablauf-Priorität", + "healthy": "💚 Extra Gesund", + "opened": "📦 Offene Produkte zuerst", + "zerowaste": "♻️ Keine Verschwendung", + "dietary_label": "🚫 Unverträglichkeiten / Einschränkungen", + "dietary_placeholder": "z.B.: glutenfrei, laktosefrei, vegetarisch..." + }, + "mealplan": { + "title": "📅 Wöchentlicher Essensplan", + "hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.", + "enabled": "✅ Wöchentlichen Essensplan aktivieren", + "legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.", + "types_title": "📋 Verfügbare Typen" + }, + "appliances": { + "title": "🔌 Verfügbare Geräte", + "hint": "Gib an, welche Geräte du hast. Sie werden bei der Rezeptgenerierung berücksichtigt.", + "new_placeholder": "z.B.: Brotbackmaschine, Thermomix, Heißluftfritteuse...", + "quick_title": "Schnell hinzufügen:", + "oven": "🔥 Backofen", + "microwave": "📡 Mikrowelle", + "air_fryer": "🍟 Heißluftfritteuse", + "bread_maker": "🍞 Brotbackmaschine", + "bimby": "🤖 Thermomix/Cookeo", + "mixer": "🌀 Küchenmaschine", + "steamer": "♨️ Dampfgarer", + "pressure_cooker": "🫕 Schnellkochtopf", + "toaster": "🍞 Toaster", + "blender": "🍹 Mixer", + "empty": "Keine Geräte hinzugefügt" + }, + "spesa": { + "title": "🛍️ Online-Einkauf", + "hint": "Online-Einkaufsanbieter konfigurieren.", + "provider_label": "🏪 Anbieter", + "email_label": "📧 E-Mail", + "password_label": "🔒 Passwort", + "login_btn": "🔐 Anmelden", + "ai_prompt_label": "🤖 KI-Produktauswahl Prompt", + "ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...", + "ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.", + "configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen" + }, + "camera": { + "title": "📷 Kamera", + "hint": "Wähle die Kamera für Barcode-Scanning und KI-Identifikation.", + "device_label": "📸 Standardkamera", + "back": "📱 Rückkamera (Standard)", + "front": "🤳 Frontkamera", + "devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.", + "detect_btn": "🔄 Kameras erkennen" + }, + "security": { + "title": "🔒 HTTPS-Zertifikat", + "hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.", + "download_btn": "📥 CA-Zertifikat herunterladen" + }, + "tts": { + "title": "🔊 Sprache & TTS", + "hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.", + "enabled": "✅ TTS aktivieren", + "url_label": "🌐 Endpunkt-URL", + "method_label": "📡 HTTP-Methode", + "auth_label": "🔐 Authentifizierung", + "auth_bearer": "Bearer Token", + "auth_custom": "Benutzerdefinierter Header", + "auth_none": "Keine", + "token_label": "🔑 Bearer Token", + "custom_header_name": "📋 Header-Name", + "custom_header_value": "📋 Header-Wert", + "content_type_label": "📄 Content-Type", + "payload_key_label": "🗝️ Textfeld im Payload", + "payload_key_hint": "Name des JSON-Feldes für den zu lesenden Text (z.B.: message, text).", + "extra_fields_label": "➕ Zusätzliche Felder (JSON)", + "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", + "extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.", + "test_btn": "🔊 Testansage senden" + }, + "language": { + "title": "🌐 Sprache", + "hint": "Wähle die Sprache der Benutzeroberfläche.", + "label": "🌐 Sprache", + "restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden." + }, + "saved": "✅ Konfiguration gespeichert!", + "saved_local": "✅ Konfiguration lokal gespeichert", + "saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}" + }, + "expiry": { + "today": "HEUTE", + "tomorrow": "Morgen", + "days": "{days} Tage", + "expired_days": "Seit {days}T", + "expired_yesterday": "Seit gestern", + "expired_today": "Heute" + }, + "status": { + "ok": "OK", + "check": "Prüfen", + "discard": "Entsorgen" + }, + "toast": { + "product_saved": "Produkt gespeichert!", + "product_created": "Produkt erstellt!", + "product_updated": "✅ Produkt aktualisiert!", + "product_removed": "Produkt entfernt", + "updated": "Aktualisiert!", + "quantity_confirmed": "✓ Menge bestätigt", + "added_to_inventory": "✅ {name} hinzugefügt!", + "removed_from_list": "✅ {name} von der Liste entfernt!", + "removed_from_list_short": "Von der Liste entfernt", + "added_to_shopping": "🛒 Zur Einkaufsliste hinzugefügt!", + "removed_from_shopping": "🛒 Von der Einkaufsliste entfernt", + "finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", + "thrown_away": "🗑️ {name} weggeworfen!", + "thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen", + "appliance_added": "Gerät hinzugefügt", + "item_added": "{name} hinzugefügt" + }, + "error": { + "generic": "Fehler", + "loading": "Fehler beim Laden des Produkts", + "not_found": "Produkt nicht gefunden", + "not_found_manual": "Produkt nicht gefunden. Manuell eingeben.", + "search": "Suchfehler. Nochmal versuchen.", + "search_short": "Suchfehler", + "save": "Fehler beim Speichern", + "connection": "Verbindungsfehler", + "camera": "Kamera nicht verfügbar", + "bring_add": "Fehler beim Hinzufügen zu Bring!", + "bring_connection": "Bring! Verbindungsfehler", + "identification": "Identifikationsfehler", + "barcode_empty": "Barcode eingeben", + "barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)", + "min_chars": "Mindestens 2 Zeichen eingeben", + "not_in_inventory": "Produkt nicht im Bestand", + "appliance_exists": "Gerät bereits vorhanden", + "already_exists": "Bereits vorhanden" + }, + "confirm": { + "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?" + }, + "edit": { + "title": "{name} bearbeiten" + }, + "screensaver": { + "recipe_btn": "Rezepte", + "scan_btn": "Produkt scannen" + }, + "days": { + "mon": "Montag", + "tue": "Dienstag", + "wed": "Mittwoch", + "thu": "Donnerstag", + "fri": "Freitag", + "sat": "Samstag", + "sun": "Sonntag" + }, + "meal_types": { + "lunch": "Mittagessen", + "dinner": "Abendessen" + } +} diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..ccee750 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,431 @@ +{ + "app": { + "name": "Dispensa Manager", + "loading": "Loading..." + }, + "nav": { + "title": "🏠 Pantry", + "home": "Home", + "inventory": "Pantry", + "recipes": "Recipes", + "shopping": "Shopping", + "log": "Log" + }, + "btn": { + "back": "← Back", + "save": "💾 Save", + "cancel": "✕ Cancel", + "close": "Close", + "add": "✅ Add", + "delete": "Delete", + "edit": "✏️ Edit", + "search": "🔍 Search", + "go": "✅ Go", + "toggle_password": "👁️ Show/Hide", + "load_more": "Load more...", + "save_config": "💾 Save Configuration", + "save_product": "💾 Save Product", + "restart": "↺ Restart", + "reset_default": "↺ Reset to default" + }, + "locations": { + "dispensa": "Pantry", + "frigo": "Fridge", + "freezer": "Freezer", + "altro": "Other" + }, + "categories": { + "latticini": "Dairy", + "carne": "Meat", + "pesce": "Fish", + "frutta": "Fruit", + "verdura": "Vegetables", + "pasta": "Pasta & Rice", + "pane": "Bread & Bakery", + "surgelati": "Frozen", + "bevande": "Beverages", + "condimenti": "Condiments", + "snack": "Snacks & Sweets", + "conserve": "Canned Goods", + "cereali": "Cereals & Legumes", + "igiene": "Hygiene", + "pulizia": "Household", + "altro": "Other", + "select": "-- Select --" + }, + "units": { + "pz": "pcs", + "conf": "pkg", + "g": "g", + "ml": "ml", + "pieces": "Pieces", + "grams": "Grams", + "box": "Package", + "boxes": "Packages" + }, + "shopping_sections": { + "frutta_verdura": "Fruits & Vegetables", + "carne_pesce": "Meat & Fish", + "latticini": "Dairy & Fresh", + "pane_dolci": "Bread & Sweets", + "pasta": "Pasta & Cereals", + "conserve": "Canned & Sauces", + "surgelati": "Frozen", + "bevande": "Beverages", + "pulizia_igiene": "Cleaning & Hygiene", + "altro": "Other" + }, + "dashboard": { + "expired_title": "🚫 Expired", + "expiring_title": "⏰ Expiring Soon", + "stats_period": "📊 Last 30 days", + "opened_title": "📦 Opened Products", + "review_title": "🔍 To Review", + "review_hint": "Quantities that seem unusual. Confirm if correct or modify.", + "quick_recipe": "🍳 Quick recipe with expiring products" + }, + "inventory": { + "title": "Pantry", + "filter_all": "All", + "search_placeholder": "🔍 Search product...", + "empty": "No products here.\nScan a product to add it!", + "no_items_found": "No inventory items found" + }, + "scan": { + "title": "Scan Product", + "mode_shopping": "🛒 Shopping Mode", + "mode_shopping_end": "✅ End shopping", + "zoom": "Zoom", + "barcode_placeholder": "Enter barcode...", + "quick_name_divider": "or type the name", + "quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...", + "manual_entry": "✏️ Manual Entry", + "ai_identify": "🤖 Identify with AI", + "hint": "Scan the barcode, type the product name, or use AI to identify it", + "debug_toggle": "🐛 Debug Log", + "barcode_acquired": "🔖 Barcode scanned: {code}", + "scan_barcode": "🔖 Scan Barcode" + }, + "action": { + "title": "What do you want to do?", + "add_btn": "📥 ADD", + "add_sub": "to pantry/fridge", + "use_btn": "📤 USE / CONSUME", + "use_sub": "from pantry/fridge" + }, + "add": { + "title": "Add to Pantry", + "location_label": "📍 Where do you put it?", + "quantity_label": "📦 Quantity", + "conf_size_label": "📦 Each package contains:", + "conf_size_placeholder": "e.g. 300", + "vacuum_label": "🫙 Vacuum sealed", + "vacuum_hint": "Expiry date will be extended automatically", + "submit": "✅ Add" + }, + "use": { + "title": "Use / Consume", + "location_label": "📍 From where?", + "quantity_label": "How much did you use?", + "partial_hint": "Or specify the quantity used:", + "use_all": "🗑️ Used ALL / Finished", + "submit": "📤 Use this quantity", + "available": "📦 Available:", + "not_in_inventory": "⚠️ Product not in inventory.", + "expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!" + }, + "product": { + "title_new": "New Product", + "title_edit": "Edit Product", + "ai_fill": "📷 Take photo and identify with AI", + "ai_fill_hint": "AI will automatically fill in the product fields", + "name_label": "🏷️ Product Name *", + "name_placeholder": "E.g.: Whole milk, Penne pasta...", + "brand_label": "🏢 Brand", + "brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...", + "category_label": "📂 Category", + "unit_label": "📏 Unit of measure", + "default_qty_label": "🔢 Default quantity", + "conf_size_label": "📦 Each package contains:", + "conf_size_placeholder": "e.g. 300", + "notes_label": "📝 Notes", + "notes_placeholder": "E.g.: lactose free, organic, store in fridge after opening...", + "barcode_label": "🔖 Barcode", + "barcode_placeholder": "Barcode (if available)", + "barcode_hint": "⚠️ Add the barcode so next time you just need to scan it!", + "submit": "💾 Save Product", + "name_required": "Enter the product name", + "conf_size_required": "Specify the package content", + "expiry_estimated": "Estimated expiry:", + "scan_expiry": "Scan expiry date", + "expiry_hint": "📝 You can edit the date or scan it with the camera", + "add_batch": "📦 + Batch with different expiry", + "package_info": "📦 Package: {info}", + "edit_catalog": "⚙️ Edit product info (name, brand, category…)", + "not_recognized": "⚠️ Product not recognized", + "edit_info": "✏️ Edit information", + "modify_details": "EDIT\nexpiry, location…" + }, + "products": { + "title": "📦 All Products", + "search_placeholder": "🔍 Search product...", + "empty": "No products in database.\nScan a product to get started!", + "no_category": "No products in this category" + }, + "recipes": { + "title": "🍳 Recipes", + "generate": "✨ Generate new recipe" + }, + "shopping": { + "title": "🛒 Shopping List", + "bring_loading": "Connecting to Bring!...", + "tab_to_buy": "🛍️ To buy", + "tab_forecast": "🧠 Forecast", + "total_label": "💰 Estimated total", + "section_to_buy": "🛍️ To buy", + "suggestions_title": "💡 AI Suggestions", + "suggestions_add": "✅ Add selected to Bring!", + "search_prices": "🔍 Search all prices", + "suggest_btn": "🤖 Suggest what to buy", + "smart_title": "🧠 Smart Predictions", + "smart_empty": "No predictions available.
Add products to your pantry to receive smart predictions.", + "smart_filter_all": "All", + "smart_filter_critical": "🔴 Urgent", + "smart_filter_high": "🟠 Soon", + "smart_filter_medium": "🟡 Plan", + "smart_filter_low": "🟢 Forecast", + "smart_add": "🛒 Add selected to Bring!", + "empty": "Shopping list empty!\nUse the button below to generate suggestions.", + "already_in_list": "🛒 \"{name}\" is already in the shopping list", + "already_in_list_short": "ℹ️ Already in the shopping list", + "add_prompt": "Do you want to add it to the shopping list?", + "smart_already": "📊 Smart shopping already predicts {name}", + "all_searched": "All products have already been searched. Use 🔄 to search individual ones.", + "search_complete": "Search complete: {count} products", + "removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list" + }, + "ai": { + "title": "🤖 AI Identification", + "capture": "📸 Take Photo", + "retake": "🔄 Retake", + "hint": "Take a photo of the product and AI will try to identify it", + "identifying": "🤖 Identifying product...", + "no_api_key": "⚠️ Gemini API key not configured.\nAdd GEMINI_API_KEY to the .env file on the server.", + "fields_filled": "✅ Fields filled by AI" + }, + "log": { + "title": "📒 Operations Log" + }, + "chat": { + "title": "Gemini Chef", + "welcome": "Hi! I'm your kitchen assistant", + "welcome_desc": "Ask me to make you a juice, a snack, a quick dish... I know your pantry, your appliances and your preferences!", + "suggestion_snack": "🍿 Quick snack", + "suggestion_juice": "🥤 Juice/Smoothie", + "suggestion_light": "🥗 Something light", + "suggestion_expiry": "⏰ Use expiring items", + "clear": "New conversation", + "placeholder": "Ask something..." + }, + "cooking": { + "close": "Close", + "tts_btn": "Read aloud", + "restart": "↺ Restart", + "replay": "🔊 Replay", + "timer": "⏱️ {time} · Timer", + "prev": "◀ Previous", + "next": "Next ▶" + }, + "settings": { + "title": "⚙️ Settings", + "tab_api": "API Keys", + "tab_bring": "Bring!", + "tab_recipe": "Recipes", + "tab_mealplan": "Weekly Plan", + "tab_appliances": "Appliances", + "tab_spesa": "Online Shopping", + "tab_camera": "Camera", + "tab_security": "Security", + "tab_tts": "Voice (TTS)", + "tab_language": "Language", + "gemini": { + "title": "🤖 Google Gemini AI", + "hint": "API key for product identification, expiry dates and recipes.", + "key_label": "Gemini API Key" + }, + "bring": { + "title": "🛒 Bring! Shopping List", + "hint": "Credentials for the Bring! shopping list integration.", + "email_label": "📧 Bring! Email", + "password_label": "🔒 Bring! Password" + }, + "recipe": { + "title": "🍳 Recipe Preferences", + "hint": "Configure the default options for recipe generation.", + "persons_label": "👥 Default servings", + "options_label": "🎯 Default recipe options", + "fast": "⚡ Quick Meal", + "light": "🥗 Light Meal", + "expiry": "⏰ Expiry Priority", + "healthy": "💚 Extra Healthy", + "opened": "📦 Open Items Priority", + "zerowaste": "♻️ Zero Waste", + "dietary_label": "🚫 Intolerances / Restrictions", + "dietary_placeholder": "E.g.: gluten free, lactose free, vegetarian..." + }, + "mealplan": { + "title": "📅 Weekly Meal Plan", + "hint": "Set the meal type for each day. It will be used as a guide in recipe generation.", + "enabled": "✅ Enable weekly meal plan", + "legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.", + "types_title": "📋 Available types" + }, + "appliances": { + "title": "🔌 Available Appliances", + "hint": "Indicate the appliances you have. They will be considered in recipe generation.", + "new_placeholder": "E.g.: Bread machine, Thermomix, Air fryer...", + "quick_title": "Quick add:", + "oven": "🔥 Oven", + "microwave": "📡 Microwave", + "air_fryer": "🍟 Air fryer", + "bread_maker": "🍞 Bread maker", + "bimby": "🤖 Thermomix/Cookeo", + "mixer": "🌀 Stand mixer", + "steamer": "♨️ Steamer", + "pressure_cooker": "🫕 Pressure cooker", + "toaster": "🍞 Toaster", + "blender": "🍹 Blender", + "empty": "No appliances added" + }, + "spesa": { + "title": "🛍️ Online Shopping", + "hint": "Configure the online shopping provider.", + "provider_label": "🏪 Provider", + "email_label": "📧 Email", + "password_label": "🔒 Password", + "login_btn": "🔐 Login", + "ai_prompt_label": "🤖 AI product selection prompt", + "ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...", + "ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.", + "configure_first": "Configure Online Shopping in settings first" + }, + "camera": { + "title": "📷 Camera", + "hint": "Choose which camera to use for barcode scanning and AI identification.", + "device_label": "📸 Default camera", + "back": "📱 Rear (default)", + "front": "🤳 Front", + "devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.", + "detect_btn": "🔄 Detect cameras" + }, + "security": { + "title": "🔒 HTTPS Certificate", + "hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.", + "download_btn": "📥 Download CA Certificate" + }, + "tts": { + "title": "🔊 Voice & TTS", + "hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.", + "enabled": "✅ Enable TTS", + "url_label": "🌐 Endpoint URL", + "method_label": "📡 HTTP Method", + "auth_label": "🔐 Authentication", + "auth_bearer": "Bearer Token", + "auth_custom": "Custom Header", + "auth_none": "None", + "token_label": "🔑 Bearer Token", + "custom_header_name": "📋 Header name", + "custom_header_value": "📋 Header value", + "content_type_label": "📄 Content-Type", + "payload_key_label": "🗝️ Text field in payload", + "payload_key_hint": "Name of the JSON field that will contain the text to read (e.g.: message, text).", + "extra_fields_label": "➕ Extra fields (JSON)", + "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", + "extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.", + "test_btn": "🔊 Send Test Voice" + }, + "language": { + "title": "🌐 Language", + "hint": "Select the interface language.", + "label": "🌐 Language", + "restart_notice": "The page will reload to apply the new language." + }, + "saved": "✅ Configuration saved!", + "saved_local": "✅ Configuration saved locally", + "saved_local_error": "⚠️ Saved locally, server error: {error}" + }, + "expiry": { + "today": "TODAY", + "tomorrow": "Tomorrow", + "days": "{days} days", + "expired_days": "{days}d ago", + "expired_yesterday": "Yesterday", + "expired_today": "Today" + }, + "status": { + "ok": "OK", + "check": "Check", + "discard": "Discard" + }, + "toast": { + "product_saved": "Product saved!", + "product_created": "Product created!", + "product_updated": "✅ Product updated!", + "product_removed": "Product removed", + "updated": "Updated!", + "quantity_confirmed": "✓ Quantity confirmed", + "added_to_inventory": "✅ {name} added!", + "removed_from_list": "✅ {name} removed from the list!", + "removed_from_list_short": "Removed from the list", + "added_to_shopping": "🛒 Added to the shopping list!", + "removed_from_shopping": "🛒 Removed from the shopping list", + "finished_to_bring": "🛒 Product finished → added to Bring!", + "thrown_away": "🗑️ {name} thrown away!", + "thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}", + "appliance_added": "Appliance added", + "item_added": "{name} added" + }, + "error": { + "generic": "Error", + "loading": "Error loading product", + "not_found": "Product not found", + "not_found_manual": "Product not found. Enter it manually.", + "search": "Search error. Try again.", + "search_short": "Search error", + "save": "Error saving", + "connection": "Connection error", + "camera": "Cannot access camera", + "bring_add": "Error adding to Bring!", + "bring_connection": "Bring! connection error", + "identification": "Identification error", + "barcode_empty": "Enter a barcode", + "barcode_format": "Barcode must contain only numbers (4-14 digits)", + "min_chars": "Type at least 2 characters", + "not_in_inventory": "Product not in inventory", + "appliance_exists": "Appliance already exists", + "already_exists": "Already exists" + }, + "confirm": { + "remove_item": "Do you really want to remove this product from inventory?" + }, + "edit": { + "title": "Edit {name}" + }, + "screensaver": { + "recipe_btn": "Recipes", + "scan_btn": "Scan product" + }, + "days": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + }, + "meal_types": { + "lunch": "Lunch", + "dinner": "Dinner" + } +} diff --git a/translations/it.json b/translations/it.json new file mode 100644 index 0000000..3dfc770 --- /dev/null +++ b/translations/it.json @@ -0,0 +1,431 @@ +{ + "app": { + "name": "Dispensa Manager", + "loading": "Caricamento..." + }, + "nav": { + "title": "🏠 Dispensa", + "home": "Home", + "inventory": "Dispensa", + "recipes": "Ricette", + "shopping": "Spesa", + "log": "Log" + }, + "btn": { + "back": "← Indietro", + "save": "💾 Salva", + "cancel": "✕ Annulla", + "close": "Chiudi", + "add": "✅ Aggiungi", + "delete": "Elimina", + "edit": "✏️ Modifica", + "search": "🔍 Cerca", + "go": "✅ Vai", + "toggle_password": "👁️ Mostra/Nascondi", + "load_more": "Carica altri...", + "save_config": "💾 Salva Configurazione", + "save_product": "💾 Salva Prodotto", + "restart": "↺ Ricomincia", + "reset_default": "↺ Ripristina default" + }, + "locations": { + "dispensa": "Dispensa", + "frigo": "Frigo", + "freezer": "Freezer", + "altro": "Altro" + }, + "categories": { + "latticini": "Latticini", + "carne": "Carne", + "pesce": "Pesce", + "frutta": "Frutta", + "verdura": "Verdura", + "pasta": "Pasta & Riso", + "pane": "Pane & Forno", + "surgelati": "Surgelati", + "bevande": "Bevande", + "condimenti": "Condimenti", + "snack": "Snack & Dolci", + "conserve": "Conserve", + "cereali": "Cereali & Legumi", + "igiene": "Igiene", + "pulizia": "Pulizia Casa", + "altro": "Altro", + "select": "-- Seleziona --" + }, + "units": { + "pz": "pz", + "conf": "conf", + "g": "g", + "ml": "ml", + "pieces": "Pezzi", + "grams": "Grammi", + "box": "Confezione", + "boxes": "Confezioni" + }, + "shopping_sections": { + "frutta_verdura": "Frutta & Verdura", + "carne_pesce": "Carne & Pesce", + "latticini": "Latticini & Fresco", + "pane_dolci": "Pane & Dolci", + "pasta": "Pasta & Cereali", + "conserve": "Conserve & Salse", + "surgelati": "Surgelati", + "bevande": "Bevande", + "pulizia_igiene": "Pulizia & Igiene", + "altro": "Altro" + }, + "dashboard": { + "expired_title": "🚫 Scaduti", + "expiring_title": "⏰ Prossime Scadenze", + "stats_period": "📊 Ultimi 30 giorni", + "opened_title": "📦 Prodotti Aperti", + "review_title": "🔍 Da revisionare", + "review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.", + "quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza" + }, + "inventory": { + "title": "Dispensa", + "filter_all": "Tutti", + "search_placeholder": "🔍 Cerca prodotto...", + "empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!", + "no_items_found": "Nessuna voce di inventario trovata" + }, + "scan": { + "title": "Scansiona Prodotto", + "mode_shopping": "🛒 Modalità Spesa", + "mode_shopping_end": "✅ Fine spesa", + "zoom": "Zoom", + "barcode_placeholder": "Inserisci codice a barre...", + "quick_name_divider": "oppure scrivi il nome", + "quick_name_placeholder": "Es: Mele, Zucchine, Pane...", + "manual_entry": "✏️ Inserimento Manuale", + "ai_identify": "🤖 Identifica con AI", + "hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo", + "debug_toggle": "🐛 Debug Log", + "barcode_acquired": "🔖 Barcode acquisito: {code}", + "scan_barcode": "🔖 Scansiona Barcode" + }, + "action": { + "title": "Cosa vuoi fare?", + "add_btn": "📥 AGGIUNGI", + "add_sub": "in dispensa/frigo", + "use_btn": "📤 USA / CONSUMA", + "use_sub": "dalla dispensa/frigo" + }, + "add": { + "title": "Aggiungi alla Dispensa", + "location_label": "📍 Dove lo metti?", + "quantity_label": "📦 Quantità", + "conf_size_label": "📦 Ogni confezione contiene:", + "conf_size_placeholder": "es. 300", + "vacuum_label": "🫙 Sotto vuoto", + "vacuum_hint": "La scadenza verrà estesa automaticamente", + "submit": "✅ Aggiungi" + }, + "use": { + "title": "Usa / Consuma", + "location_label": "📍 Da dove?", + "quantity_label": "Quanto hai usato?", + "partial_hint": "Oppure specifica la quantità usata:", + "use_all": "🗑️ Usato TUTTO / Finito", + "submit": "📤 Usa questa quantità", + "available": "📦 Disponibile:", + "not_in_inventory": "⚠️ Prodotto non presente nell'inventario.", + "expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!" + }, + "product": { + "title_new": "Nuovo Prodotto", + "title_edit": "Modifica Prodotto", + "ai_fill": "📷 Scatta foto e identifica con AI", + "ai_fill_hint": "L'AI compilerà automaticamente i campi del prodotto", + "name_label": "🏷️ Nome Prodotto *", + "name_placeholder": "Es: Latte intero, Pasta penne rigate...", + "brand_label": "🏢 Marca", + "brand_placeholder": "Es: Barilla, Granarolo, Mutti...", + "category_label": "📂 Categoria", + "unit_label": "📏 Unità di misura", + "default_qty_label": "🔢 Quantità default", + "conf_size_label": "📦 Ogni confezione contiene:", + "conf_size_placeholder": "es. 300", + "notes_label": "📝 Note", + "notes_placeholder": "Es: senza lattosio, bio, conservare in frigo dopo apertura...", + "barcode_label": "🔖 Barcode", + "barcode_placeholder": "Codice a barre (se disponibile)", + "barcode_hint": "⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!", + "submit": "💾 Salva Prodotto", + "name_required": "Inserisci il nome del prodotto", + "conf_size_required": "Specifica il contenuto di ogni confezione", + "expiry_estimated": "Scadenza stimata:", + "scan_expiry": "Scansiona data scadenza", + "expiry_hint": "📝 Puoi modificare la data o scansionarla con la fotocamera", + "add_batch": "📦 + Lotto con scadenza diversa", + "package_info": "📦 Confezione: {info}", + "edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)", + "not_recognized": "⚠️ Prodotto non riconosciuto", + "edit_info": "✏️ Modifica informazioni", + "modify_details": "MODIFICA\nscadenza, luogo…" + }, + "products": { + "title": "📦 Tutti i Prodotti", + "search_placeholder": "🔍 Cerca prodotto...", + "empty": "Nessun prodotto nel database.\nScansiona un prodotto per iniziare!", + "no_category": "Nessun prodotto in questa categoria" + }, + "recipes": { + "title": "🍳 Ricette", + "generate": "✨ Genera nuova ricetta" + }, + "shopping": { + "title": "🛒 Lista della Spesa", + "bring_loading": "Connessione a Bring!...", + "tab_to_buy": "🛍️ Da comprare", + "tab_forecast": "🧠 In previsione", + "total_label": "💰 Totale stimato", + "section_to_buy": "🛍️ Da comprare", + "suggestions_title": "💡 Suggerimenti AI", + "suggestions_add": "✅ Aggiungi selezionati a Bring!", + "search_prices": "🔍 Cerca tutti i prezzi", + "suggest_btn": "🤖 Suggerisci cosa comprare", + "smart_title": "🧠 Previsioni intelligenti", + "smart_empty": "Nessuna previsione disponibile.
Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.", + "smart_filter_all": "Tutti", + "smart_filter_critical": "🔴 Urgenti", + "smart_filter_high": "🟠 Presto", + "smart_filter_medium": "🟡 Pianifica", + "smart_filter_low": "🟢 Previsione", + "smart_add": "🛒 Aggiungi selezionati a Bring!", + "empty": "Lista della spesa vuota!\nUsa il pulsante sotto per generare suggerimenti.", + "already_in_list": "🛒 \"{name}\" già nella lista della spesa", + "already_in_list_short": "ℹ️ Già nella lista della spesa", + "add_prompt": "Vuoi aggiungerlo alla lista della spesa?", + "smart_already": "📊 La spesa intelligente prevede già {name}", + "all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.", + "search_complete": "Ricerca completata: {count} prodotti", + "removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista" + }, + "ai": { + "title": "🤖 Identificazione AI", + "capture": "📸 Scatta Foto", + "retake": "🔄 Riscatta", + "hint": "Scatta una foto del prodotto e l'AI cercherà di identificarlo", + "identifying": "🤖 Identifico il prodotto...", + "no_api_key": "⚠️ Chiave API Gemini non configurata.\nAggiungi GEMINI_API_KEY nel file .env sul server.", + "fields_filled": "✅ Campi compilati dall'AI" + }, + "log": { + "title": "📒 Log Operazioni" + }, + "chat": { + "title": "Gemini Chef", + "welcome": "Ciao! Sono il tuo assistente cucina", + "welcome_desc": "Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!", + "suggestion_snack": "🍿 Spuntino veloce", + "suggestion_juice": "🥤 Succo/Frullato", + "suggestion_light": "🥗 Qualcosa di leggero", + "suggestion_expiry": "⏰ Usa le scadenze", + "clear": "Nuova conversazione", + "placeholder": "Chiedi qualcosa..." + }, + "cooking": { + "close": "Chiudi", + "tts_btn": "Leggi ad alta voce", + "restart": "↺ Ricomincia", + "replay": "🔊 Rileggi", + "timer": "⏱️ {time} · Timer", + "prev": "◀ Precedente", + "next": "Successivo ▶" + }, + "settings": { + "title": "⚙️ Configurazione", + "tab_api": "API Keys", + "tab_bring": "Bring!", + "tab_recipe": "Ricette", + "tab_mealplan": "Piano Settimanale", + "tab_appliances": "Elettrodomestici", + "tab_spesa": "Spesa Online", + "tab_camera": "Fotocamera", + "tab_security": "Sicurezza", + "tab_tts": "Voce (TTS)", + "tab_language": "Lingua", + "gemini": { + "title": "🤖 Google Gemini AI", + "hint": "Chiave API per identificazione prodotti, scadenze e ricette.", + "key_label": "API Key Gemini" + }, + "bring": { + "title": "🛒 Bring! Shopping List", + "hint": "Credenziali per l'integrazione con la lista della spesa Bring!", + "email_label": "📧 Email Bring!", + "password_label": "🔒 Password Bring!" + }, + "recipe": { + "title": "🍳 Preferenze Ricette", + "hint": "Configura le opzioni predefinite per la generazione delle ricette.", + "persons_label": "👥 Persone predefinite", + "options_label": "🎯 Opzioni ricetta predefinite", + "fast": "⚡ Pasto Veloce", + "light": "🥗 Poca Fame", + "expiry": "⏰ Priorità Scadenze", + "healthy": "💚 Extra Salutare", + "opened": "📦 Priorità Cose Aperte", + "zerowaste": "♻️ Zero Sprechi", + "dietary_label": "🚫 Intolleranze / Restrizioni", + "dietary_placeholder": "Es: senza glutine, senza lattosio, vegetariano..." + }, + "mealplan": { + "title": "📅 Piano Pasti Settimanale", + "hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.", + "enabled": "✅ Attiva piano pasti settimanale", + "legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.", + "types_title": "📋 Tipologie disponibili" + }, + "appliances": { + "title": "🔌 Elettrodomestici Disponibili", + "hint": "Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.", + "new_placeholder": "Es: Macchina del pane, Bimby, Friggitrice ad aria...", + "quick_title": "Aggiungi velocemente:", + "oven": "🔥 Forno", + "microwave": "📡 Microonde", + "air_fryer": "🍟 Friggitrice ad aria", + "bread_maker": "🍞 Macchina pane", + "bimby": "🤖 Bimby/Cookeo", + "mixer": "🌀 Planetaria", + "steamer": "♨️ Vaporiera", + "pressure_cooker": "🫕 Pentola pressione", + "toaster": "🍞 Tostapane", + "blender": "🍹 Frullatore", + "empty": "Nessun elettrodomestico aggiunto" + }, + "spesa": { + "title": "🛍️ Spesa Online", + "hint": "Configura il provider per la spesa online.", + "provider_label": "🏪 Provider", + "email_label": "📧 Email", + "password_label": "🔒 Password", + "login_btn": "🔐 Accedi", + "ai_prompt_label": "🤖 Prompt AI selezione prodotto", + "ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...", + "ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.", + "configure_first": "Configura prima la Spesa Online nelle impostazioni" + }, + "camera": { + "title": "📷 Fotocamera", + "hint": "Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.", + "device_label": "📸 Fotocamera predefinita", + "back": "📱 Posteriore (default)", + "front": "🤳 Anteriore", + "devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.", + "detect_btn": "🔄 Rileva fotocamere" + }, + "security": { + "title": "🔒 Certificato HTTPS", + "hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.", + "download_btn": "📥 Scarica Certificato CA" + }, + "tts": { + "title": "🔊 Voce & TTS", + "hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.", + "enabled": "✅ Attiva TTS", + "url_label": "🌐 URL Endpoint", + "method_label": "📡 Metodo HTTP", + "auth_label": "🔐 Autenticazione", + "auth_bearer": "Bearer Token", + "auth_custom": "Header personalizzato", + "auth_none": "Nessuna", + "token_label": "🔑 Bearer Token", + "custom_header_name": "📋 Nome header", + "custom_header_value": "📋 Valore header", + "content_type_label": "📄 Content-Type", + "payload_key_label": "🗝️ Campo testo nel payload", + "payload_key_hint": "Nome del campo JSON che conterrà il testo da leggere (es: message, text).", + "extra_fields_label": "➕ Campi extra (JSON)", + "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", + "extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.", + "test_btn": "🔊 Invia Test Vocale" + }, + "language": { + "title": "🌐 Lingua / Language", + "hint": "Seleziona la lingua dell'interfaccia. Select the interface language.", + "label": "🌐 Lingua", + "restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua." + }, + "saved": "✅ Configurazione salvata!", + "saved_local": "✅ Configurazione salvata localmente", + "saved_local_error": "⚠️ Salvato localmente, errore server: {error}" + }, + "expiry": { + "today": "OGGI", + "tomorrow": "Domani", + "days": "{days} giorni", + "expired_days": "Da {days}g", + "expired_yesterday": "Da ieri", + "expired_today": "Oggi" + }, + "status": { + "ok": "OK", + "check": "Controlla", + "discard": "Buttare" + }, + "toast": { + "product_saved": "Prodotto salvato!", + "product_created": "Prodotto creato!", + "product_updated": "✅ Prodotto aggiornato!", + "product_removed": "Prodotto rimosso", + "updated": "Aggiornato!", + "quantity_confirmed": "✓ Quantità confermata", + "added_to_inventory": "✅ {name} aggiunto!", + "removed_from_list": "✅ {name} rimosso dalla lista!", + "removed_from_list_short": "Rimosso dalla lista", + "added_to_shopping": "🛒 Aggiunto alla lista della spesa!", + "removed_from_shopping": "🛒 Rimosso dalla lista della spesa", + "finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!", + "thrown_away": "🗑️ {name} buttato!", + "thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}", + "appliance_added": "Elettrodomestico aggiunto", + "item_added": "{name} aggiunto" + }, + "error": { + "generic": "Errore", + "loading": "Errore nel caricamento del prodotto", + "not_found": "Prodotto non trovato", + "not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.", + "search": "Errore nella ricerca. Riprova.", + "search_short": "Errore nella ricerca", + "save": "Errore nel salvataggio", + "connection": "Errore di connessione", + "camera": "Impossibile accedere alla fotocamera", + "bring_add": "Errore nell'aggiunta a Bring!", + "bring_connection": "Errore connessione Bring!", + "identification": "Errore nell'identificazione", + "barcode_empty": "Inserisci un codice a barre", + "barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)", + "min_chars": "Scrivi almeno 2 caratteri", + "not_in_inventory": "Prodotto non nell'inventario", + "appliance_exists": "Elettrodomestico già presente", + "already_exists": "Già presente" + }, + "confirm": { + "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?" + }, + "edit": { + "title": "Modifica {name}" + }, + "screensaver": { + "recipe_btn": "Ricette", + "scan_btn": "Scansiona prodotto" + }, + "days": { + "mon": "Lunedì", + "tue": "Martedì", + "wed": "Mercoledì", + "thu": "Giovedì", + "fri": "Venerdì", + "sat": "Sabato", + "sun": "Domenica" + }, + "meal_types": { + "lunch": "Pranzo", + "dinner": "Cena" + } +}