Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85ccdaa6f6 | |||
| 16993135b9 | |||
| d1716fa6ff | |||
| 3ac42f7767 | |||
| eb19265586 | |||
| 8a69e6d941 | |||
| c5b0dbcf42 | |||
| 338bd7ff66 | |||
| c7532f90cd | |||
| 5831e3bcea |
@@ -37,8 +37,10 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
||||
VCODE=$(grep 'versionCode' evershelf-kiosk/app/build.gradle.kts | grep -oP '\d+')
|
||||
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Kiosk version: $VERSION"
|
||||
echo "code=$VCODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Kiosk version: $VERSION (versionCode $VCODE)"
|
||||
|
||||
- name: Build debug APK
|
||||
run: gradle assembleDebug --no-daemon
|
||||
@@ -75,7 +77,21 @@ jobs:
|
||||
sleep 3
|
||||
gh release create kiosk-latest \
|
||||
--title "EverShelf Kiosk Latest" \
|
||||
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
|
||||
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
|
||||
--prerelease \
|
||||
artifacts/evershelf-kiosk.apk
|
||||
|
||||
- name: Publish APK to releases/ for LAN OTA
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk
|
||||
printf '{"version":"%s","version_code":%s}\n' \
|
||||
"${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \
|
||||
> releases/kiosk-version.json
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add releases/evershelf-kiosk.apk releases/kiosk-version.json
|
||||
git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
|
||||
|
||||
@@ -43,7 +43,18 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t evershelf-test .
|
||||
run: |
|
||||
set -e
|
||||
for attempt in 1 2 3; do
|
||||
echo "Docker build attempt $attempt/3..."
|
||||
if docker build -t evershelf-test .; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt failed — retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
echo "Docker build failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Test container starts
|
||||
run: |
|
||||
|
||||
@@ -14,8 +14,9 @@ RewriteEngine On
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Force HTTPS
|
||||
# Force HTTPS (skip when terminated TLS is forwarded — Traefik, Caddy, NPM, …)
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# API routing
|
||||
|
||||
@@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.42] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES.
|
||||
- **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage.
|
||||
- **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments.
|
||||
|
||||
### Fixed
|
||||
- **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context.
|
||||
- **SQLite database locked (#201–#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`.
|
||||
- **Smart shopping timeout (#203–#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories.
|
||||
- **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`.
|
||||
- **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented.
|
||||
|
||||
## [1.7.41] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
- **Docker/Traefik “Impossibile contattare il server”** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production.
|
||||
- **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually.
|
||||
- **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs.
|
||||
|
||||
## [1.7.40] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
@@ -40,6 +40,17 @@
|
||||
|
||||
---
|
||||
|
||||
### 🆕 Release 1.7.42 (2026-06-11)
|
||||
|
||||
- **Stable shopping total** — Each list item is priced as one typical purchase (no more inflated totals from 14-day restock quantities or €/kg × piece count).
|
||||
- **Waste reason picker** — When discarding food, choose why (expired, wrong storage, bought too much, …); EverShelf learns and adjusts restock suggestions and storage hints.
|
||||
- **Fewer SQLite lock errors** — `inventory_use` and `shopping_add` retry on `SQLITE_BUSY`; smart shopping gets a longer PHP time limit on large pantries.
|
||||
- **Android kiosk** — Locale string escaping fix; setup wizard JSON save fix (CI build).
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🏠 NEW — Home Assistant Integration
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
/**
|
||||
* EverShelf API bootstrap — shared by HTTP router and cron.
|
||||
*/
|
||||
// Never emit HTML notices before JSON API responses (breaks fetch().json() in the PWA).
|
||||
if (!defined('CRON_MODE') && (getenv('DISPLAY_ERRORS') ?: '') !== '1') {
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
}
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/constants.php';
|
||||
require_once __DIR__ . '/lib/github.php';
|
||||
|
||||
+242
-33
@@ -55,6 +55,12 @@ if (($_GET['action'] ?? '') === 'ping') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Kiosk OTA metadata (LAN self-host; no DB required) ───────────────────────
|
||||
if (($_GET['action'] ?? '') === 'kiosk_update') {
|
||||
getKioskUpdate();
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── App bootstrap — same-origin browsers receive API token automatically ───────
|
||||
if (($_GET['action'] ?? '') === 'app_bootstrap') {
|
||||
$required = evershelfApiTokenRequired();
|
||||
@@ -3121,6 +3127,121 @@ function addToInventory(PDO $db): void {
|
||||
invalidateSmartShoppingCache();
|
||||
}
|
||||
|
||||
/** Waste transaction notes use format Buttato|reason_key (legacy: plain "Buttato"). */
|
||||
function _isWasteNotes(string $notes): bool {
|
||||
return $notes === 'Buttato' || str_starts_with($notes, 'Buttato|');
|
||||
}
|
||||
|
||||
function _wasteReasonKey(string $notes): ?string {
|
||||
if ($notes === 'Buttato') {
|
||||
return 'unknown';
|
||||
}
|
||||
if (preg_match('/^Buttato\|([a-z_]+)/', $notes, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _loadWasteLearning(PDO $db): array {
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
$row = $db->query("SELECT value FROM app_settings WHERE key = 'waste_learning'")->fetchColumn();
|
||||
$cache = ($row !== false && $row !== '') ? (json_decode((string)$row, true) ?: []) : [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
function _saveWasteLearning(PDO $db, array $data): void {
|
||||
$stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES ('waste_learning', ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at");
|
||||
$stmt->execute([json_encode($data, JSON_UNESCAPED_UNICODE)]);
|
||||
invalidateSmartShoppingCache();
|
||||
}
|
||||
|
||||
function _guessPreferredStorageLocation(string $name, string $category): string {
|
||||
$n = mb_strtolower($name . ' ' . $category);
|
||||
if (preg_match('/surgelat|gelato|congelat|frozen|piselli surg|spinaci surg|basilico surg/', $n)) {
|
||||
return 'freezer';
|
||||
}
|
||||
if (preg_match('/latte|yogurt|formaggio|burro|panna|uova|insalata|rucola|spinaci|pollo|carne|pesce|prosciutto|salame|mortadella|bresaola|affettato/', $n)) {
|
||||
return 'frigo';
|
||||
}
|
||||
return 'dispensa';
|
||||
}
|
||||
|
||||
function _applyWasteLearning(PDO $db, int $productId, string $reason, string $location, array $product): void {
|
||||
if ($reason === '' || $reason === 'other') {
|
||||
return;
|
||||
}
|
||||
$data = _loadWasteLearning($db);
|
||||
$pid = (string)$productId;
|
||||
if (!isset($data[$pid])) {
|
||||
$data[$pid] = [];
|
||||
}
|
||||
$data[$pid]['last_reason'] = $reason;
|
||||
$data[$pid]['last_at'] = time();
|
||||
$data[$pid]['count_' . $reason] = (int)($data[$pid]['count_' . $reason] ?? 0) + 1;
|
||||
|
||||
switch ($reason) {
|
||||
case 'expired':
|
||||
case 'spoiled':
|
||||
$data[$pid]['alert_days_sooner'] = min(5, (int)($data[$pid]['alert_days_sooner'] ?? 0) + 1);
|
||||
break;
|
||||
case 'wrong_location':
|
||||
$preferred = _guessPreferredStorageLocation($product['name'] ?? '', $product['category'] ?? '');
|
||||
if ($preferred !== $location) {
|
||||
$data[$pid]['preferred_location'] = $preferred;
|
||||
}
|
||||
break;
|
||||
case 'kept_too_long':
|
||||
case 'forgotten':
|
||||
$data[$pid]['buy_smaller'] = true;
|
||||
$data[$pid]['max_suggested_pz'] = 2;
|
||||
break;
|
||||
case 'bought_too_much':
|
||||
$data[$pid]['buy_less'] = true;
|
||||
$data[$pid]['max_suggested_conf'] = 1;
|
||||
$data[$pid]['max_suggested_pz'] = 2;
|
||||
break;
|
||||
case 'bad_quality':
|
||||
$data[$pid]['buy_less'] = true;
|
||||
break;
|
||||
}
|
||||
_saveWasteLearning($db, $data);
|
||||
}
|
||||
|
||||
function _maybeApplyWasteLearning(PDO $db, int $productId, string $notes, string $location): void {
|
||||
if (!_isWasteNotes($notes)) {
|
||||
return;
|
||||
}
|
||||
$reason = _wasteReasonKey($notes) ?? 'unknown';
|
||||
$stmt = $db->prepare("SELECT name, category FROM products WHERE id = ?");
|
||||
$stmt->execute([$productId]);
|
||||
$product = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$product) {
|
||||
return;
|
||||
}
|
||||
_applyWasteLearning($db, $productId, $reason, $location, $product);
|
||||
}
|
||||
|
||||
function _applyWasteHintsToSuggestion(int $productId, $suggestedQty, string $suggestedUnit, array $wasteLearning): array {
|
||||
$hint = $wasteLearning[(string)$productId] ?? [];
|
||||
if ($suggestedQty === null || empty($hint)) {
|
||||
return [$suggestedQty, $suggestedUnit];
|
||||
}
|
||||
if (!empty($hint['buy_less']) || !empty($hint['buy_smaller'])) {
|
||||
if ($suggestedUnit === 'conf') {
|
||||
$cap = (float)($hint['max_suggested_conf'] ?? 1);
|
||||
$suggestedQty = min((float)$suggestedQty, max(1.0, $cap));
|
||||
} elseif ($suggestedUnit === 'pz') {
|
||||
$cap = (float)($hint['max_suggested_pz'] ?? 2);
|
||||
$suggestedQty = min((float)$suggestedQty, max(1.0, $cap));
|
||||
}
|
||||
}
|
||||
return [$suggestedQty, $suggestedUnit];
|
||||
}
|
||||
|
||||
function useFromInventory(PDO $db): void {
|
||||
EverLog::info('useFromInventory');
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -3137,6 +3258,18 @@ function useFromInventory(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dbWithRetry(function () use ($db, $productId, $quantity, $useAll, $location, $notes): void {
|
||||
useFromInventoryCore($db, $productId, $quantity, $useAll, $location, $notes);
|
||||
});
|
||||
} catch (\PDOException $e) {
|
||||
EverLog::error('useFromInventory db error', ['msg' => $e->getMessage()]);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Database busy — please retry']);
|
||||
}
|
||||
}
|
||||
|
||||
function useFromInventoryCore(PDO $db, $productId, $quantity, $useAll, $location, $notes): void {
|
||||
// ── Server-side deduplication ─────────────────────────────────────────
|
||||
// Guard against accidental double-consume triggers (scale jitter, double tap,
|
||||
// delayed/offline replay burst). We only apply this stricter gate to manual
|
||||
@@ -3195,10 +3328,10 @@ function useFromInventory(PDO $db): void {
|
||||
$stmt->execute([$productId]);
|
||||
$allItems = $stmt->fetchAll();
|
||||
$totalRemoved = 0;
|
||||
$explicitFinish = ($notes !== 'Buttato');
|
||||
$explicitFinish = !_isWasteNotes($notes);
|
||||
foreach ($allItems as $item) {
|
||||
$totalRemoved += $item['quantity'];
|
||||
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||
$type = _isWasteNotes($notes) ? 'waste' : 'out';
|
||||
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]);
|
||||
|
||||
@@ -3212,6 +3345,7 @@ function useFromInventory(PDO $db): void {
|
||||
$stmt->execute([$item['id']]);
|
||||
}
|
||||
}
|
||||
_maybeApplyWasteLearning($db, (int)$productId, $notes, $location === '__all__' ? 'dispensa' : $location);
|
||||
echo json_encode(['success' => true, 'remaining' => 0, 'removed' => $totalRemoved]);
|
||||
return;
|
||||
}
|
||||
@@ -3270,9 +3404,10 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
|
||||
// Log transaction
|
||||
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||
$type = _isWasteNotes($notes) ? 'waste' : 'out';
|
||||
$stmt3 = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt3->execute([$productId, $type, $quantity, $location, $notes]);
|
||||
_maybeApplyWasteLearning($db, (int)$productId, $notes, $location);
|
||||
|
||||
$remaining = $newFraction > 0.001 ? $newFraction : 0;
|
||||
// Skip the normal flow — jump to Bring! check and response
|
||||
@@ -3361,13 +3496,14 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
|
||||
// Log transaction (actual amount removed, not requested)
|
||||
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||
$type = _isWasteNotes($notes) ? 'waste' : 'out';
|
||||
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$productId, $type, $actualDeducted, $location, $notes]);
|
||||
_maybeApplyWasteLearning($db, (int)$productId, $notes, $location);
|
||||
|
||||
// User explicitly chose "use all/finished": remove this row now instead of
|
||||
// leaving quantity=0 pending confirmation.
|
||||
if ($useAll && $notes !== 'Buttato' && $newQty <= 0) {
|
||||
if ($useAll && !_isWasteNotes($notes) && $newQty <= 0) {
|
||||
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
||||
$stmt->execute([$existing['id']]);
|
||||
}
|
||||
@@ -4923,6 +5059,34 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
|
||||
// ===== SETTINGS =====
|
||||
|
||||
function getKioskUpdate(): void {
|
||||
$root = dirname(__DIR__);
|
||||
$jsonPath = $root . '/releases/kiosk-version.json';
|
||||
$apkPath = $root . '/releases/evershelf-kiosk.apk';
|
||||
if (!is_file($jsonPath) || !is_file($apkPath)) {
|
||||
echo json_encode(['success' => false, 'error' => 'not_available']);
|
||||
return;
|
||||
}
|
||||
$meta = json_decode((string)file_get_contents($jsonPath), true);
|
||||
if (!is_array($meta)) {
|
||||
echo json_encode(['success' => false, 'error' => 'invalid_metadata']);
|
||||
return;
|
||||
}
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
|
||||
? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
|
||||
$basePath = preg_replace('#/api/index\.php$#', '', $script) ?: '';
|
||||
$defaultApkUrl = $scheme . '://' . $host . $basePath . '/releases/evershelf-kiosk.apk';
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'version' => (string)($meta['version'] ?? ''),
|
||||
'version_code' => (int)($meta['version_code'] ?? 0),
|
||||
'apk_url' => (string)($meta['apk_url'] ?? $defaultApkUrl),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function getServerSettings(): void {
|
||||
EverLog::debug('getServerSettings');
|
||||
$geminiKey = env('GEMINI_API_KEY');
|
||||
@@ -10578,6 +10742,7 @@ function invalidateSmartShoppingCache(): void {
|
||||
|
||||
function smartShoppingCached(PDO $db): void {
|
||||
EverLog::info('smartShoppingCached');
|
||||
set_time_limit(120);
|
||||
// Never let the browser or proxy cache this — urgency is time-sensitive
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
@@ -10656,6 +10821,7 @@ function _productOnBring(string $productName, array $bringItems, string $shoppin
|
||||
|
||||
function smartShopping(PDO $db): void {
|
||||
EverLog::info('smartShopping');
|
||||
set_time_limit(120);
|
||||
$now = time();
|
||||
$today = date('Y-m-d');
|
||||
|
||||
@@ -10767,6 +10933,7 @@ function smartShopping(PDO $db): void {
|
||||
|
||||
// 5. Analyze each product
|
||||
$items = [];
|
||||
$wasteLearning = _loadWasteLearning($db);
|
||||
foreach ($products as $p) {
|
||||
$pid = $p['id'];
|
||||
$inv = $inventory[$pid] ?? null;
|
||||
@@ -11198,12 +11365,20 @@ function smartShopping(PDO $db): void {
|
||||
if ($buyCount > 0 && $totalUsed > $buyCount * 5 && $daysSinceFirst < 999) {
|
||||
$need14 = ($buyCount / $daysSinceFirst) * 14;
|
||||
}
|
||||
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
|
||||
$suggestedUnit = 'conf';
|
||||
// conf + package weight: express suggestion in g/ml, not raw conf count from mis-tracked grams.
|
||||
if ($defQty > 0 && in_array(strtolower($pkgUnit), ['g', 'ml'], true)) {
|
||||
$pkgs = (int) max(1, min(3, (int)($need14 + 0.3)));
|
||||
$suggestedQty = $pkgs * (int) $defQty;
|
||||
$suggestedUnit = strtolower($pkgUnit);
|
||||
$suggestedApprox = $pkgs > 1;
|
||||
} else {
|
||||
$suggestedQty = (int) max(1, min(3, (int)($need14 + 0.3)));
|
||||
$suggestedUnit = 'conf';
|
||||
}
|
||||
|
||||
} elseif ($pkgUnit !== '' && $defQty > 0) {
|
||||
// Real package info available → express in confezioni (definitive)
|
||||
$pkgs = (int) max(1, min(10, (int)($need14 / $defQty + 0.3)));
|
||||
$pkgs = (int) max(1, min(3, (int)($need14 / $defQty + 0.3)));
|
||||
$suggestedQty = $pkgs;
|
||||
$suggestedUnit = 'conf';
|
||||
|
||||
@@ -11212,7 +11387,7 @@ function smartShopping(PDO $db): void {
|
||||
// use defQty as the minimum purchase unit and round to nearest multiple.
|
||||
// This ensures we never suggest less than one "reference pack".
|
||||
$pkgs = (int) max(1, (int)($need14 / $defQty + 0.3));
|
||||
$pkgs = min(10, $pkgs);
|
||||
$pkgs = min(3, $pkgs);
|
||||
$suggestedQty = $pkgs * (int)$defQty;
|
||||
$suggestedUnit = $unit;
|
||||
$suggestedApprox = true; // always "almeno" — no confirmed pkg size
|
||||
@@ -11234,8 +11409,8 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
|
||||
} elseif ($unit === 'pz') {
|
||||
// No package info → raw pz count, approximate
|
||||
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
|
||||
// No package info → raw pz count, approximate (cap 5 — not 14-day bulk buy)
|
||||
$suggestedQty = (int) max(1, min(5, (int)($need14 + 0.3)));
|
||||
$suggestedUnit = 'pz';
|
||||
$suggestedApprox = ($suggestedQty > 1);
|
||||
}
|
||||
@@ -11278,17 +11453,17 @@ function smartShopping(PDO $db): void {
|
||||
if ($gap > 0) {
|
||||
if ($unit === 'conf') {
|
||||
if ($defQty > 0 && in_array(strtolower($pkgUnit), ['g', 'ml'])) {
|
||||
$pkgs = (int)max(1, min(10, (int)ceil($gap / $defQty)));
|
||||
$pkgs = (int)max(1, min(3, (int)ceil($gap / $defQty)));
|
||||
$suggestedQty = $pkgs * (int)$defQty;
|
||||
$suggestedUnit = strtolower($pkgUnit);
|
||||
$suggestedApprox = true;
|
||||
} else {
|
||||
$suggestedQty = (int)max(1, min(10, (int)ceil($gap)));
|
||||
$suggestedQty = (int)max(1, min(3, (int)ceil($gap)));
|
||||
$suggestedUnit = 'conf';
|
||||
$suggestedApprox = false;
|
||||
}
|
||||
} elseif ($unit === 'pz') {
|
||||
$suggestedQty = (int)max(1, min(10, (int)ceil($gap)));
|
||||
$suggestedQty = (int)max(1, min(5, (int)ceil($gap)));
|
||||
$suggestedUnit = 'pz';
|
||||
$suggestedApprox = $suggestedQty > 1;
|
||||
} elseif ($unit === 'g' || $unit === 'ml') {
|
||||
@@ -11321,6 +11496,13 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
[$suggestedQty, $suggestedUnit] = _applyWasteHintsToSuggestion($pid, $suggestedQty, $suggestedUnit ?? $unit, $wasteLearning);
|
||||
$wHint = $wasteLearning[(string)$pid] ?? [];
|
||||
if (!empty($wHint['preferred_location'])) {
|
||||
$locLabel = $wHint['preferred_location'];
|
||||
$reasons[] = "Past waste: store in {$locLabel}";
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'product_id' => $pid,
|
||||
'name' => $p['name'],
|
||||
@@ -11602,10 +11784,30 @@ function shoppingGetList(PDO $db): void {
|
||||
|
||||
function shoppingAdd(PDO $db): void {
|
||||
if (isShoppingBringMode()) {
|
||||
bringAddItems($db);
|
||||
try {
|
||||
dbWithRetry(function () use ($db): void {
|
||||
bringAddItems($db);
|
||||
});
|
||||
} catch (\PDOException $e) {
|
||||
EverLog::error('shoppingAdd/bring db error', ['msg' => $e->getMessage()]);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Database busy — please retry']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
try {
|
||||
dbWithRetry(function () use ($db, $input): void {
|
||||
shoppingAddInternal($db, $input);
|
||||
});
|
||||
} catch (\PDOException $e) {
|
||||
EverLog::error('shoppingAdd db error', ['msg' => $e->getMessage()]);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Database busy — please retry']);
|
||||
}
|
||||
}
|
||||
|
||||
function shoppingAddInternal(PDO $db, array $input): void {
|
||||
$items = $input['items'] ?? [];
|
||||
$added = 0; $updated = 0; $skipped = 0;
|
||||
foreach ($items as $item) {
|
||||
@@ -12856,22 +13058,16 @@ function _matchSmartShoppingItem(string $name, array $smartItems): ?array {
|
||||
|
||||
/**
|
||||
* Resolve qty/unit/defQty for price estimation from smart-shopping suggestions.
|
||||
* Each shopping-list line is priced as ONE typical retail purchase — not 14-day restock stock.
|
||||
*/
|
||||
function _resolveShoppingPriceItem(string $name, array $smartItems): array {
|
||||
$si = _matchSmartShoppingItem($name, $smartItems);
|
||||
if ($si && !empty($si['suggested_qty']) && (float)$si['suggested_qty'] > 0) {
|
||||
return [
|
||||
'name' => $name,
|
||||
'quantity' => (float)$si['suggested_qty'],
|
||||
'unit' => trim($si['suggested_unit'] ?? $si['unit'] ?? 'conf'),
|
||||
'default_quantity' => (float)($si['default_qty'] ?? 0),
|
||||
'package_unit' => trim($si['package_unit'] ?? ''),
|
||||
];
|
||||
}
|
||||
if ($si) {
|
||||
$unit = trim($si['unit'] ?? 'conf');
|
||||
$defQty = (float)($si['default_qty'] ?? 0);
|
||||
$unit = trim($si['unit'] ?? 'conf');
|
||||
$defQty = (float)($si['default_qty'] ?? 0);
|
||||
$pkgUnit = trim($si['package_unit'] ?? '');
|
||||
|
||||
// Packaged goods (conf + weight/volume): one package at list price.
|
||||
if ($unit === 'conf' && $defQty > 0 && $pkgUnit !== '') {
|
||||
return [
|
||||
'name' => $name,
|
||||
@@ -12881,16 +13077,31 @@ function _resolveShoppingPriceItem(string $name, array $smartItems): array {
|
||||
'package_unit' => $pkgUnit,
|
||||
];
|
||||
}
|
||||
|
||||
// Sold by piece: 2–3 items typical for a single shop trip.
|
||||
if ($unit === 'pz') {
|
||||
$gramsPerPiece = ($defQty >= 20) ? $defQty : 200.0;
|
||||
return [
|
||||
'name' => $name,
|
||||
'quantity' => 2,
|
||||
'unit' => 'pz',
|
||||
'default_quantity' => $gramsPerPiece,
|
||||
'package_unit' => 'g',
|
||||
];
|
||||
}
|
||||
|
||||
// Bulk g/ml with known reference pack: one pack, not multi-week stock.
|
||||
if (($unit === 'g' || $unit === 'ml') && $defQty > 0) {
|
||||
return [
|
||||
'name' => $name,
|
||||
'quantity' => $defQty,
|
||||
'unit' => $unit,
|
||||
'default_quantity' => $defQty,
|
||||
'package_unit' => $pkgUnit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'quantity' => 1,
|
||||
@@ -13288,13 +13499,11 @@ function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float
|
||||
$weightKg = $qty;
|
||||
}
|
||||
if ($weightKg <= 0) {
|
||||
// Two cases:
|
||||
// A) defQty was 0 (no weight data at all) → "–" is more honest than a fake price.
|
||||
// B) defQty was 1-19 (suspicious: the value was stored as a piece count, not grams;
|
||||
// the assignment was intentionally skipped by the defQty<20 guard above).
|
||||
// In case B, fall back to ppu × qty so the badge shows something rather than €0.00.
|
||||
if (in_array($unit, ['pz', 'conf']) && $defQty > 0) {
|
||||
return round($pricePerUnit * max(1.0, $qty), 2);
|
||||
// Piece/count units with €/kg AI price: estimate weight per piece (never €/kg × piece count).
|
||||
if (in_array($unit, ['pz', 'conf'], true)) {
|
||||
$gramsPerPiece = ($defQty >= 20) ? $defQty : 200.0;
|
||||
$weightKg = max(1.0, $qty) * $gramsPerPiece / 1000.0;
|
||||
return round($pricePerUnit * $weightKg, 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ class LoggingPDOStatement {
|
||||
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
class LoggingPDO extends \PDO {
|
||||
#[\ReturnTypeWillChange]
|
||||
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
||||
$stmt = parent::prepare($query, $options);
|
||||
if ($stmt === false) {
|
||||
|
||||
+75
-33
@@ -6809,6 +6809,53 @@ async function quickUse(productId, location) {
|
||||
}
|
||||
}
|
||||
|
||||
const WASTE_REASON_KEYS = ['expired', 'spoiled', 'wrong_location', 'kept_too_long', 'bought_too_much', 'forgotten', 'bad_quality', 'other'];
|
||||
|
||||
function _wasteNotesForReason(reason) {
|
||||
return 'Buttato|' + reason;
|
||||
}
|
||||
|
||||
function _showWasteReasonModal(productLabel, onPick) {
|
||||
const buttons = WASTE_REASON_KEYS.map(r =>
|
||||
`<button type="button" class="btn btn-large full-width" style="margin-bottom:8px;text-align:left" data-waste-reason="${r}">${escapeHtml(t('waste.reason_' + r))}</button>`
|
||||
).join('');
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>${t('waste.reason_title')}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<p style="color:var(--text-muted);margin:8px 0 16px">${escapeHtml(productLabel)}</p>
|
||||
<p style="color:var(--text-muted);margin:0 0 12px;font-size:0.9rem">${t('waste.reason_subtitle')}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:0">${buttons}
|
||||
<button type="button" class="btn btn-secondary full-width" style="margin-top:8px" onclick="closeModal()">${t('confirm.cancel')}</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
document.querySelectorAll('[data-waste-reason]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const reason = btn.getAttribute('data-waste-reason');
|
||||
closeModal();
|
||||
onPick(reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _inventoryWaste(payload, productLabel) {
|
||||
return new Promise((resolve, reject) => {
|
||||
_showWasteReasonModal(productLabel || '', async (reason) => {
|
||||
showLoading(true);
|
||||
try {
|
||||
const result = await api('inventory_use', {}, 'POST', { ...payload, notes: _wasteNotesForReason(reason) });
|
||||
resolve(result);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteInventoryItem(id) {
|
||||
const item = currentInventory.find(i => i.id === id);
|
||||
const unit = item ? (item.unit || 'pz') : 'pz';
|
||||
@@ -6853,19 +6900,15 @@ async function _discardOnePiece(inventoryId) {
|
||||
const item = currentInventory.find(i => i.id === inventoryId);
|
||||
if (!item) { closeModal(); return; }
|
||||
closeModal();
|
||||
showLoading(true);
|
||||
try {
|
||||
await api('inventory_use', {}, 'POST', {
|
||||
await _inventoryWaste({
|
||||
product_id: item.product_id,
|
||||
quantity: 1,
|
||||
location: item.location,
|
||||
notes: 'Buttato'
|
||||
});
|
||||
showLoading(false);
|
||||
}, item.name);
|
||||
showToast(t('toast.thrown_away_partial', { qty: 1, unit: item.unit || 'pz', name: item.name }), 'success');
|
||||
refreshCurrentPage();
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
@@ -6874,19 +6917,15 @@ async function _discardAllFromModal(inventoryId) {
|
||||
const item = currentInventory.find(i => i.id === inventoryId);
|
||||
if (!item) { closeModal(); return; }
|
||||
closeModal();
|
||||
showLoading(true);
|
||||
try {
|
||||
await api('inventory_use', {}, 'POST', {
|
||||
await _inventoryWaste({
|
||||
product_id: item.product_id,
|
||||
use_all: true,
|
||||
location: item.location,
|
||||
notes: 'Buttato'
|
||||
});
|
||||
showLoading(false);
|
||||
}, item.name);
|
||||
showToast(t('toast.thrown_away', { name: item.name }), 'success');
|
||||
refreshCurrentPage();
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
@@ -9180,15 +9219,12 @@ async function throwAll() {
|
||||
t('use.throw_all_confirm_title') || '🗑️ Butta tutto',
|
||||
(t('use.throw_all_confirm_msg') || 'Vuoi davvero buttare via tutto il prodotto?') + (name ? `\n"${name}"` : ''),
|
||||
async () => {
|
||||
showLoading(true);
|
||||
try {
|
||||
const result = await api('inventory_use', {}, 'POST', {
|
||||
const result = await _inventoryWaste({
|
||||
product_id: currentProduct.id,
|
||||
use_all: true,
|
||||
location: '__all__',
|
||||
notes: 'Buttato'
|
||||
});
|
||||
showLoading(false);
|
||||
}, name);
|
||||
if (result.success) {
|
||||
showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success');
|
||||
showPage('dashboard');
|
||||
@@ -9196,7 +9232,6 @@ async function throwAll() {
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
},
|
||||
@@ -9208,15 +9243,12 @@ async function throwPartial() {
|
||||
const qty = parseFloat(document.getElementById('throw-quantity').value) || 1;
|
||||
const loc = document.getElementById('throw-location').value;
|
||||
closeModal();
|
||||
showLoading(true);
|
||||
try {
|
||||
const result = await api('inventory_use', {}, 'POST', {
|
||||
const result = await _inventoryWaste({
|
||||
product_id: currentProduct.id,
|
||||
quantity: qty,
|
||||
location: loc,
|
||||
notes: 'Buttato'
|
||||
});
|
||||
showLoading(false);
|
||||
}, currentProduct.name);
|
||||
if (result.success) {
|
||||
showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success');
|
||||
showPage('dashboard');
|
||||
@@ -9224,7 +9256,6 @@ async function throwPartial() {
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
@@ -12128,19 +12159,11 @@ async function syncShoppingPriceTotal(forceRefresh = false) {
|
||||
function _buildPricePayload() {
|
||||
return shoppingItems.map((item) => {
|
||||
const smart = _matchBringToSmart(item.name, smartShoppingItems);
|
||||
if (smart?.suggested_qty > 0) {
|
||||
return {
|
||||
name: item.name,
|
||||
quantity: smart.suggested_qty,
|
||||
unit: smart.suggested_unit || smart.unit || 'conf',
|
||||
default_quantity: smart.default_qty || 0,
|
||||
package_unit: smart.package_unit || '',
|
||||
};
|
||||
}
|
||||
if (smart) {
|
||||
const unit = smart.unit || 'conf';
|
||||
const defQty = parseFloat(smart.default_qty) || 0;
|
||||
const pkgUnit = smart.package_unit || '';
|
||||
// One shopping-list line ≈ one retail purchase (not 14-day restock qty).
|
||||
if (unit === 'conf' && defQty > 0 && pkgUnit) {
|
||||
return {
|
||||
name: item.name,
|
||||
@@ -12150,6 +12173,25 @@ function _buildPricePayload() {
|
||||
package_unit: pkgUnit,
|
||||
};
|
||||
}
|
||||
if (unit === 'pz') {
|
||||
const gramsPerPiece = defQty >= 20 ? defQty : 200;
|
||||
return {
|
||||
name: item.name,
|
||||
quantity: 2,
|
||||
unit: 'pz',
|
||||
default_quantity: gramsPerPiece,
|
||||
package_unit: 'g',
|
||||
};
|
||||
}
|
||||
if ((unit === 'g' || unit === 'ml') && defQty > 0) {
|
||||
return {
|
||||
name: item.name,
|
||||
quantity: defQty,
|
||||
unit,
|
||||
default_quantity: defQty,
|
||||
package_unit: pkgUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { name: item.name, quantity: 1, unit: 'conf', default_quantity: 0, package_unit: '' };
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 18
|
||||
versionName = "1.7.17"
|
||||
versionCode = 20
|
||||
versionName = "1.7.19"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -643,6 +643,79 @@ class KioskActivity : AppCompatActivity() {
|
||||
webView.evaluateJavascript("$jsCallback($escaped)", null)
|
||||
}
|
||||
}
|
||||
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
val installedVc: Long = try {
|
||||
val pi = packageManager.getPackageInfo(packageName, 0)
|
||||
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
|
||||
else @Suppress("DEPRECATION") pi.versionCode.toLong()
|
||||
} catch (_: Exception) { -1L }
|
||||
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(r.size, l.size)) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when {
|
||||
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
|
||||
currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) ->
|
||||
semverNewer(remoteVersion, currentKiosk)
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun applyUpdate(remoteVersion: String, apkUrl: String) {
|
||||
val result = JSONObject()
|
||||
.put("has_update", true)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", remoteVersion)
|
||||
.put("apk_url", apkUrl)
|
||||
notifyJs(result)
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteVersion)
|
||||
.putString(KEY_PENDING_UPDATE_URL, apkUrl)
|
||||
.apply()
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteVersion", apkUrl) }
|
||||
}
|
||||
|
||||
// 1) Prefer LAN/self-hosted update (no GitHub required)
|
||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/')
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
try {
|
||||
val localApi = "$baseUrl/api/index.php?action=kiosk_update"
|
||||
val conn = openTrustedConnection(localApi)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
if (conn.responseCode == 200) {
|
||||
val localJson = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
conn.disconnect()
|
||||
if (localJson.optBoolean("success")) {
|
||||
val remoteVersion = localJson.optString("version", "")
|
||||
val remoteVc = localJson.optLong("version_code", -1L)
|
||||
val apkUrl = localJson.optString("apk_url", "")
|
||||
if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) {
|
||||
applyUpdate(remoteVersion, apkUrl)
|
||||
return@Thread
|
||||
}
|
||||
if (!needsUpdate(remoteVersion, remoteVc)) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("source", "local"))
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
} else conn.disconnect()
|
||||
} catch (_: Exception) { /* fall through to GitHub */ }
|
||||
}
|
||||
|
||||
// 2) GitHub release fallback (requires internet)
|
||||
try {
|
||||
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
@@ -657,43 +730,16 @@ class KioskActivity : AppCompatActivity() {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val json = JSONObject(body)
|
||||
val latestTag = json.optString("tag_name", "")
|
||||
if (latestTag.isEmpty()) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", "no tag"))
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
|
||||
// Extract the actual kiosk version from the release body text.
|
||||
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
|
||||
// Fall back to stripping the tag prefix if body parsing fails.
|
||||
val bodyText = json.optString("body", "")
|
||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
|
||||
.find(bodyText)?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: norm(latestTag)
|
||||
?: norm(json.optString("tag_name", ""))
|
||||
|
||||
// Compare semver: returns true if `remote` is strictly greater than `local`
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val len = maxOf(r.size, l.size)
|
||||
for (i in 0 until len) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
|
||||
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
|
||||
|
||||
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
|
||||
val assets = json.optJSONArray("assets")
|
||||
var kioskApkUrl = ""
|
||||
if (assets != null) {
|
||||
@@ -707,38 +753,35 @@ class KioskActivity : AppCompatActivity() {
|
||||
}
|
||||
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||
|
||||
// Only flag an update when the remote version is parseable as semver AND
|
||||
// strictly greater than the installed version.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
|
||||
semverNewer(remoteKioskVersion, currentKiosk)
|
||||
|
||||
val result = JSONObject()
|
||||
.put("has_update", kioskNeedsUpdate)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", remoteKioskVersion)
|
||||
.put("apk_url", kioskApkUrl)
|
||||
|
||||
notifyJs(result)
|
||||
|
||||
if (!kioskNeedsUpdate) {
|
||||
// Clear any stale pending update if the current version is now up to date
|
||||
if (!needsUpdate(remoteKioskVersion, remoteVc)) {
|
||||
notifyJs(JSONObject().put("has_update", false))
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// Persist the pending update so the banner reappears after a crash/restart
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
|
||||
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
|
||||
.apply()
|
||||
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteKioskVersion", kioskApkUrl) }
|
||||
applyUpdate(remoteKioskVersion, kioskApkUrl)
|
||||
} catch (e: Exception) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
/** HTTPS with self-signed cert support (LAN servers). */
|
||||
private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection {
|
||||
val conn = URL(urlStr).openConnection()
|
||||
if (conn is javax.net.ssl.HttpsURLConnection) {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : javax.net.ssl.X509TrustManager {
|
||||
override fun checkClientTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||
override fun checkServerTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
conn.sslSocketFactory = sc.socketFactory
|
||||
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
return conn as java.net.HttpURLConnection
|
||||
}
|
||||
|
||||
/**
|
||||
* On resume: if a previous session detected an available update and saved it to prefs,
|
||||
* restore the update banner immediately without a network round-trip.
|
||||
|
||||
@@ -540,6 +540,11 @@ class SetupActivity : AppCompatActivity() {
|
||||
// Cancel auto-discover when leaving server step
|
||||
if (step != 3) discoverCancelled.set(true)
|
||||
|
||||
// Auto-discover when entering server step (empty URL only)
|
||||
if (step == 3 && urlEdit.text.toString().trim().isEmpty()) {
|
||||
autoDiscover()
|
||||
}
|
||||
|
||||
// Scroll to top
|
||||
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
|
||||
}
|
||||
@@ -697,6 +702,58 @@ class SetupActivity : AppCompatActivity() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun normalizeDiscoveredBase(urlStr: String): String {
|
||||
var base = urlStr.substringBefore("/api/")
|
||||
if (base.endsWith(":443")) base = base.removeSuffix(":443")
|
||||
if (base.endsWith(":80")) base = base.removeSuffix(":80")
|
||||
return if (base.endsWith("/")) base else "$base/"
|
||||
}
|
||||
|
||||
private fun probeEverShelfEndpoint(urlStr: String): String? {
|
||||
return try {
|
||||
val conn = openConn(urlStr) ?: return null
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..399) {
|
||||
conn.disconnect()
|
||||
return null
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
if (body.contains("gemini_key_set") || body.contains("\"success\"") || body.contains("\"ok\"")) {
|
||||
normalizeDiscoveredBase(urlStr)
|
||||
} else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeEverShelfHost(ip: String, port: Int): String? {
|
||||
val reachable = try {
|
||||
Socket().use { s -> s.connect(InetSocketAddress(ip, port), 800); true }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!reachable) return null
|
||||
|
||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||
val portInUrl = when {
|
||||
scheme == "https" && port == 443 -> ""
|
||||
scheme == "http" && port == 80 -> ""
|
||||
else -> ":$port"
|
||||
}
|
||||
val paths = listOf(
|
||||
"/dispensa/api/index.php?action=ping",
|
||||
"/api/index.php?action=ping",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/api/index.php?action=get_settings",
|
||||
"/evershelf/api/index.php?action=get_settings",
|
||||
)
|
||||
for (path in paths) {
|
||||
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun openConn(urlStr: String): HttpURLConnection? {
|
||||
return try {
|
||||
val conn = URL(urlStr).openConnection()
|
||||
@@ -772,9 +829,52 @@ class SetupActivity : AppCompatActivity() {
|
||||
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
|
||||
|
||||
val ports = listOf(443, 80, 8080, 8443)
|
||||
|
||||
// ── 1b. Fast path: likely hosts on Wi-Fi subnet (incl. .128) before full sweep ─
|
||||
val priorityIps = linkedSetOf<String>()
|
||||
try {
|
||||
val ifaces = NetworkInterface.getNetworkInterfaces()
|
||||
while (ifaces != null && ifaces.hasMoreElements()) {
|
||||
val intf = ifaces.nextElement()
|
||||
if (!intf.isUp || intf.isLoopback) continue
|
||||
for (addr in intf.interfaceAddresses) {
|
||||
val ip = addr.address
|
||||
if (ip is java.net.Inet4Address && !ip.isLoopbackAddress) {
|
||||
priorityIps.add(ip.hostAddress ?: continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
for (subnet in wifiSubnets.ifEmpty { subnets.take(1) }) {
|
||||
for (last in listOf(1, 128, 100, 10, 50, 254)) {
|
||||
priorityIps.add("$subnet.$last")
|
||||
}
|
||||
}
|
||||
|
||||
runOnUiThread { discoverStatus.text = "🔍 ${getString(R.string.setup_discovering_detail)}" }
|
||||
for (ip in priorityIps) {
|
||||
if (discoverCancelled.get()) break
|
||||
for (port in ports) {
|
||||
val hit = probeEverShelfHost(ip, port)
|
||||
if (hit != null) {
|
||||
runOnUiThread {
|
||||
urlEdit.setText(hit)
|
||||
discoverStatus.text = "✅ ${getString(R.string.setup_server_found)}: $hit"
|
||||
discoverStatus.setTextColor(0xFF34d399.toInt())
|
||||
showUrlStatus("✅ ${getString(R.string.setup_server_found)}", true)
|
||||
btnDiscover.isEnabled = true
|
||||
btnDiscover.text = getString(R.string.setup_discover_btn)
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val paths = listOf(
|
||||
"/api/index.php?action=get_settings",
|
||||
"/dispensa/api/index.php?action=ping",
|
||||
"/api/index.php?action=ping",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/api/index.php?action=get_settings",
|
||||
"/evershelf/api/index.php?action=get_settings",
|
||||
)
|
||||
|
||||
@@ -819,30 +919,24 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
// Full HTTP probe on reachable host
|
||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||
val portInUrl = when {
|
||||
scheme == "https" && port == 443 -> ""
|
||||
scheme == "http" && port == 80 -> ""
|
||||
else -> ":$port"
|
||||
}
|
||||
for (path in paths) {
|
||||
if (discoverCancelled.get() || found.get()) break
|
||||
val urlStr = "$scheme://$ip:$port$path"
|
||||
try {
|
||||
val conn = openConn(urlStr) ?: continue
|
||||
val code = conn.responseCode
|
||||
if (code in 200..399) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
|
||||
return@submit urlStr.substringBefore("/api/") + "/"
|
||||
}
|
||||
} else conn.disconnect()
|
||||
} catch (_: Exception) {}
|
||||
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it }
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Collect results as they complete (not in submission order) ────
|
||||
// ── 3. Collect results until all tasks finish or a server is found ────
|
||||
var result: String? = null
|
||||
var collected = 0
|
||||
while (collected < total && !discoverCancelled.get()) {
|
||||
val future = cs.poll(3, TimeUnit.SECONDS) ?: break
|
||||
while (collected < total && !discoverCancelled.get() && result == null) {
|
||||
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue
|
||||
collected++
|
||||
val r = try { future.get() } catch (_: Exception) { null }
|
||||
if (r != null && found.compareAndSet(false, true)) {
|
||||
@@ -1101,9 +1195,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||
}
|
||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
|
||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
|
||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
|
||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"")
|
||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"")
|
||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"")
|
||||
append("}")
|
||||
}
|
||||
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<string name="install_error_download">Download fehlgeschlagen</string>
|
||||
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
|
||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||
<string name="install_perm_detail">Aktiviere 'Unbekannte Apps installieren' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||
<string name="btn_back">Zurück</string>
|
||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
|
||||
|
||||
Zum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → "API-Schlüssel erhalten"</string>
|
||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.\n\nZum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → \"API-Schlüssel erhalten\"</string>
|
||||
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.
|
||||
|
||||
Bring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
||||
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
||||
<string name="setup_done_title">Alles bereit!</string>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<string name="install_error_download">Descarga fallida</string>
|
||||
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
||||
<string name="install_error_install">Instalación fallida</string>
|
||||
<string name="install_perm_detail">Habilita 'Instalar apps desconocidas' en los ajustes y vuelve aquí.</string>
|
||||
<string name="install_perm_detail">Habilita \'Instalar apps desconocidas\' en los ajustes y vuelve aquí.</string>
|
||||
<string name="install_btn_retry">↩ Reintentar</string>
|
||||
<string name="btn_back">Atrás</string>
|
||||
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
|
||||
|
||||
Para activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → "Obtener clave API"</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.\n\nPara activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → \"Obtener clave API\"</string>
|
||||
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.
|
||||
|
||||
Introduce tus credenciales de Bring! para activar la integración.</string>
|
||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.\n\nIntroduce tus credenciales de Bring! para activar la integración.</string>
|
||||
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
||||
<string name="setup_done_title">¡Todo listo!</string>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
<string name="setup_enter_url">Veuillez d'abord saisir une URL</string>
|
||||
<string name="setup_enter_url">Veuillez d\'abord saisir une URL</string>
|
||||
<string name="setup_testing">Test de connexion…</string>
|
||||
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
||||
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
||||
<string name="setup_unreachable">Impossible d'atteindre le serveur</string>
|
||||
<string name="setup_unreachable">Impossible d\'atteindre le serveur</string>
|
||||
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
||||
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
||||
<string name="setup_discovering">Analyse en cours…</string>
|
||||
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l'URL manuellement.</string>
|
||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l\'URL manuellement.</string>
|
||||
<string name="setup_exit_title">Quitter la configuration ?</string>
|
||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l'app.</string>
|
||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l\'app.</string>
|
||||
<string name="setup_exit_confirm">Quitter</string>
|
||||
<string name="setup_exit_cancel">Continuer</string>
|
||||
<string name="setup_step_back">← Retour</string>
|
||||
@@ -22,20 +22,20 @@
|
||||
<string name="wizard_step3_title">Balance intelligente</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
||||
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
||||
<string name="wizard_step3_yes">✅ Oui, j'ai une balance</string>
|
||||
<string name="wizard_step3_yes">✅ Oui, j\'ai une balance</string>
|
||||
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
||||
<string name="ble_scanning">🔍 Scan en cours…</string>
|
||||
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
||||
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu'elle est allumée et à proximité, puis réessayez.</string>
|
||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu\'elle est allumée et à proximité, puis réessayez.</string>
|
||||
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
||||
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
||||
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
||||
<string name="ble_weight_received">Poids reçu — correspond-il à l'affichage de la balance ?</string>
|
||||
<string name="ble_weight_received">Poids reçu — correspond-il à l\'affichage de la balance ?</string>
|
||||
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
||||
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l'une d'elles pour la sélectionner.</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l\'une d\'elles pour la sélectionner.</string>
|
||||
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
||||
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
||||
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
||||
@@ -43,13 +43,13 @@
|
||||
<string name="install_downloading">Téléchargement en cours…</string>
|
||||
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
||||
<string name="install_installing">Installation en cours…</string>
|
||||
<string name="install_confirm_detail">Confirmez l'installation dans la boîte de dialogue ouverte.</string>
|
||||
<string name="install_confirm_detail">Confirmez l\'installation dans la boîte de dialogue ouverte.</string>
|
||||
<string name="install_success">Installé avec succès !</string>
|
||||
<string name="install_success_detail">L'app a été mise à jour.</string>
|
||||
<string name="install_success_detail">L\'app a été mise à jour.</string>
|
||||
<string name="install_error_download">Téléchargement échoué</string>
|
||||
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
||||
<string name="install_error_install">Installation échouée</string>
|
||||
<string name="install_perm_detail">Activez 'Installer des apps inconnues' dans les paramètres, puis revenez ici.</string>
|
||||
<string name="install_perm_detail">Activez \'Installer des apps inconnues\' dans les paramètres, puis revenez ici.</string>
|
||||
<string name="install_btn_retry">↩ Réessayer</string>
|
||||
<string name="btn_back">Retour</string>
|
||||
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
||||
@@ -58,13 +58,13 @@
|
||||
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
||||
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
||||
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
||||
<string name="wizard_server_ok_detail">Rapport d'erreurs actif — les échecs d'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||
<string name="wizard_server_ok_detail">Rapport d\'erreurs actif — les échecs d\'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Les erreurs n'atteindront pas GitHub Issues. Vérifiez l'URL saisie à l'étape 2.</string>
|
||||
<string name="wizard_server_error_detail">Les erreurs n\'atteindront pas GitHub Issues. Vérifiez l\'URL saisie à l\'étape 2.</string>
|
||||
<string name="setup_features_title">Fonctionnalités</string>
|
||||
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
||||
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d'inactivité.</string>
|
||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d\'inactivité.</string>
|
||||
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
||||
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
||||
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
|
||||
|
||||
Pour l'activer, entrez votre clé API Gemini gratuite.</string>
|
||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → "Obtenir une clé API"</string>
|
||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.\n\nPour l\'activer, entrez votre clé API Gemini gratuite.</string>
|
||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → \"Obtenir une clé API\"</string>
|
||||
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Liste de courses</string>
|
||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l'app Bring!.
|
||||
|
||||
Entrez vos identifiants Bring! pour activer l'intégration.</string>
|
||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l\'app Bring!.\n\nEntrez vos identifiants Bring! pour activer l\'intégration.</string>
|
||||
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
||||
<string name="setup_done_title">Tout est prêt !</string>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
||||
<string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l'URL manualmente.</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l'app.</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||
<string name="setup_exit_confirm">Esci</string>
|
||||
<string name="setup_exit_cancel">Continua</string>
|
||||
<string name="setup_step_back">← Indietro</string>
|
||||
@@ -28,28 +28,28 @@
|
||||
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
||||
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
||||
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall'elenco.</string>
|
||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall\'elenco.</string>
|
||||
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
||||
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
||||
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all'avvio.</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell'elenco per connettersi.</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L'app è stata aggiornata.</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita 'Installa app sconosciute' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
<string name="btn_back">Indietro</string>
|
||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||
@@ -60,11 +60,11 @@
|
||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l'URL inserito al passaggio 2.</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||
<string name="setup_features_title">Funzionalità</string>
|
||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||
@@ -72,15 +72,11 @@
|
||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
|
||||
|
||||
Per abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → "Ottieni chiave API"</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.\n\nPer abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → \"Ottieni chiave API\"</string>
|
||||
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l'app Bring!.
|
||||
|
||||
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
|
||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l\'app Bring!.\n\nInserisci le credenziali del tuo account Bring! per abilitare l\'integrazione.</string>
|
||||
<string name="setup_bring_email_hint">Email Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Password Bring!</string>
|
||||
<string name="setup_done_title">Tutto pronto!</string>
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.40</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.42</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.40</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.42</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.40",
|
||||
"version": "1.7.42",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"version":"1.7.19","version_code":20}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/** Delete all comments on open feature/enhancement backlog issues (English-only tracker policy). */
|
||||
declare(strict_types=1);
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
require_once __DIR__ . '/../api/lib/constants.php';
|
||||
|
||||
$token = _ghToken();
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function ghRequest(string $token, string $method, string $url, ?array $body = null): array {
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: token ' . $token,
|
||||
'Accept: application/vnd.github+json',
|
||||
'X-GitHub-Api-Version: 2022-11-28',
|
||||
'User-Agent: EverShelf-Triage/1.0',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($method === 'DELETE') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
} elseif ($method === 'GET') {
|
||||
// default
|
||||
}
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['code' => $code, 'body' => $raw];
|
||||
}
|
||||
|
||||
$issues = [122, 121, 120, 119, 118, 117, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($issues as $num) {
|
||||
$page = 1;
|
||||
while (true) {
|
||||
$url = 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments?per_page=100&page=$page";
|
||||
$r = ghRequest($token, 'GET', $url);
|
||||
if ($r['code'] !== 200) {
|
||||
fwrite(STDERR, "#$num list comments HTTP {$r['code']}\n");
|
||||
break;
|
||||
}
|
||||
$comments = json_decode($r['body'], true);
|
||||
if (!is_array($comments) || empty($comments)) {
|
||||
break;
|
||||
}
|
||||
foreach ($comments as $c) {
|
||||
$id = (int)($c['id'] ?? 0);
|
||||
if ($id <= 0) continue;
|
||||
$dr = ghRequest($token, 'DELETE', 'https://api.github.com/repos/' . GH_REPO . "/issues/comments/$id");
|
||||
if ($dr['code'] === 204) {
|
||||
$deleted++;
|
||||
echo "deleted comment $id on #$num\n";
|
||||
} else {
|
||||
fwrite(STDERR, "FAIL delete comment $id on #$num HTTP {$dr['code']}\n");
|
||||
}
|
||||
usleep(200000);
|
||||
}
|
||||
if (count($comments) < 100) break;
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done. Deleted $deleted comments.\n";
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/** Reopen wrongly closed feature issues; close resolved auto-report bugs (English). */
|
||||
declare(strict_types=1);
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
require_once __DIR__ . '/../api/lib/constants.php';
|
||||
|
||||
$token = _ghToken();
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function ghApi(string $token, string $method, string $url, array $payload = []): array {
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: token ' . $token,
|
||||
'Accept: application/vnd.github+json',
|
||||
'X-GitHub-Api-Version: 2022-11-28',
|
||||
'User-Agent: EverShelf-Triage/1.0',
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
if ($method === 'PATCH') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
} elseif ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||
}
|
||||
|
||||
function comment(string $token, int $num, string $body): void {
|
||||
$r = ghApi($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments", ['body' => $body]);
|
||||
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK comment #$num\n" : "FAIL comment #$num\n";
|
||||
}
|
||||
|
||||
function closeIssue(string $token, int $num): void {
|
||||
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'closed']);
|
||||
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK close #$num\n" : "FAIL close #$num\n";
|
||||
}
|
||||
|
||||
function reopenIssue(string $token, int $num): void {
|
||||
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'open']);
|
||||
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK reopen #$num\n" : "FAIL reopen #$num\n";
|
||||
}
|
||||
|
||||
$reopen = [
|
||||
125 => "Reopened: **voice commands in cooking mode** are not implemented yet (only TTS readout exists). This was closed by mistake during bulk triage — the feature backlog should stay open until hands-free step navigation ships.",
|
||||
98 => "Reopened: **pin favourite products to the top of inventory** is not implemented yet (recipe favourites #124 are done, but product pinning is a separate request). Closed by mistake — keeping on the backlog.",
|
||||
];
|
||||
|
||||
foreach ($reopen as $num => $msg) {
|
||||
comment($token, $num, $msg);
|
||||
reopenIssue($token, $num);
|
||||
}
|
||||
|
||||
$bugs = [
|
||||
201 => 'Fixed in latest develop: `inventory_use` and `shopping_add` now retry on `SQLITE_BUSY` via `dbWithRetry()` (same pattern as #198).',
|
||||
202 => 'Fixed: Bring/internal `shopping_add` wrapped in `dbWithRetry()` to survive cron + PWA concurrent writes.',
|
||||
203 => 'Fixed: `smartShopping()` / `smartShoppingCached()` now call `set_time_limit(120)` so large pantries no longer hit the 30s PHP fatal.',
|
||||
204 => 'Fixed: same as #203 — smart shopping timeout caused HTTP 500; extended execution limit resolves the crash.',
|
||||
];
|
||||
|
||||
foreach ($bugs as $num => $msg) {
|
||||
comment($token, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._");
|
||||
closeIssue($token, $num);
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* One-shot triage: comment + close resolved auto-report bugs; reply on #200 (keep open).
|
||||
* Triage resolved auto-report bugs only (English comments).
|
||||
* Feature/enhancement backlog issues are never bulk-closed here.
|
||||
* Usage: php scripts/triage-open-issues.php [--dry-run]
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
@@ -75,76 +76,22 @@ function closeIssue(string $token, string $repo, int $num, bool $dryRun): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── #200: reply only, keep OPEN ─────────────────────────────────────────────
|
||||
$body200 = <<<'MD'
|
||||
Ciao Marco, grazie per la segnalazione dettagliata.
|
||||
|
||||
Il messaggio **«Impossibile contattare il server»** compare quando il browser **non riesce a completare** la richiesta a `api/index.php?action=health_check`. Quindi phpinfo funziona, ma **l'endpoint API no** (404, redirect, TLS, path sbagliato, ecc.).
|
||||
|
||||
### Check rapidi (dalla macchina dove apri il browser)
|
||||
|
||||
```bash
|
||||
curl -sv "https://TUO-DOMINIO/api/index.php?action=ping"
|
||||
curl -sv "https://TUO-DOMINIO/api/index.php?action=health_check"
|
||||
```
|
||||
|
||||
Se uno dei due fallisce: DevTools → **Network** → URL esatto e **status code** della richiesta `health_check`.
|
||||
|
||||
### Cause frequenti con Traefik + Docker Swarm
|
||||
|
||||
1. **Routing incompleto** — Traefik deve inoltrare `/` **e** `/api/*`, non solo la homepage.
|
||||
2. **Redirect HTTPS** — dietro Traefik serve `X-Forwarded-Proto: https`, oppure disabilitare il redirect in `.htaccess`. Nelle immagini recenti il Dockerfile imposta `SetEnvIf X-Forwarded-Proto "https" HTTPS=on`.
|
||||
3. **Sottopath** — EverShelf usa URL relativi (`api/index.php`); se l'app è su `/sottocartella/`, l'URL pubblico deve essere coerente.
|
||||
4. **Volume `data/`** — al primo avvio può essere quasi vuoto; assicurati permessi scrivibili:
|
||||
```bash
|
||||
docker exec -it CONTAINER chown -R www-data:www-data /var/www/html/data
|
||||
docker exec -it CONTAINER chmod -R 775 /var/www/html/data
|
||||
```
|
||||
5. **`API_TOKEN` in `.env`** — se impostato, compare un prompt token (non «server non raggiungibile»).
|
||||
|
||||
### Per il passo successivo
|
||||
|
||||
Puoi condividere:
|
||||
- URL pubblico esatto (con path)
|
||||
- Output dei due `curl` sopra
|
||||
- Screenshot Network tab su `health_check`
|
||||
- Labels Traefik del servizio (router + middlewares)
|
||||
|
||||
Resta aperta finché non confermi che `ping`/`health_check` rispondono — poi chiudiamo insieme.
|
||||
MD;
|
||||
|
||||
commentIssue($token, $repo, 200, $body200, $dryRun);
|
||||
|
||||
// ── Resolved auto-report bugs ───────────────────────────────────────────────
|
||||
$bugs = [
|
||||
198 => "Risolto in develop: `PRAGMA busy_timeout` portato a 10s e `dbWithRetry()` su `updateInventory` per ritentare su SQLITE_BUSY quando cron smart-shopping e PWA scrivono in parallelo.",
|
||||
199 => "Duplicato di #198 — stesso evento (`inventory_update` → database locked). Fix: retry + busy_timeout aumentato.",
|
||||
196 => "Risolto in v1.7.38+: `saveProduct` intercetta `UNIQUE constraint failed: products.barcode`, fa merge sul prodotto esistente o risponde 409 JSON (`barcode_already_used`) invece di HTTP 500.",
|
||||
197 => "Conseguenza lato PWA del crash PHP #196 — risolto con gestione barcode duplicato in `saveProduct`.",
|
||||
195 => "Risolto: `EverLog::request()` ora riceve sempre stringhe — `\$method = (string)(\$_SERVER['REQUEST_METHOD'] ?? 'GET')` (fix CLI/cron che passavano null).",
|
||||
193 => "Stesso root cause di #195 (fatal TypeError su `EverLog::request` con method null da CLI). Fix già in develop.",
|
||||
194 => "Risolto: `_applySpesaScanUI` usava `currentPage` (inesistente) → corretto in `_currentPageId`.",
|
||||
192 => "Risolto: in `renderShoppingItems` la variabile `enriched` veniva referenziata prima della dichiarazione (TDZ). Ora `enrichedRaw` → `_dedupeShoppingByGeneric` → `enriched`.",
|
||||
191 => "Risolto: in `_runStartupCheck` `setProgress` è dichiarata prima delle chiamate e `barEl` inizializzato prima dell'uso (niente più TDZ).",
|
||||
134 => "Segnalazione auto-report su volume Docker non scrivibile. Mitigazioni: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile `chown www-data`. Su Swarm: `chown -R www-data:www-data data` al primo boot.",
|
||||
184 => "Correlato a #134: SQLite readonly quando `data/` o `evershelf.db` non sono scrivibili. Fix operativo + chmod WAL/SHM sidecar in `_ensureDbWritable()`.",
|
||||
198 => 'Fixed in develop: `PRAGMA busy_timeout` raised to 10s and `dbWithRetry()` on `updateInventory` retries SQLITE_BUSY when cron and PWA write in parallel.',
|
||||
199 => 'Duplicate of #198 — same event (`inventory_update` → database locked). Fix: retry + longer busy_timeout.',
|
||||
196 => 'Fixed in v1.7.38+: `saveProduct` handles duplicate barcodes (merge or 409 JSON) instead of HTTP 500.',
|
||||
197 => 'PWA side-effect of PHP crash #196 — fixed with duplicate barcode handling in `saveProduct`.',
|
||||
195 => 'Fixed: `EverLog::request()` always receives strings — `(string)($_SERVER[\'REQUEST_METHOD\'] ?? \'GET\')`.',
|
||||
193 => 'Same root cause as #195 (TypeError when method was null from CLI).',
|
||||
194 => 'Fixed: `_applySpesaScanUI` referenced `currentPage` → corrected to `_currentPageId`.',
|
||||
192 => 'Fixed: TDZ on `enriched` in `renderShoppingItems`.',
|
||||
191 => 'Fixed: TDZ on `setProgress` / `barEl` in `_runStartupCheck`.',
|
||||
134 => 'Auto-report for non-writable Docker volume. Mitigations: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile chown.',
|
||||
184 => 'Related to #134: SQLite readonly when `data/` is not writable.',
|
||||
];
|
||||
|
||||
foreach ($bugs as $num => $msg) {
|
||||
commentIssue($token, $repo, $num, $msg . "\n\n_Chiuso dopo triage — fix in develop._", $dryRun);
|
||||
closeIssue($token, $repo, $num, $dryRun);
|
||||
}
|
||||
|
||||
// ── Feature / enhancement backlog (close with acknowledgment) ───────────────
|
||||
$features = [122, 121, 120, 119, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
|
||||
$featMsg = <<<'MD'
|
||||
Grazie per la proposta — è nel **backlog** del progetto.
|
||||
|
||||
Chiudiamo questa issue per tenere il tracker focalizzato sui bug attivi; la funzionalità resta nel radar per release future. **Riapri pure** quando vuoi lavorarci o seguirne lo sviluppo.
|
||||
MD;
|
||||
|
||||
foreach ($features as $num) {
|
||||
commentIssue($token, $repo, $num, $featMsg, $dryRun);
|
||||
commentIssue($token, $repo, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._", $dryRun);
|
||||
closeIssue($token, $repo, $num, $dryRun);
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"throw_all_confirm_btn": "🗑️ Ja, entsorgen",
|
||||
"locations_short": "Orte"
|
||||
},
|
||||
"waste": {
|
||||
"reason_title": "Warum wirfst du es weg?",
|
||||
"reason_subtitle": "Das hilft uns, ähnliche Verschwendung zu vermeiden.",
|
||||
"reason_expired": "⏰ Abgelaufen",
|
||||
"reason_spoiled": "🦠 Verdorben",
|
||||
"reason_wrong_location": "📍 Falscher Lagerort",
|
||||
"reason_kept_too_long": "⏳ Zu lange aufbewahrt",
|
||||
"reason_bought_too_much": "🛒 Zu viel gekauft",
|
||||
"reason_forgotten": "😴 Vergessen / nicht rechtzeitig genutzt",
|
||||
"reason_bad_quality": "👎 Schlechte Qualität beim Kauf",
|
||||
"reason_other": "❓ Sonstiges"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Neues Produkt",
|
||||
"title_edit": "Produkt bearbeiten",
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"throw_all_confirm_btn": "🗑️ Yes, discard",
|
||||
"locations_short": "places"
|
||||
},
|
||||
"waste": {
|
||||
"reason_title": "Why are you discarding it?",
|
||||
"reason_subtitle": "This helps us prevent similar waste next time.",
|
||||
"reason_expired": "⏰ Expired",
|
||||
"reason_spoiled": "🦠 Spoiled / gone bad",
|
||||
"reason_wrong_location": "📍 Wrong storage (fridge/freezer/pantry)",
|
||||
"reason_kept_too_long": "⏳ Kept too long",
|
||||
"reason_bought_too_much": "🛒 Bought too much",
|
||||
"reason_forgotten": "😴 Forgotten / not used in time",
|
||||
"reason_bad_quality": "👎 Poor quality when bought",
|
||||
"reason_other": "❓ Other"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "New Product",
|
||||
"title_edit": "Edit Product",
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"throw_all_confirm_btn": "🗑️ Sí, desechar",
|
||||
"locations_short": "ubicaciones"
|
||||
},
|
||||
"waste": {
|
||||
"reason_title": "¿Por qué lo tiras?",
|
||||
"reason_subtitle": "Nos ayuda a evitar desperdicios similares.",
|
||||
"reason_expired": "⏰ Caducado",
|
||||
"reason_spoiled": "🦠 Estropeado",
|
||||
"reason_wrong_location": "📍 Lugar de guardado incorrecto",
|
||||
"reason_kept_too_long": "⏳ Guardado demasiado tiempo",
|
||||
"reason_bought_too_much": "🛒 Comprado de más",
|
||||
"reason_forgotten": "😴 Olvidado / no usado a tiempo",
|
||||
"reason_bad_quality": "👎 Mala calidad al comprar",
|
||||
"reason_other": "❓ Otro"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuevo producto",
|
||||
"title_edit": "Editar producto",
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"throw_all_confirm_btn": "🗑️ Oui, jeter",
|
||||
"locations_short": "emplacements"
|
||||
},
|
||||
"waste": {
|
||||
"reason_title": "Pourquoi le jetez-vous ?",
|
||||
"reason_subtitle": "Cela nous aide à éviter des gaspillages similaires.",
|
||||
"reason_expired": "⏰ Périmé",
|
||||
"reason_spoiled": "🦠 Abîmé / gâté",
|
||||
"reason_wrong_location": "📍 Mauvais emplacement",
|
||||
"reason_kept_too_long": "⏳ Conservé trop longtemps",
|
||||
"reason_bought_too_much": "🛒 Acheté en trop grande quantité",
|
||||
"reason_forgotten": "😴 Oublié / pas utilisé à temps",
|
||||
"reason_bad_quality": "👎 Mauvaise qualité à l'achat",
|
||||
"reason_other": "❓ Autre"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nouveau produit",
|
||||
"title_edit": "Modifier le produit",
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"throw_all_confirm_btn": "🗑️ Sì, butta",
|
||||
"locations_short": "posti"
|
||||
},
|
||||
"waste": {
|
||||
"reason_title": "Perché lo butti?",
|
||||
"reason_subtitle": "Ci aiuta a evitare sprechi simili in futuro.",
|
||||
"reason_expired": "⏰ Scaduto",
|
||||
"reason_spoiled": "🦠 Andato a male / deperito",
|
||||
"reason_wrong_location": "📍 Posto sbagliato (frigo/freezer/dispensa)",
|
||||
"reason_kept_too_long": "⏳ Tenuto troppo a lungo",
|
||||
"reason_bought_too_much": "🛒 Comprato troppo",
|
||||
"reason_forgotten": "😴 Dimenticato / non usato in tempo",
|
||||
"reason_bad_quality": "👎 Qualità scadente all'acquisto",
|
||||
"reason_other": "❓ Altro"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuovo Prodotto",
|
||||
"title_edit": "Modifica Prodotto",
|
||||
|
||||
Reference in New Issue
Block a user