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 '
${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 @@