chore: auto-merge develop → main
Triggered by: fa0442e feat: native shopping list — decouple from Bring! (#105)
This commit is contained in:
@@ -89,6 +89,41 @@ PRICE_CURRENCY=EUR
|
||||
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
|
||||
PRICE_UPDATE_MONTHS=3
|
||||
|
||||
# ── Cleanup / retention ──────────────────────────────────────────────────────
|
||||
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
|
||||
RECIPE_RETENTION_DAYS=7
|
||||
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
|
||||
# Smart Shopping uses this history to compute purchase frequencies.
|
||||
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
|
||||
# Minimum enforced at runtime: 30 days.
|
||||
TRANSACTION_RETENTION_DAYS=90
|
||||
|
||||
# ── Local Backup ─────────────────────────────────────────────────────────────
|
||||
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
|
||||
BACKUP_ENABLED=true
|
||||
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
|
||||
BACKUP_RETENTION_DAYS=3
|
||||
|
||||
# ── Google Drive Backup ───────────────────────────────────────────────────────
|
||||
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
|
||||
GDRIVE_ENABLED=false
|
||||
#
|
||||
# Setup steps:
|
||||
# 1. Create a Google Cloud project and enable the Drive API
|
||||
# 2. Create a Service Account and download the JSON key
|
||||
# 3. Create a Drive folder and share it with the service account email
|
||||
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
|
||||
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
|
||||
#
|
||||
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
|
||||
GDRIVE_SERVICE_ACCOUNT_JSON=
|
||||
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
|
||||
GDRIVE_SERVICE_ACCOUNT_FILE=
|
||||
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
|
||||
GDRIVE_FOLDER_ID=
|
||||
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
|
||||
GDRIVE_RETENTION_DAYS=30
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────────────
|
||||
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
||||
# Leave empty to allow anyone with access to the server to change settings.
|
||||
|
||||
@@ -87,11 +87,45 @@ try {
|
||||
ob_end_clean();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
||||
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','7') . 'd' . ")\n";
|
||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
|
||||
} catch (Throwable $ce) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ── Daily incremental backup ──────────────────────────────────────────
|
||||
// Create a local backup at most once every 23 h; also push to Google Drive
|
||||
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
|
||||
// though the cron runs every 5 minutes.
|
||||
if (env('BACKUP_ENABLED', 'true') === 'true') {
|
||||
try {
|
||||
$lastBackupTs = 0;
|
||||
if (file_exists(BACKUP_LAST_TS_PATH)) {
|
||||
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
|
||||
$lastBackupTs = (int)($lastData['ts'] ?? 0);
|
||||
}
|
||||
if (time() - $lastBackupTs >= 82800) { // 23 h
|
||||
$backupResult = createLocalBackup($db);
|
||||
if ($backupResult['success']) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
|
||||
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
|
||||
if (env('GDRIVE_ENABLED', 'false') === 'true') {
|
||||
$gResult = backupToGDrive($db);
|
||||
if ($gResult['success']) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
|
||||
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||
|
||||
@@ -48,6 +48,13 @@ function getDB(): PDO {
|
||||
? new LoggingPDO('sqlite:' . DB_PATH)
|
||||
: new PDO('sqlite:' . DB_PATH);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
|
||||
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
|
||||
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||
// For SQLite, we use PRAGMA busy_timeout.
|
||||
$db->exec('PRAGMA journal_mode = WAL;');
|
||||
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
|
||||
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
$db->exec("PRAGMA foreign_keys=ON");
|
||||
@@ -244,6 +251,22 @@ function migrateDB(PDO $db): void {
|
||||
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
||||
|
||||
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
|
||||
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
|
||||
if (empty($shopTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE shopping_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
raw_name TEXT NOT NULL DEFAULT '',
|
||||
specification TEXT NOT NULL DEFAULT '',
|
||||
added_at INTEGER DEFAULT (strftime('%s','now')),
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)
|
||||
");
|
||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+148
-28
@@ -661,6 +661,7 @@ $_writeActions = [
|
||||
'inventory_add','inventory_use','inventory_update','inventory_remove',
|
||||
'product_save','product_delete','product_merge',
|
||||
'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names',
|
||||
'shopping_add','shopping_remove',
|
||||
'dismiss_anomaly','save_settings',
|
||||
];
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($rateLimitAction, $_writeActions, true)) {
|
||||
@@ -836,6 +837,19 @@ try {
|
||||
case 'bring_suggest':
|
||||
bringSuggestItems($db);
|
||||
break;
|
||||
// Shopping abstraction layer (delegates to internal DB or Bring!)
|
||||
case 'shopping_list':
|
||||
shoppingGetList($db);
|
||||
break;
|
||||
case 'shopping_add':
|
||||
shoppingAdd($db);
|
||||
break;
|
||||
case 'shopping_remove':
|
||||
shoppingRemove($db);
|
||||
break;
|
||||
case 'shopping_suggest':
|
||||
bringSuggestItems($db);
|
||||
break;
|
||||
case 'smart_shopping':
|
||||
smartShoppingCached($db);
|
||||
break;
|
||||
@@ -3031,6 +3045,12 @@ function getServerSettings(): void {
|
||||
'gdrive_retention_days' => (int)env('GDRIVE_RETENTION_DAYS', '30'),
|
||||
'gdrive_client_id_set' => !empty(env('GDRIVE_CLIENT_ID')),
|
||||
'gdrive_refresh_token_set'=> !empty(env('GDRIVE_REFRESH_TOKEN')),
|
||||
// Shopping list
|
||||
'shopping_enabled' => env('SHOPPING_ENABLED', 'true') === 'true',
|
||||
'shopping_mode' => env('SHOPPING_MODE', 'internal'),
|
||||
'shopping_smart_suggestions' => env('SHOPPING_SMART_SUGGESTIONS', 'true') === 'true',
|
||||
'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true',
|
||||
'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3095,6 +3115,7 @@ function saveSettings(): void {
|
||||
'gdrive_folder_id' => 'GDRIVE_FOLDER_ID',
|
||||
'gdrive_client_id' => 'GDRIVE_CLIENT_ID',
|
||||
'gdrive_client_secret' => 'GDRIVE_CLIENT_SECRET',
|
||||
'shopping_mode' => 'SHOPPING_MODE',
|
||||
];
|
||||
// Boolean keys
|
||||
$boolMap = [
|
||||
@@ -3112,6 +3133,9 @@ function saveSettings(): void {
|
||||
'zerowaste_tips_enabled' => 'ZEROWASTE_TIPS_ENABLED',
|
||||
'backup_enabled' => 'BACKUP_ENABLED',
|
||||
'gdrive_enabled' => 'GDRIVE_ENABLED',
|
||||
'shopping_enabled' => 'SHOPPING_ENABLED',
|
||||
'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS',
|
||||
'shopping_forecast' => 'SHOPPING_FORECAST',
|
||||
];
|
||||
// Integer keys
|
||||
$intMap = [
|
||||
@@ -3122,7 +3146,8 @@ function saveSettings(): void {
|
||||
'transaction_retention_days' => 'TRANSACTION_RETENTION_DAYS',
|
||||
'vacuum_expiry_extension_days'=> 'VACUUM_EXPIRY_EXTENSION_DAYS',
|
||||
'backup_retention_days' => 'BACKUP_RETENTION_DAYS',
|
||||
'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS',
|
||||
'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS',
|
||||
'shopping_auto_add_threshold' => 'SHOPPING_AUTO_ADD_THRESHOLD',
|
||||
];
|
||||
// Float keys
|
||||
$floatMap = [
|
||||
@@ -6232,48 +6257,67 @@ function computeShoppingName(string $name, string $category = '', string $brand
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time Bring! sync for a single product.
|
||||
* Called after inventory changes (use/update/add) to keep Bring! in sync immediately
|
||||
* instead of waiting for the next cron cycle.
|
||||
* Real-time shopping sync for a single product.
|
||||
* Called after inventory changes (use/update/add) to keep the shopping list in sync immediately.
|
||||
* Delegates to Bring! or internal DB depending on SHOPPING_MODE.
|
||||
*/
|
||||
function bringQuickSyncProduct(PDO $db, int $productId): void {
|
||||
$stmt = $db->prepare("SELECT SUM(quantity) FROM inventory WHERE product_id = ? AND quantity > 0");
|
||||
$stmt->execute([$productId]);
|
||||
$totalQty = (float)($stmt->fetchColumn() ?: 0);
|
||||
|
||||
$auth = bringAuth();
|
||||
if (!$auth) return;
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
|
||||
$stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?");
|
||||
$stmt->execute([$productId]);
|
||||
$prod = $stmt->fetch();
|
||||
if (!$prod) return;
|
||||
|
||||
$genericName = $prod['shopping_name'] ?: computeShoppingName($prod['name'], '', $prod['brand']);
|
||||
$bringName = italianToBring($genericName);
|
||||
|
||||
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
|
||||
if (!$listData || !isset($listData['purchase'])) return;
|
||||
if (isShoppingBringMode()) {
|
||||
// Delegate to Bring!
|
||||
$auth = bringAuth();
|
||||
if (!$auth) return;
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
$bringName = italianToBring($genericName);
|
||||
|
||||
$onBring = false;
|
||||
foreach ($listData['purchase'] as $item) {
|
||||
if (strcasecmp($item['name'] ?? '', $bringName) === 0) { $onBring = true; break; }
|
||||
}
|
||||
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
|
||||
if (!$listData || !isset($listData['purchase'])) return;
|
||||
|
||||
if ($totalQty <= 0 && !$onBring) {
|
||||
// Out of stock — add to Bring!
|
||||
$spec = $genericName !== $prod['name']
|
||||
? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') . ' · 🛒 Esaurito'
|
||||
: ($prod['brand'] ? $prod['brand'] . ' · ' : '') . '🛒 Esaurito';
|
||||
bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}",
|
||||
http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec]));
|
||||
EverLog::info('bringQuickSync: added to Bring!', ['product_id' => $productId, 'name' => $bringName]);
|
||||
} elseif ($totalQty > 0 && $onBring) {
|
||||
// Back in stock — remove from Bring!
|
||||
bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}",
|
||||
http_build_query(['uuid' => $listUUID, 'remove' => $bringName]));
|
||||
EverLog::info('bringQuickSync: removed from Bring!', ['product_id' => $productId, 'name' => $bringName]);
|
||||
$onBring = false;
|
||||
foreach ($listData['purchase'] as $item) {
|
||||
if (strcasecmp($item['name'] ?? '', $bringName) === 0) { $onBring = true; break; }
|
||||
}
|
||||
|
||||
if ($totalQty <= 0 && !$onBring) {
|
||||
$spec = $genericName !== $prod['name']
|
||||
? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') . ' · 🛒 Esaurito'
|
||||
: ($prod['brand'] ? $prod['brand'] . ' · ' : '') . '🛒 Esaurito';
|
||||
bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}",
|
||||
http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec]));
|
||||
EverLog::info('bringQuickSync: added to Bring!', ['product_id' => $productId, 'name' => $bringName]);
|
||||
} elseif ($totalQty > 0 && $onBring) {
|
||||
bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}",
|
||||
http_build_query(['uuid' => $listUUID, 'remove' => $bringName]));
|
||||
EverLog::info('bringQuickSync: removed from Bring!', ['product_id' => $productId, 'name' => $bringName]);
|
||||
}
|
||||
} else {
|
||||
// Internal mode
|
||||
$threshold = (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0');
|
||||
$stmtCheck = $db->prepare("SELECT id FROM shopping_list WHERE lower(name) = lower(?)");
|
||||
$stmtCheck->execute([$genericName]);
|
||||
$onList = (bool)$stmtCheck->fetch();
|
||||
|
||||
if ($totalQty <= $threshold && !$onList) {
|
||||
$spec = $genericName !== $prod['name']
|
||||
? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '')
|
||||
: ($prod['brand'] ?: '');
|
||||
$db->prepare("INSERT OR IGNORE INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)")
|
||||
->execute([$genericName, $prod['name'], $spec]);
|
||||
EverLog::info('shoppingQuickSync: added to internal list', ['product_id' => $productId, 'name' => $genericName]);
|
||||
} elseif ($totalQty > $threshold && $onList) {
|
||||
$db->prepare("DELETE FROM shopping_list WHERE lower(name) = lower(?)")->execute([$genericName]);
|
||||
EverLog::info('shoppingQuickSync: removed from internal list', ['product_id' => $productId, 'name' => $genericName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8041,6 +8085,82 @@ function bringSuggestItems(PDO $db): void {
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// ===== SHOPPING ABSTRACTION (internal DB or Bring!) =====
|
||||
|
||||
function isShoppingBringMode(): bool {
|
||||
return env('SHOPPING_MODE', 'internal') === 'bring'
|
||||
&& !empty(env('BRING_EMAIL'))
|
||||
&& !empty(env('BRING_PASSWORD'));
|
||||
}
|
||||
|
||||
function shoppingGetList(PDO $db): void {
|
||||
if (isShoppingBringMode()) {
|
||||
bringGetList();
|
||||
return;
|
||||
}
|
||||
$items = $db->query(
|
||||
"SELECT name, raw_name, specification FROM shopping_list ORDER BY sort_order ASC, added_at ASC"
|
||||
)->fetchAll();
|
||||
$purchase = array_map(fn($r) => [
|
||||
'name' => $r['name'],
|
||||
'rawName' => $r['raw_name'] ?: $r['name'],
|
||||
'specification' => $r['specification'],
|
||||
], $items);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'listUUID' => 'internal-list',
|
||||
'purchase' => $purchase,
|
||||
'recently' => [],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function shoppingAdd(PDO $db): void {
|
||||
if (isShoppingBringMode()) {
|
||||
bringAddItems();
|
||||
return;
|
||||
}
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$items = $input['items'] ?? [];
|
||||
$added = 0; $updated = 0; $skipped = 0;
|
||||
foreach ($items as $item) {
|
||||
$name = trim($item['name'] ?? '');
|
||||
if ($name === '') continue;
|
||||
$rawName = trim($item['rawName'] ?? $item['raw_name'] ?? $name);
|
||||
$spec = $item['specification'] ?? '';
|
||||
$updateSpec = !empty($item['update_spec']);
|
||||
$stmt = $db->prepare("SELECT id, specification FROM shopping_list WHERE lower(name) = lower(?)");
|
||||
$stmt->execute([$name]);
|
||||
$existing = $stmt->fetch();
|
||||
if ($existing) {
|
||||
if ($updateSpec && $existing['specification'] !== $spec) {
|
||||
$db->prepare("UPDATE shopping_list SET specification=?, raw_name=? WHERE id=?")->execute([$spec, $rawName, $existing['id']]);
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
} else {
|
||||
$db->prepare("INSERT INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)")->execute([$name, $rawName, $spec]);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => []]);
|
||||
}
|
||||
|
||||
function shoppingRemove(PDO $db): void {
|
||||
if (isShoppingBringMode()) {
|
||||
bringRemoveItem();
|
||||
return;
|
||||
}
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$name = trim($input['name'] ?? '');
|
||||
if ($name === '') {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing name']);
|
||||
return;
|
||||
}
|
||||
$db->prepare("DELETE FROM shopping_list WHERE lower(name) = lower(?)")->execute([$name]);
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
// ===== SHARED APP DATA FUNCTIONS =====
|
||||
|
||||
function appSettingsGet(PDO $db): void {
|
||||
|
||||
+73
-23
@@ -2903,6 +2903,8 @@ async function loadSettingsUI() {
|
||||
if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia';
|
||||
if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR';
|
||||
if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3;
|
||||
// Shopping settings (server merge)
|
||||
_applyShoppingSettingsUI(s);
|
||||
}
|
||||
} catch(e) { /* offline, use local */ }
|
||||
// Price settings
|
||||
@@ -2936,6 +2938,8 @@ async function loadSettingsUI() {
|
||||
if (gdriveFolderUiEl && !gdriveFolderUiEl.dataset.loaded) gdriveFolderUiEl.value = s.gdrive_folder_id || '';
|
||||
const gdriveRetUiEl = document.getElementById('setting-gdrive-retention-days');
|
||||
if (gdriveRetUiEl && !gdriveRetUiEl.dataset.loaded) gdriveRetUiEl.value = s.gdrive_retention_days || 30;
|
||||
// Shopping settings
|
||||
_applyShoppingSettingsUI(s);
|
||||
// Hide kiosk download banner if running inside Android WebView (kiosk mode)
|
||||
const kioskBanner = document.getElementById('kiosk-download-banner');
|
||||
if (kioskBanner && /; wv\)/.test(navigator.userAgent)) {
|
||||
@@ -3269,6 +3273,35 @@ function removeAppliance(idx) {
|
||||
renderAppliances(s.appliances);
|
||||
}
|
||||
|
||||
function _applyShoppingSettingsUI(s) {
|
||||
const enabledEl = document.getElementById('setting-shopping-enabled');
|
||||
if (enabledEl) enabledEl.checked = s.shopping_enabled !== false;
|
||||
const mode = s.shopping_mode || 'internal';
|
||||
document.querySelectorAll('input[name="shopping-mode"]').forEach(r => { r.checked = (r.value === mode); });
|
||||
const bringSection = document.getElementById('bring-subsection');
|
||||
if (bringSection) bringSection.style.display = mode === 'bring' ? '' : 'none';
|
||||
const suggestEl = document.getElementById('setting-shopping-smart-suggestions');
|
||||
if (suggestEl) suggestEl.checked = s.shopping_smart_suggestions !== false;
|
||||
const forecastEl = document.getElementById('setting-shopping-forecast');
|
||||
if (forecastEl) forecastEl.checked = s.shopping_forecast !== false;
|
||||
const autoAddEl = document.getElementById('setting-shopping-auto-add');
|
||||
if (autoAddEl) autoAddEl.value = s.shopping_auto_add_threshold || 0;
|
||||
}
|
||||
|
||||
function onShoppingEnabledChange() {
|
||||
const s = getSettings();
|
||||
s.shopping_enabled = document.getElementById('setting-shopping-enabled').checked;
|
||||
saveSettingsToStorage(s);
|
||||
}
|
||||
|
||||
function onShoppingModeChange(value) {
|
||||
const bringSection = document.getElementById('bring-subsection');
|
||||
if (bringSection) bringSection.style.display = value === 'bring' ? '' : 'none';
|
||||
const s = getSettings();
|
||||
s.shopping_mode = value;
|
||||
saveSettingsToStorage(s);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const s = getSettings();
|
||||
// Only update gemini_key if user actually typed something; preserve existing key otherwise
|
||||
@@ -3354,6 +3387,17 @@ async function saveSettings() {
|
||||
if (gdriveFolderEl) s.gdrive_folder_id = gdriveFolderEl.value.trim();
|
||||
const gdriveRetentionEl = document.getElementById('setting-gdrive-retention-days');
|
||||
if (gdriveRetentionEl) s.gdrive_retention_days = parseInt(gdriveRetentionEl.value, 10) || 30;
|
||||
// Shopping settings
|
||||
const shoppingEnabledEl = document.getElementById('setting-shopping-enabled');
|
||||
if (shoppingEnabledEl) s.shopping_enabled = shoppingEnabledEl.checked;
|
||||
const shoppingModeEl = document.querySelector('input[name="shopping-mode"]:checked');
|
||||
if (shoppingModeEl) s.shopping_mode = shoppingModeEl.value;
|
||||
const shoppingSuggestEl = document.getElementById('setting-shopping-smart-suggestions');
|
||||
if (shoppingSuggestEl) s.shopping_smart_suggestions = shoppingSuggestEl.checked;
|
||||
const shoppingForecastEl = document.getElementById('setting-shopping-forecast');
|
||||
if (shoppingForecastEl) s.shopping_forecast = shoppingForecastEl.checked;
|
||||
const shoppingAutoAddEl = document.getElementById('setting-shopping-auto-add');
|
||||
if (shoppingAutoAddEl) s.shopping_auto_add_threshold = parseInt(shoppingAutoAddEl.value, 10) || 0;
|
||||
// OAuth fields
|
||||
const gdriveClientIdEl = document.getElementById('setting-gdrive-client-id');
|
||||
if (gdriveClientIdEl && gdriveClientIdEl.value.trim()) s.gdrive_client_id = gdriveClientIdEl.value.trim();
|
||||
@@ -3412,6 +3456,11 @@ async function saveSettings() {
|
||||
gdrive_retention_days: s.gdrive_retention_days || 30,
|
||||
...(s.gdrive_client_id ? { gdrive_client_id: s.gdrive_client_id } : {}),
|
||||
...(s.gdrive_client_secret ? { gdrive_client_secret: s.gdrive_client_secret } : {}),
|
||||
shopping_enabled: s.shopping_enabled !== false,
|
||||
shopping_mode: s.shopping_mode || 'internal',
|
||||
shopping_smart_suggestions: s.shopping_smart_suggestions !== false,
|
||||
shopping_forecast: s.shopping_forecast !== false,
|
||||
shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0,
|
||||
}, tokenHeader);
|
||||
const statusEl = document.getElementById('settings-status');
|
||||
if (result.success) {
|
||||
@@ -3471,14 +3520,15 @@ function togglePasswordVisibility(inputId) {
|
||||
|
||||
// ===== API HELPER =====
|
||||
async function api(action, params = {}, method = 'GET', body = null, extraHeaders = {}) {
|
||||
// In demo mode, all Bring! write operations are no-ops
|
||||
// In demo mode, all shopping write operations are no-ops
|
||||
if (_demoMode) {
|
||||
const BRING_WRITE_ACTIONS = ['bring_add', 'bring_remove', 'bring_migrate_names', 'bring_set_spec'];
|
||||
const BRING_WRITE_ACTIONS = ['bring_add', 'bring_remove', 'bring_migrate_names', 'bring_set_spec',
|
||||
'shopping_add', 'shopping_remove'];
|
||||
if (BRING_WRITE_ACTIONS.includes(action)) {
|
||||
return { success: true, added: 0, removed: 0, skipped: 0, _demo: true };
|
||||
}
|
||||
// bring_list returns the in-memory demo list
|
||||
if (action === 'bring_list') {
|
||||
// shopping_list / bring_list return the in-memory demo list
|
||||
if (action === 'shopping_list' || action === 'bring_list') {
|
||||
return { success: true, purchase: shoppingItems, listUUID: 'demo-list', _demo: true };
|
||||
}
|
||||
}
|
||||
@@ -8246,7 +8296,7 @@ async function submitAdd(e) {
|
||||
// try a client-side fuzzy remove using the already-loaded shoppingItems
|
||||
const match = _findSimilarItem(currentProduct.name, shoppingItems);
|
||||
if (match) {
|
||||
api('bring_remove', {}, 'POST', {
|
||||
api('shopping_remove', {}, 'POST', {
|
||||
name: match.name,
|
||||
rawName: match.rawName || '',
|
||||
listUUID: shoppingListUUID
|
||||
@@ -8869,7 +8919,7 @@ function showLowStockBringPrompt(result, afterCallback) {
|
||||
try {
|
||||
const payload = { items: [{ name: shoppingName, specification: spec }] };
|
||||
if (shoppingListUUID) payload.listUUID = shoppingListUUID;
|
||||
const data = await api('bring_add', {}, 'POST', payload);
|
||||
const data = await api('shopping_add', {}, 'POST', payload);
|
||||
if (data.success && data.added > 0) {
|
||||
showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info');
|
||||
}
|
||||
@@ -8959,7 +9009,7 @@ async function addLowStockToBring() {
|
||||
window._lowStockSpec = null;
|
||||
const payload = { items: [{ name: bringName, specification: spec }] };
|
||||
if (shoppingListUUID) payload.listUUID = shoppingListUUID;
|
||||
const data = await api('bring_add', {}, 'POST', payload);
|
||||
const data = await api('shopping_add', {}, 'POST', payload);
|
||||
if (data.success && data.added > 0) {
|
||||
// Pin as user-added so cleanup never auto-removes it
|
||||
const pinned = Object.assign({}, _pinnedBringCache || {});
|
||||
@@ -9965,7 +10015,7 @@ function toggleShoppingTag(itemIdx, tag) {
|
||||
if (tag === 'urgente' && shoppingListUUID) {
|
||||
const isNowUrgent = existing.includes('urgente');
|
||||
const newSpec = isNowUrgent ? t('shopping.urgency_spec_critical') : '';
|
||||
api('bring_add', {}, 'POST', {
|
||||
api('shopping_add', {}, 'POST', {
|
||||
items: [{ name: item.name, specification: newSpec, update_spec: true }],
|
||||
listUUID: shoppingListUUID,
|
||||
}).catch(() => {});
|
||||
@@ -9993,7 +10043,7 @@ async function confirmShoppingItemFound() {
|
||||
_spesaScanTarget = null;
|
||||
document.getElementById('shopping-scan-target-banner').style.display = 'none';
|
||||
try {
|
||||
const r = await api('bring_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID });
|
||||
const r = await api('shopping_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID });
|
||||
if (r.success) {
|
||||
const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase());
|
||||
if (idx >= 0) shoppingItems.splice(idx, 1);
|
||||
@@ -10113,7 +10163,7 @@ async function autoAddCriticalItems() {
|
||||
if (toAdd.length === 0) return;
|
||||
const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) }));
|
||||
try {
|
||||
const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
||||
const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
||||
if (result.success && result.added > 0) {
|
||||
// Track these as auto-added so cleanupObsoleteBringItems can safely remove them later
|
||||
_markAutoAddedBring(itemsToAdd.map(i => i.name));
|
||||
@@ -10508,7 +10558,7 @@ async function cleanupObsoleteBringItems() {
|
||||
const removedNames = [];
|
||||
for (const item of toRemove) {
|
||||
try {
|
||||
const r = await api('bring_remove', {}, 'POST', {
|
||||
const r = await api('shopping_remove', {}, 'POST', {
|
||||
name: item.name,
|
||||
rawName: item.rawName || '',
|
||||
listUUID: shoppingListUUID
|
||||
@@ -10934,7 +10984,7 @@ async function addSmartToBring() {
|
||||
|
||||
showLoading(true);
|
||||
try {
|
||||
const result = await api('bring_add', {}, 'POST', {
|
||||
const result = await api('shopping_add', {}, 'POST', {
|
||||
items: itemsToAdd,
|
||||
listUUID: shoppingListUUID,
|
||||
});
|
||||
@@ -10968,7 +11018,7 @@ async function loadShoppingCount() {
|
||||
const el = document.getElementById('stat-spesa');
|
||||
if (el) el.classList.add('stat-loading');
|
||||
try {
|
||||
const data = await api('bring_list');
|
||||
const data = await api('shopping_list');
|
||||
if (el) {
|
||||
if (data.success && data.purchase) {
|
||||
el.textContent = data.purchase.length;
|
||||
@@ -11097,7 +11147,7 @@ async function autoSyncUrgencySpecs() {
|
||||
}
|
||||
if (toUpdate.length === 0) return;
|
||||
try {
|
||||
await api('bring_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID });
|
||||
await api('shopping_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID });
|
||||
} catch (e) { /* ignore - sync is best-effort */ }
|
||||
}
|
||||
|
||||
@@ -11114,7 +11164,7 @@ async function loadShoppingList() {
|
||||
loadShoppingList._bgCall = false;
|
||||
if (isBackgroundCall) {
|
||||
try {
|
||||
const data = await api('bring_list');
|
||||
const data = await api('shopping_list');
|
||||
if (data.success) {
|
||||
const newItems = data.purchase || [];
|
||||
const newNames = new Set(newItems.map(i => i.name.toLowerCase()));
|
||||
@@ -11164,7 +11214,7 @@ async function loadShoppingList() {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api('bring_list');
|
||||
const data = await api('shopping_list');
|
||||
statusEl.style.display = 'none';
|
||||
|
||||
if (!data.success) {
|
||||
@@ -11401,7 +11451,7 @@ async function removeBringItem(idx) {
|
||||
const item = shoppingItems[idx];
|
||||
if (!item) return;
|
||||
try {
|
||||
const data = await api('bring_remove', {}, 'POST', {
|
||||
const data = await api('shopping_remove', {}, 'POST', {
|
||||
name: item.name,
|
||||
rawName: item.rawName || '',
|
||||
listUUID: shoppingListUUID
|
||||
@@ -11428,7 +11478,7 @@ async function generateSuggestions() {
|
||||
suggestionsEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = await api('bring_suggest', {}, 'POST', {});
|
||||
const data = await api('shopping_suggest', {}, 'POST', {});
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`;
|
||||
@@ -11578,7 +11628,7 @@ async function addSelectedSuggestions() {
|
||||
return { name: s.name };
|
||||
});
|
||||
|
||||
const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID });
|
||||
const data = await api('shopping_add', {}, 'POST', { items, listUUID: shoppingListUUID });
|
||||
|
||||
if (data.success) {
|
||||
let msg = data.added === 1 ? t('shopping.bring_added_one') : t('shopping.bring_added_many').replace('{n}', data.added);
|
||||
@@ -14635,7 +14685,7 @@ async function loadScreensaverData() {
|
||||
const [statsRes, invRes, bringRes] = await Promise.all([
|
||||
api('stats'),
|
||||
api('inventory_list'),
|
||||
api('bring_list').catch(() => null)
|
||||
api('shopping_list').catch(() => null)
|
||||
]);
|
||||
_screensaverData = {
|
||||
stats: statsRes,
|
||||
@@ -15816,7 +15866,7 @@ async function _backgroundBringSync() {
|
||||
|
||||
try {
|
||||
const [bringData, smartData] = await Promise.all([
|
||||
api('bring_list').catch(() => null),
|
||||
api('shopping_list').catch(() => null),
|
||||
api('smart_shopping').catch(() => null),
|
||||
]);
|
||||
|
||||
@@ -15893,12 +15943,12 @@ async function _backgroundBringSync() {
|
||||
|
||||
const allChanges = [...toAdd, ...toUpdate];
|
||||
if (allChanges.length > 0) {
|
||||
await api('bring_add', {}, 'POST', { items: allChanges, listUUID });
|
||||
await api('shopping_add', {}, 'POST', { items: allChanges, listUUID });
|
||||
logOperation('bg_bring_sync', { added: toAdd.map(i=>i.name), updated: toUpdate.map(i=>i.name) });
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
await api('bring_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID });
|
||||
await api('shopping_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID });
|
||||
logOperation('bg_bring_remove', { removed: toRemove });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
|
||||
+61
-3
@@ -833,7 +833,7 @@
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
||||
@@ -955,9 +955,36 @@
|
||||
</div>
|
||||
<!-- Bring! Tab -->
|
||||
<div class="settings-panel" id="tab-bring">
|
||||
<!-- Shopping enable + provider -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
|
||||
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
|
||||
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
|
||||
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="shopping-mode-group">
|
||||
<label data-i18n="settings.shopping.mode_label">Provider</label>
|
||||
<div class="radio-group" style="margin-top:6px">
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
|
||||
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
|
||||
</label>
|
||||
<label class="radio-option" style="margin-left:16px">
|
||||
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
|
||||
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bring! sub-section (shown only when mode = bring) -->
|
||||
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
|
||||
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
||||
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
||||
@@ -968,6 +995,37 @@
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Smart suggestions + forecast -->
|
||||
<div class="settings-card" style="margin-top:12px">
|
||||
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-shopping-smart-suggestions">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-shopping-forecast">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px">
|
||||
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
|
||||
<div class="qty-control" style="margin-top:6px">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)">−</button>
|
||||
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
|
||||
</div>
|
||||
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Price Estimation Settings -->
|
||||
<div class="settings-card" style="margin-top:12px">
|
||||
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
||||
|
||||
+16
-1
@@ -849,7 +849,22 @@
|
||||
"currency_title": "Währung",
|
||||
"currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird."
|
||||
},
|
||||
"tab_general": "Allgemein"
|
||||
"tab_general": "Allgemein",
|
||||
"shopping": {
|
||||
"tab": "Einkaufsliste",
|
||||
"title": "Einkaufsliste",
|
||||
"hint": "Konfiguriere die integrierte Einkaufsliste oder verbinde Bring!.",
|
||||
"enable_label": "Einkaufsliste aktivieren",
|
||||
"mode_label": "Anbieter",
|
||||
"mode_internal": "Intern (ohne Bring!)",
|
||||
"mode_bring": "Bring! (externe App)",
|
||||
"bring_section_title": "Bring!-Konfiguration",
|
||||
"ai_section_title": "KI-Unterstützung",
|
||||
"smart_suggestions_label": "KI-Vorschläge",
|
||||
"forecast_label": "Prognose für bald leere Produkte",
|
||||
"auto_add_label": "Automatisch hinzufügen wenn",
|
||||
"auto_add_suffix": "im Lager verbleibend (0 = nur wenn leer)"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HEUTE",
|
||||
|
||||
+16
-1
@@ -849,7 +849,22 @@
|
||||
"currency_title": "Currency",
|
||||
"currency_hint": "The currency used for all costs and prices in the app."
|
||||
},
|
||||
"tab_general": "General"
|
||||
"tab_general": "General",
|
||||
"shopping": {
|
||||
"tab": "Shopping list",
|
||||
"title": "Shopping list",
|
||||
"hint": "Configure the built-in shopping list or connect Bring!.",
|
||||
"enable_label": "Enable shopping list",
|
||||
"mode_label": "Provider",
|
||||
"mode_internal": "Built-in (no Bring!)",
|
||||
"mode_bring": "Bring! (external app)",
|
||||
"bring_section_title": "Bring! configuration",
|
||||
"ai_section_title": "AI assistance",
|
||||
"smart_suggestions_label": "AI suggestions",
|
||||
"forecast_label": "Forecast low-stock products",
|
||||
"auto_add_label": "Auto-add to list when",
|
||||
"auto_add_suffix": "remaining in stock (0 = only when empty)"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
"today": "TODAY",
|
||||
|
||||
@@ -806,6 +806,21 @@
|
||||
"gdrive_oauth_window_opened": "Ventana abierta — autoriza y regresa aquí",
|
||||
"gdrive_oauth_how_to": "Cómo configurar OAuth 2.0 (paso a paso)",
|
||||
"gdrive_oauth_steps": "<li>Ve a <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> y selecciona tu proyecto</li><li>Habilita la <strong>API de Google Drive</strong>: <em>API y servicios → Habilitar API → Google Drive API</em></li><li>Ve a <em>API y servicios → Credenciales → Crear credenciales → ID de cliente OAuth</em></li><li>Tipo de aplicación: <strong>Aplicación web</strong>; agrega la URL mostrada abajo como <em>URI de redirección autorizado</em></li><li>Copia el <strong>Client ID</strong> y el <strong>Client Secret</strong> en los campos de arriba y guarda</li><li>Haz clic en <strong>Autorizar con Google</strong>: inicia sesión en tu cuenta de Google y concede acceso</li><li>La ventana se cierra automáticamente al finalizar y las copias de seguridad están listas</li>"
|
||||
},
|
||||
"shopping": {
|
||||
"tab": "Lista de la compra",
|
||||
"title": "Lista de la compra",
|
||||
"hint": "Configura la lista de la compra integrada o conecta Bring!.",
|
||||
"enable_label": "Activar lista de la compra",
|
||||
"mode_label": "Proveedor",
|
||||
"mode_internal": "Integrado (sin Bring!)",
|
||||
"mode_bring": "Bring! (app externa)",
|
||||
"bring_section_title": "Configuración de Bring!",
|
||||
"ai_section_title": "Asistencia IA",
|
||||
"smart_suggestions_label": "Sugerencias IA",
|
||||
"forecast_label": "Previsión de productos por agotar",
|
||||
"auto_add_label": "Añadir automáticamente cuando",
|
||||
"auto_add_suffix": "restante en stock (0 = solo cuando se agota)"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
|
||||
@@ -806,6 +806,21 @@
|
||||
"gdrive_oauth_window_opened": "Fenêtre ouverte — autorisez et revenez ici",
|
||||
"gdrive_oauth_how_to": "Configurer OAuth 2.0 (étape par étape)",
|
||||
"gdrive_oauth_steps": "<li>Allez sur <a href='https://console.cloud.google.com/' target='_blank' rel='noopener'>console.cloud.google.com</a> et sélectionnez votre projet</li><li>Activez l’<strong>API Google Drive</strong> : <em>API et services → Activer les API → Google Drive API</em></li><li>Allez dans <em>API et services → Identifiants → Créer des identifiants → ID client OAuth</em></li><li>Type d’application : <strong>Application Web</strong> ; ajoutez l’URL affichée ci-dessous comme <em>URI de redirection autorisé</em></li><li>Copiez le <strong>Client ID</strong> et le <strong>Client Secret</strong> dans les champs ci-dessus et enregistrez</li><li>Cliquez sur <strong>Autoriser avec Google</strong> : connectez-vous et accordez l’accès</li><li>La fenêtre se ferme automatiquement une fois terminé et les sauvegardes sont prêtes</li>"
|
||||
},
|
||||
"shopping": {
|
||||
"tab": "Liste de courses",
|
||||
"title": "Liste de courses",
|
||||
"hint": "Configurez la liste de courses intégrée ou connectez Bring!.",
|
||||
"enable_label": "Activer la liste de courses",
|
||||
"mode_label": "Fournisseur",
|
||||
"mode_internal": "Intégré (sans Bring!)",
|
||||
"mode_bring": "Bring! (application externe)",
|
||||
"bring_section_title": "Configuration Bring!",
|
||||
"ai_section_title": "Assistance IA",
|
||||
"smart_suggestions_label": "Suggestions IA",
|
||||
"forecast_label": "Prévision des produits bientôt épuisés",
|
||||
"auto_add_label": "Ajouter automatiquement quand",
|
||||
"auto_add_suffix": "restant en stock (0 = seulement quand épuisé)"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
|
||||
+16
-1
@@ -849,7 +849,22 @@
|
||||
"currency_title": "Valuta",
|
||||
"currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app."
|
||||
},
|
||||
"tab_general": "Generali"
|
||||
"tab_general": "Generali",
|
||||
"shopping": {
|
||||
"tab": "Lista spesa",
|
||||
"title": "Lista della spesa",
|
||||
"hint": "Configura la lista della spesa integrata o collega Bring!.",
|
||||
"enable_label": "Abilita lista della spesa",
|
||||
"mode_label": "Provider",
|
||||
"mode_internal": "Interno (senza Bring!)",
|
||||
"mode_bring": "Bring! (app esterna)",
|
||||
"bring_section_title": "Configurazione Bring!",
|
||||
"ai_section_title": "Assistenza AI",
|
||||
"smart_suggestions_label": "Suggerimenti AI",
|
||||
"forecast_label": "Previsione prodotti in esaurimento",
|
||||
"auto_add_label": "Aggiungi automaticamente quando",
|
||||
"auto_add_suffix": "rimasto in magazzino (0 = solo quando esaurito)"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
"today": "OGGI",
|
||||
|
||||
Reference in New Issue
Block a user