Prepare for public distribution v1.0.0
- Remove all personal data from source code (HA IP, JWT tokens) - Move secrets to .env configuration (gitignored) - Create .env.example template for new installations - Add centralized env() helper, eliminate code duplication (~120 lines removed) - Add input validation on inventory operations (quantity bounds, location whitelist) - Remove sensitive credential exposure in API responses - Remove database and runtime files from Git tracking - Disable database push-to-GitHub backup (local-only backup now) - Update .gitignore for distribution - Add comprehensive README with installation guide - Add CHANGELOG.md for version tracking - Add MIT LICENSE - Add author/license headers to all source files - TTS defaults now empty (configured per-installation via .env)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Dispensa Manager - Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
# cp .env.example .env
|
||||
|
||||
# Google Gemini AI API Key (required for AI features)
|
||||
# Get one at: https://aistudio.google.com/app/apikey
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# Bring! Shopping List credentials (optional)
|
||||
# Sign up at: https://www.getbring.com/
|
||||
BRING_EMAIL=
|
||||
BRING_PASSWORD=
|
||||
|
||||
# TTS (Text-to-Speech) for cooking mode voice guidance (optional)
|
||||
# Works with Home Assistant, or any HTTP endpoint that accepts text
|
||||
TTS_URL=
|
||||
TTS_TOKEN=
|
||||
TTS_METHOD=POST
|
||||
TTS_AUTH_TYPE=bearer
|
||||
TTS_CONTENT_TYPE=application/json
|
||||
TTS_PAYLOAD_KEY=message
|
||||
TTS_ENABLED=false
|
||||
+14
-11
@@ -1,28 +1,31 @@
|
||||
# Environment variables (secrets)
|
||||
# Environment variables (secrets — copy .env.example to .env)
|
||||
.env
|
||||
|
||||
# SQLite WAL/SHM temp files
|
||||
# Data directory (user-specific runtime data)
|
||||
data/dispensa.db
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
|
||||
# Bring! auth token cache
|
||||
data/backups/
|
||||
data/cron.log
|
||||
data/smart_shopping_cache.json
|
||||
data/bring_token.json
|
||||
data/bring_catalog.json
|
||||
|
||||
# DupliClick token cache
|
||||
data/dupliclick_token.json
|
||||
|
||||
# Client debug log (runtime only)
|
||||
data/client_debug.log
|
||||
|
||||
# SSL CA cert (local only)
|
||||
ca.crt
|
||||
# SSL certificates (local only)
|
||||
data/*.crt
|
||||
data/*.pem
|
||||
*.crt
|
||||
*.pem
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
# Editor / IDE
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Dispensa Manager will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Complete pantry inventory management (Pantry, Fridge, Freezer, Other)
|
||||
- Barcode scanning with QuaggaJS
|
||||
- Open Food Facts barcode lookup
|
||||
- Google Gemini AI integration (product identification, expiry reading, recipes, chat)
|
||||
- Bring! shopping list integration
|
||||
- Smart shopping predictions with cron-based caching
|
||||
- Cooking mode with step-by-step guidance and TTS support
|
||||
- Opened product tracking with reduced shelf-life calculation
|
||||
- Vacuum-sealed product support with extended expiry
|
||||
- Waste vs. consumption tracking (30-day chart)
|
||||
- Expired product safety assessment by category
|
||||
- Weekly meal plan configuration
|
||||
- DupliClick online grocery ordering integration
|
||||
- PWA support (installable, mobile-first)
|
||||
- Local database backup script
|
||||
- Multi-device settings sync via SQLite
|
||||
|
||||
### Security
|
||||
- Centralized `.env` configuration (secrets never in code)
|
||||
- Removed all hardcoded credentials and personal data
|
||||
- Input validation on inventory operations
|
||||
- Parameterized SQL queries throughout
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Stimpfl Daniel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,280 @@
|
||||
# 🏠 Dispensa Manager
|
||||
|
||||
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.php.net/)
|
||||
[](https://www.sqlite.org/)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/img/screenshot-dashboard.png" alt="Dashboard Screenshot" width="320" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📦 Inventory Management
|
||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
|
||||
- **AI identification** — Take a photo and let Google Gemini identify the product
|
||||
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
||||
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||
- **Product identification** — Point your camera at any product for instant recognition
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions** — AI-powered purchase recommendations
|
||||
|
||||
### 🛒 Shopping List
|
||||
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
|
||||
- **Smart predictions** — Know what you'll need before you run out
|
||||
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in
|
||||
- **DupliClick integration** — Online grocery ordering (Gruppo Poli)
|
||||
|
||||
### 🍳 Cooking Mode
|
||||
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
||||
- **Text-to-Speech** — Voice readout of recipe steps (configurable TTS endpoint)
|
||||
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
|
||||
- **Ingredient tracking** — Mark ingredients as used during cooking
|
||||
|
||||
### 📊 Dashboard
|
||||
- **Waste tracking** — Monitor consumed vs. wasted products over 30 days
|
||||
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category)
|
||||
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
|
||||
|
||||
### 📱 Progressive Web App
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
- **Installable** — Add to home screen for a native app experience
|
||||
- **Multi-device** — Settings and data sync across devices on the same server
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- **Web server** with PHP 8.0+ (Apache or Nginx)
|
||||
- **PHP extensions**: `pdo_sqlite`, `curl`, `mbstring`, `json`
|
||||
- **HTTPS** recommended (required for camera access on mobile)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/dadaloop82/dispensa.git
|
||||
cd dispensa
|
||||
|
||||
# 2. Create configuration file
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Set permissions
|
||||
chmod 755 data/
|
||||
chmod 664 data/.gitkeep
|
||||
chown -R www-data:www-data data/
|
||||
|
||||
# 4. Edit your configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
### Configuration (.env)
|
||||
|
||||
```ini
|
||||
# Required for AI features (get a key at https://aistudio.google.com/app/apikey)
|
||||
GEMINI_API_KEY=your_api_key_here
|
||||
|
||||
# Optional: Bring! shopping list integration
|
||||
BRING_EMAIL=your_email@example.com
|
||||
BRING_PASSWORD=your_password
|
||||
|
||||
# Optional: Text-to-Speech for cooking mode
|
||||
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
||||
TTS_TOKEN=your_long_lived_token
|
||||
TTS_ENABLED=true
|
||||
```
|
||||
|
||||
### Web Server Configuration
|
||||
|
||||
<details>
|
||||
<summary><strong>Apache (.htaccess)</strong></summary>
|
||||
|
||||
The app works out of the box with Apache if placed in the web root or a subdirectory. Make sure `mod_rewrite` is enabled and `AllowOverride All` is set.
|
||||
|
||||
```apache
|
||||
<Directory /var/www/html/dispensa>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Nginx</strong></summary>
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-server.local;
|
||||
root /var/www/html/dispensa;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
try_files $uri $uri/ =404;
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
|
||||
# Deny access to sensitive files
|
||||
location ~ /\.env { deny all; }
|
||||
location ~ /data/ { deny all; }
|
||||
location ~ /backup\.sh { deny all; }
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### HTTPS Setup (Recommended)
|
||||
|
||||
Camera access requires HTTPS on most mobile browsers. Options:
|
||||
- **Let's Encrypt** with Certbot (for public-facing servers)
|
||||
- **Self-signed certificate** (for local network only)
|
||||
- **Reverse proxy** (e.g., Caddy, Traefik) with automatic TLS
|
||||
|
||||
### Cron Job (Optional)
|
||||
|
||||
Set up a cron job for smart shopping predictions:
|
||||
|
||||
```bash
|
||||
# Run every 5 minutes
|
||||
*/5 * * * * php /path/to/dispensa/api/cron_smart_shopping.php >> /path/to/dispensa/data/cron.log 2>&1
|
||||
```
|
||||
|
||||
### Backup (Optional)
|
||||
|
||||
The included `backup.sh` creates local daily backups of your database:
|
||||
|
||||
```bash
|
||||
# Run daily at 3 AM
|
||||
0 3 * * * /path/to/dispensa/backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
dispensa/
|
||||
├── index.html # Single-page application (SPA)
|
||||
├── manifest.json # PWA manifest
|
||||
├── .env.example # Configuration template
|
||||
├── backup.sh # Local database backup script
|
||||
├── LICENSE # MIT License
|
||||
│
|
||||
├── api/
|
||||
│ ├── index.php # Main API router (all endpoints)
|
||||
│ ├── database.php # SQLite schema, migrations, helpers
|
||||
│ └── cron_smart_shopping.php # Background job for predictions
|
||||
│
|
||||
├── assets/
|
||||
│ ├── css/style.css # All application styles
|
||||
│ ├── js/app.js # All application logic
|
||||
│ └── img/ # Static images
|
||||
│
|
||||
└── data/ # Runtime data (gitignored)
|
||||
├── dispensa.db # SQLite database (auto-created)
|
||||
├── backups/ # Local DB backups
|
||||
└── *.json # Token/cache files
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Category | Action | Method | Description |
|
||||
|----------|--------|--------|-------------|
|
||||
| **Products** | `search_barcode` | GET | Find product by barcode |
|
||||
| | `lookup_barcode` | GET | Look up barcode on Open Food Facts |
|
||||
| | `product_save` | POST | Create or update a product |
|
||||
| | `products_list` | GET | List all products |
|
||||
| **Inventory** | `inventory_list` | GET | List inventory items |
|
||||
| | `inventory_add` | POST | Add product to inventory |
|
||||
| | `inventory_use` | POST | Use/consume from inventory |
|
||||
| | `inventory_summary` | GET | Count by location |
|
||||
| **AI** | `gemini_identify` | POST | Identify product from photo |
|
||||
| | `gemini_expiry` | POST | Read expiry date from photo |
|
||||
| | `gemini_chat` | POST | Chat with AI assistant |
|
||||
| | `generate_recipe` | POST | Generate recipe from inventory |
|
||||
| **Shopping** | `bring_list` | GET | Get Bring! shopping list |
|
||||
| | `bring_add` | POST | Add items to Bring! |
|
||||
| | `smart_shopping` | GET | Smart shopping predictions |
|
||||
| **Settings** | `get_settings` | GET | Get server configuration |
|
||||
| | `save_settings` | POST | Update server configuration |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Notes
|
||||
|
||||
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||
- **Database** stays local — never pushed to remote repositories
|
||||
- **API keys** are passed server-side only — never exposed to the browser
|
||||
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
||||
- Consider adding **authentication** if the server is accessible from the internet
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
# Run PHP's built-in server for local development
|
||||
php -S localhost:8080 -t /path/to/dispensa
|
||||
|
||||
# Check PHP syntax
|
||||
php -l api/index.php
|
||||
php -l api/database.php
|
||||
```
|
||||
|
||||
The application uses no build tools — edit files directly and refresh.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Roadmap
|
||||
|
||||
- [ ] Multi-language support (i18n)
|
||||
- [ ] User authentication / multi-user support
|
||||
- [ ] Docker container for easy deployment
|
||||
- [ ] REST API documentation (OpenAPI/Swagger)
|
||||
- [ ] Offline mode with service worker
|
||||
- [ ] Export/import inventory data
|
||||
- [ ] Notification system (Telegram, email) for expiring products
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add my feature'`)
|
||||
4. Push to the branch (`git push origin feature/my-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 Author
|
||||
|
||||
**Stimpfl Daniel** — [dadaloop82@gmail.com](mailto:dadaloop82@gmail.com)
|
||||
|
||||
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Database initialization and connection for Dispensa Manager
|
||||
* Dispensa Manager - Database initialization, schema, and migrations.
|
||||
* Uses SQLite with WAL journal mode for concurrent read/write performance.
|
||||
*
|
||||
* @author Stimpfl Daniel <dadaloop82@gmail.com>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
define('DB_PATH', __DIR__ . '/../data/dispensa.db');
|
||||
|
||||
+70
-120
@@ -1,12 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Dispensa Manager - Main API Router
|
||||
* Handles all CRUD operations for products and inventory
|
||||
* Handles all CRUD operations for products, inventory, shopping lists,
|
||||
* AI-powered features (Gemini), and third-party integrations (Bring!, DupliClick).
|
||||
*
|
||||
* @author Stimpfl Daniel <dadaloop82@gmail.com>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// database.php must always be loaded (used both by HTTP router and cron)
|
||||
require_once __DIR__ . '/database.php';
|
||||
|
||||
/**
|
||||
* Load environment variables from .env file.
|
||||
* Returns associative array of key => value pairs.
|
||||
*/
|
||||
function loadEnv(): array {
|
||||
static $cache = null;
|
||||
if ($cache !== null) return $cache;
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$cache = [];
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0 || strpos($line, '=') === false) continue;
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
$cache[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single environment variable, with optional default.
|
||||
*/
|
||||
function env(string $key, string $default = ''): string {
|
||||
$vars = loadEnv();
|
||||
return $vars[$key] ?? $default;
|
||||
}
|
||||
|
||||
// When included by the cron script, skip HTTP headers and routing entirely
|
||||
if (!defined('CRON_MODE')) {
|
||||
|
||||
@@ -586,8 +618,8 @@ function listInventory(PDO $db): void {
|
||||
|
||||
function addToInventory(PDO $db): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$productId = $input['product_id'] ?? 0;
|
||||
$quantity = $input['quantity'] ?? 1;
|
||||
$productId = (int)($input['product_id'] ?? 0);
|
||||
$quantity = (float)($input['quantity'] ?? 1);
|
||||
$location = $input['location'] ?? 'dispensa';
|
||||
$expiry = $input['expiry_date'] ?? null;
|
||||
$unit = $input['unit'] ?? null;
|
||||
@@ -598,6 +630,21 @@ function addToInventory(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate quantity bounds
|
||||
if ($quantity <= 0 || $quantity > 100000) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid quantity']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate location
|
||||
$validLocations = ['dispensa', 'frigo', 'freezer', 'altro'];
|
||||
if (!in_array($location, $validLocations)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid location']);
|
||||
return;
|
||||
}
|
||||
|
||||
// If a different unit was specified, update the product's unit
|
||||
if ($unit) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
@@ -1157,47 +1204,29 @@ function getStats(PDO $db): void {
|
||||
// ===== SETTINGS =====
|
||||
|
||||
function getServerSettings(): void {
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$envVars = [];
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0 || strpos($line, '=') === false) continue;
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
$envVars[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
|
||||
// Return masked versions for security
|
||||
$geminiKey = $envVars['GEMINI_API_KEY'] ?? '';
|
||||
$bringEmail = $envVars['BRING_EMAIL'] ?? '';
|
||||
$bringPassword = $envVars['BRING_PASSWORD'] ?? '';
|
||||
// Return values for client — passwords are never exposed
|
||||
$geminiKey = env('GEMINI_API_KEY');
|
||||
$bringEmail = env('BRING_EMAIL');
|
||||
|
||||
echo json_encode([
|
||||
'gemini_key' => $geminiKey,
|
||||
'gemini_key_set' => !empty($geminiKey),
|
||||
'bring_email' => $bringEmail,
|
||||
'bring_password_set' => !empty($bringPassword)
|
||||
'bring_password_set' => !empty(env('BRING_PASSWORD')),
|
||||
'tts_url' => env('TTS_URL'),
|
||||
'tts_token' => env('TTS_TOKEN'),
|
||||
'tts_method' => env('TTS_METHOD', 'POST'),
|
||||
'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'),
|
||||
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
|
||||
'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'),
|
||||
'tts_enabled' => env('TTS_ENABLED', 'false') === 'true',
|
||||
]);
|
||||
}
|
||||
|
||||
function saveSettings(): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
|
||||
// Read existing .env content
|
||||
$envContent = '';
|
||||
$envVars = [];
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
$envVars[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
$envVars = loadEnv();
|
||||
|
||||
// Update values from input — only overwrite if new value is non-empty
|
||||
if (!empty($input['gemini_key'])) {
|
||||
@@ -1227,22 +1256,7 @@ function saveSettings(): void {
|
||||
// ===== GEMINI AI FUNCTIONS =====
|
||||
|
||||
function geminiReadExpiry(): void {
|
||||
// Load API key from .env
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$apiKey = '';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
if (trim($key) === 'GEMINI_API_KEY') {
|
||||
$apiKey = trim($val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
@@ -1330,22 +1344,7 @@ function geminiReadExpiry(): void {
|
||||
|
||||
// ===== GEMINI CHAT =====
|
||||
function geminiChat(PDO $db): void {
|
||||
// Load API key
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$apiKey = '';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
if (trim($key) === 'GEMINI_API_KEY') {
|
||||
$apiKey = trim($val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
@@ -1497,22 +1496,7 @@ PROMPT;
|
||||
|
||||
// ===== RECIPE GENERATION WITH GEMINI =====
|
||||
function generateRecipe(PDO $db): void {
|
||||
// Load API key from .env
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$apiKey = '';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
if (trim($key) === 'GEMINI_API_KEY') {
|
||||
$apiKey = trim($val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
@@ -2034,22 +2018,7 @@ PROMPT;
|
||||
|
||||
// ===== GEMINI AI PRODUCT IDENTIFICATION =====
|
||||
function geminiIdentifyProduct(): void {
|
||||
// Load API key
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$apiKey = '';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
if (trim($key) === 'GEMINI_API_KEY') {
|
||||
$apiKey = trim($val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
@@ -2200,26 +2169,9 @@ function searchOpenFoodFacts(string $searchTerms, string $name, string $brand):
|
||||
|
||||
// ===== BRING! SHOPPING LIST INTEGRATION =====
|
||||
|
||||
function loadEnvVars(): array {
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$vars = [];
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
$vars[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $vars;
|
||||
}
|
||||
|
||||
function bringAuth(): ?array {
|
||||
$env = loadEnvVars();
|
||||
$email = $env['BRING_EMAIL'] ?? '';
|
||||
$password = $env['BRING_PASSWORD'] ?? '';
|
||||
$email = env('BRING_EMAIL');
|
||||
$password = env('BRING_PASSWORD');
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
return null;
|
||||
@@ -2955,8 +2907,7 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
|
||||
function bringSuggestItems(PDO $db): void {
|
||||
$env = loadEnvVars();
|
||||
$apiKey = $env['GEMINI_API_KEY'] ?? '';
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['success' => false, 'error' => 'API Key Gemini non configurata']);
|
||||
@@ -3441,8 +3392,7 @@ function dupliclickExtractSpecKeywords(string $spec): string {
|
||||
* Use Gemini AI to pick the best product from search results
|
||||
*/
|
||||
function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array {
|
||||
$env = loadEnvVars();
|
||||
$apiKey = $env['GEMINI_API_KEY'] ?? '';
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) return null;
|
||||
|
||||
$defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare (con eventuale descrizione tra parentesi) e una lista di prodotti trovati nel catalogo del supermercato.
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Dispensa Manager - UI Styles
|
||||
* Mobile-first PWA design with CSS custom properties.
|
||||
*
|
||||
* @author Stimpfl Daniel <dadaloop82@gmail.com>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/* ===== CSS VARIABLES & RESET ===== */
|
||||
:root {
|
||||
--primary: #2d5016;
|
||||
|
||||
+24
-5
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Dispensa Manager - Main Application JS
|
||||
* Complete pantry management with barcode scanning and AI identification
|
||||
* Complete pantry management with barcode scanning, AI identification,
|
||||
* Bring! shopping list integration, recipe generation, and TTS cooking mode.
|
||||
*
|
||||
* @author Stimpfl Daniel <dadaloop82@gmail.com>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// ===== REMOTE LOGGING =====
|
||||
@@ -722,13 +726,13 @@ async function loadSettingsUI() {
|
||||
}
|
||||
// TTS settings — init defaults on first load
|
||||
if (!s._tts_initialized) {
|
||||
s.tts_url = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak';
|
||||
s.tts_token = s.tts_token || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2OGQ3Njk1N2E0MjY0ZTBjOWQ4YjczZDY4ZDVmMWJlZCIsImlhdCI6MTc2MDI1NjIxNSwiZXhwIjoyMDc1NjE2MjE1fQ.X5UyMMPd7wTA6Gh11Nzg7Ox-enlDDom_lJIAJruUtcE';
|
||||
s.tts_url = s.tts_url || '';
|
||||
s.tts_token = s.tts_token || '';
|
||||
s.tts_payload_key = s.tts_payload_key || 'message';
|
||||
s.tts_method = s.tts_method || 'POST';
|
||||
s.tts_auth_type = s.tts_auth_type || 'bearer';
|
||||
s.tts_content_type = s.tts_content_type || 'application/json';
|
||||
s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : true;
|
||||
s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : false;
|
||||
s._tts_initialized = true;
|
||||
saveSettingsToStorage(s);
|
||||
}
|
||||
@@ -762,6 +766,21 @@ async function loadSettingsUI() {
|
||||
if (!s.bring_email && serverSettings.bring_email) {
|
||||
document.getElementById('setting-bring-email').value = serverSettings.bring_email;
|
||||
}
|
||||
// Load TTS defaults from server .env if not set locally
|
||||
if (!s.tts_url && serverSettings.tts_url) {
|
||||
s.tts_url = serverSettings.tts_url;
|
||||
s.tts_token = serverSettings.tts_token || '';
|
||||
s.tts_method = serverSettings.tts_method || 'POST';
|
||||
s.tts_auth_type = serverSettings.tts_auth_type || 'bearer';
|
||||
s.tts_content_type = serverSettings.tts_content_type || 'application/json';
|
||||
s.tts_payload_key = serverSettings.tts_payload_key || 'message';
|
||||
s.tts_enabled = serverSettings.tts_enabled || false;
|
||||
saveSettingsToStorage(s);
|
||||
// Update UI fields with server values
|
||||
if (ttsUrlEl) ttsUrlEl.value = s.tts_url;
|
||||
if (ttsTokenEl) ttsTokenEl.value = s.tts_token;
|
||||
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled;
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -7416,7 +7435,7 @@ function renderCookingStep() {
|
||||
}
|
||||
|
||||
function _buildTtsRequest(text, s) {
|
||||
const url = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak';
|
||||
const url = s.tts_url || '';
|
||||
const method = s.tts_method || 'POST';
|
||||
const authType = s.tts_auth_type || 'bearer';
|
||||
const token = s.tts_token || '';
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Daily backup of Dispensa database to GitHub
|
||||
# Runs via cron: commits and pushes data/dispensa.db
|
||||
# Daily backup of Dispensa database (local only)
|
||||
# The database is NOT pushed to remote repositories.
|
||||
# Runs via cron: creates a local timestamped backup copy
|
||||
#
|
||||
# Example crontab entry:
|
||||
# 0 3 * * * /var/www/html/dispensa/backup.sh
|
||||
|
||||
cd /var/www/html/dispensa || exit 1
|
||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
||||
|
||||
# Only commit if there are actual changes
|
||||
if git diff --quiet data/ 2>/dev/null && git diff --cached --quiet data/ 2>/dev/null; then
|
||||
# Check for untracked files in data/
|
||||
if [ -z "$(git ls-files --others --exclude-standard data/)" ]; then
|
||||
exit 0 # Nothing changed
|
||||
fi
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
DB_FILE="${INSTALL_DIR}/data/dispensa.db"
|
||||
if [ ! -f "$DB_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DATE=$(date '+%Y-%m-%d %H:%M')
|
||||
git add data/dispensa.db
|
||||
git commit -m "📦 Backup database automatico - $DATE"
|
||||
git push
|
||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||
cp "$DB_FILE" "${BACKUP_DIR}/dispensa_${DATE}.db"
|
||||
|
||||
# Keep only the last 7 backups
|
||||
ls -t "${BACKUP_DIR}"/dispensa_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
||||
|
||||
-2585
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"success":true,"items":[{"product_id":69,"name":"Cipolla Dorata","brand":"","category":"verdura","unit":"pz","current_qty":1,"default_qty":0,"package_unit":"","pct_left":11,"use_count":17,"buy_count":1,"daily_rate":0.36,"uses_per_month":16.6,"days_since_last_use":1,"days_left":3,"expiry_date":"2026-04-13","days_to_expiry":3,"is_opened":false,"urgency":"high","reasons":["Quasi finito (11%)","Scade tra 3gg"],"score":135,"on_bring":true,"locations":"frigo"},{"product_id":3,"name":"Cracker integrali","brand":"Barilla,Mulino Bianco","category":"en:snacks","unit":"conf","current_qty":2,"default_qty":25,"package_unit":"g","pct_left":12,"use_count":6,"buy_count":1,"daily_rate":0.46,"uses_per_month":5.9,"days_since_last_use":12,"days_left":4,"expiry_date":"2026-05-28","days_to_expiry":48,"is_opened":true,"urgency":"high","reasons":["Quasi finito (12%)"],"score":90,"on_bring":false,"locations":"dispensa"},{"product_id":47,"name":"Lenticchie","brand":"Primia","category":"en:plant-based-foods-and-beverages","unit":"conf","current_qty":2,"default_qty":400,"package_unit":"g","pct_left":1,"use_count":5,"buy_count":4,"daily_rate":29.32,"uses_per_month":4.9,"days_since_last_use":6,"days_left":0,"expiry_date":"2029-12-13","days_to_expiry":1343,"is_opened":false,"urgency":"high","reasons":["Quasi finito (1%)"],"score":90,"on_bring":false,"locations":"dispensa"},{"product_id":129,"name":"Latte di Montagna","brand":"Mila","category":"en:dairies","unit":"conf","current_qty":4,"default_qty":1000,"package_unit":"ml","pct_left":160,"use_count":17,"buy_count":6,"daily_rate":0.43,"uses_per_month":20,"days_since_last_use":1,"days_left":9,"expiry_date":"2026-04-11","days_to_expiry":1,"is_opened":true,"urgency":"medium","reasons":["Scade tra 1gg"],"score":55,"on_bring":false,"locations":"frigo"},{"product_id":122,"name":"Carote","brand":"Bell'orto","category":"en:plant-based-foods-and-beverages","unit":"g","current_qty":750,"default_qty":750,"package_unit":"","pct_left":100,"use_count":3,"buy_count":2,"daily_rate":29.42,"uses_per_month":3.5,"days_since_last_use":11,"days_left":25,"expiry_date":"2026-04-13","days_to_expiry":3,"is_opened":false,"urgency":"medium","reasons":["Scade tra 3gg"],"score":40,"on_bring":false,"locations":"dispensa"},{"product_id":188,"name":"Melone Retato","brand":"Iper Poli","category":"frutta","unit":"g","current_qty":498,"default_qty":1400,"package_unit":"","pct_left":36,"use_count":1,"buy_count":1,"daily_rate":332.81,"uses_per_month":0.5,"days_since_last_use":1,"days_left":1,"expiry_date":"2026-04-12","days_to_expiry":2,"is_opened":true,"urgency":"medium","reasons":["Scade tra 2gg"],"score":40,"on_bring":false,"locations":"frigo"},{"product_id":5,"name":"Fette biscottate Integrali","brand":"Primia","category":"en:plant-based-foods-and-beverages","unit":"conf","current_qty":2.5,"default_qty":36,"package_unit":"g","pct_left":21,"use_count":2,"buy_count":1,"daily_rate":0.24,"uses_per_month":2,"days_since_last_use":2,"days_left":10,"expiry_date":"2026-06-06","days_to_expiry":57,"is_opened":true,"urgency":"low","reasons":["Scorta bassa (21%)"],"score":30,"on_bring":false,"locations":"dispensa"},{"product_id":132,"name":"Noci sgusciate","brand":"Fruttbella","category":"conserve","unit":"g","current_qty":60,"default_qty":200,"package_unit":"","pct_left":30,"use_count":4,"buy_count":1,"daily_rate":5.49,"uses_per_month":4.7,"days_since_last_use":6,"days_left":11,"expiry_date":"2026-05-29","days_to_expiry":49,"is_opened":true,"urgency":"low","reasons":["Scorta bassa (30%)"],"score":30,"on_bring":false,"locations":"dispensa"},{"product_id":136,"name":"Biscotti Pastefrolle","brand":"Balocco","category":"snack","unit":"g","current_qty":350,"default_qty":700,"package_unit":"","pct_left":67,"use_count":4,"buy_count":2,"daily_rate":27.47,"uses_per_month":4.7,"days_since_last_use":6,"days_left":13,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~13gg"],"score":25,"on_bring":false,"locations":"dispensa"}],"cached_at":"2026-04-10T05:15:02+00:00","cached_ts":1775798102}
|
||||
@@ -6,6 +6,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="theme-color" content="#2d5016">
|
||||
<meta name="author" content="Stimpfl Daniel">
|
||||
<meta name="description" content="Self-hosted pantry manager with barcode scanning, AI identification, and shopping list integration.">
|
||||
<title>Dispensa Manager</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
|
||||
|
||||
Reference in New Issue
Block a user