Compare commits
4 Commits
kiosk-1.7.19
...
v1.7.42
| Author | SHA1 | Date | |
|---|---|---|---|
| 85ccdaa6f6 | |||
| 16993135b9 | |||
| d1716fa6ff | |||
| 3ac42f7767 |
@@ -11,6 +11,20 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+166
-6
@@ -3127,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);
|
||||
@@ -3143,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
|
||||
@@ -3201,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]);
|
||||
|
||||
@@ -3218,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;
|
||||
}
|
||||
@@ -3276,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
|
||||
@@ -3367,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']]);
|
||||
}
|
||||
@@ -10612,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');
|
||||
@@ -10690,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');
|
||||
|
||||
@@ -10801,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;
|
||||
@@ -11363,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'],
|
||||
@@ -11644,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) {
|
||||
|
||||
+55
-24
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
+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.41</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.41</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.41",
|
||||
"version": "1.7.42",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1 @@
|
||||
{
|
||||
"version": "1.7.19",
|
||||
"version_code": 20
|
||||
}
|
||||
{"version":"1.7.19","version_code":20}
|
||||
|
||||
@@ -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";
|
||||
@@ -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