diff --git a/public/share-list.html b/public/share-list.html
new file mode 100644
index 0000000..486187f
--- /dev/null
+++ b/public/share-list.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+
ForkFolio β Shared Shopping List
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Shopping List Not Found
+
This shared shopping list link may have been revoked or no longer exists.
+
+ Back to ForkFolio
+
+
+
+
+
+
+
+
+
+
Weekly Groceries
+
+
+ Shared by @chef
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This shopping list is currently empty
+
No items have been added to this list yet.
+
+
+
+
+
+
diff --git a/public/share-list.js b/public/share-list.js
new file mode 100644
index 0000000..fe611fa
--- /dev/null
+++ b/public/share-list.js
@@ -0,0 +1,316 @@
+let sharedListData = null;
+let sharedShareToken = null;
+let consolidateItems = true;
+let groupByMode = 'aisle';
+
+const AISLE_ORDER = [
+ 'Produce',
+ 'Meat & Seafood',
+ 'Dairy & Eggs',
+ 'Canned & Jarred',
+ 'Bakery & Grains',
+ 'Pantry & Spices',
+ 'Frozen',
+ 'Other'
+];
+
+const AISLE_ICONS = {
+ 'Produce': 'apple',
+ 'Meat & Seafood': 'beef',
+ 'Dairy & Eggs': 'milk',
+ 'Canned & Jarred': 'archive',
+ 'Bakery & Grains': 'wheat',
+ 'Pantry & Spices': 'flame',
+ 'Frozen': 'snowflake',
+ 'Other': 'shopping-bag'
+};
+
+window.addEventListener('DOMContentLoaded', () => {
+ initTheme();
+ lucide.createIcons();
+
+ document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
+ document.getElementById('shared-toggle-consolidate').addEventListener('change', (e) => {
+ consolidateItems = e.target.checked;
+ renderSharedList();
+ });
+ document.getElementById('shared-group-by').addEventListener('change', (e) => {
+ groupByMode = e.target.value;
+ renderSharedList();
+ });
+ document.getElementById('btn-shared-copy-text').addEventListener('click', handleCopySharedText);
+
+ const params = new URLSearchParams(window.location.search);
+ sharedShareToken = params.get('token');
+ if (!sharedShareToken) {
+ showNotFound();
+ return;
+ }
+
+ loadSharedShoppingList();
+});
+
+function initTheme() {
+ const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
+ if (savedTheme === 'dark') {
+ document.body.classList.add('dark-theme');
+ } else {
+ document.body.classList.remove('dark-theme');
+ }
+}
+
+function toggleTheme() {
+ document.body.classList.toggle('dark-theme');
+ const isDark = document.body.classList.contains('dark-theme');
+ localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
+}
+
+function showNotFound() {
+ document.getElementById('share-list-block').style.display = 'none';
+ document.getElementById('list-not-found-block').style.display = 'flex';
+ lucide.createIcons();
+}
+
+async function loadSharedShoppingList() {
+ try {
+ const res = await fetch(`/api/share/shopping-lists/${encodeURIComponent(sharedShareToken)}`);
+ if (!res.ok) {
+ showNotFound();
+ return;
+ }
+ sharedListData = await res.json();
+ document.title = `${sharedListData.name} β Shared Shopping List | ForkFolio`;
+ document.getElementById('shared-list-title').textContent = sharedListData.name;
+ document.getElementById('shared-list-creator').textContent = `@${sharedListData.createdBy || 'chef'}`;
+ document.getElementById('share-list-block').style.display = 'block';
+ renderSharedList();
+ } catch (err) {
+ console.error('Failed to load shared shopping list:', err);
+ showNotFound();
+ }
+}
+
+function formatScaledNumber(num) {
+ if (num % 1 === 0) return num.toString();
+ const rounded = Math.round(num * 100) / 100;
+ const decimal = rounded % 1;
+ const integer = Math.floor(rounded);
+ if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
+ if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
+ if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
+ if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
+ if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
+ return rounded.toString();
+}
+
+function buildDisplayEntries(items, shouldConsolidate) {
+ if (!shouldConsolidate) {
+ return items.map(item => ({
+ itemIds: [item.id],
+ displayText: item.rawText,
+ category: item.category || 'Other',
+ sources: item.recipeTitle ? [item.recipeTitle] : [],
+ checked: !!item.checked
+ }));
+ }
+
+ const groups = new Map();
+ for (const item of items) {
+ const normName = (item.itemName || item.rawText || '').toLowerCase().trim();
+ const normUnit = (item.unit || '').toLowerCase().trim();
+ const key = `${item.checked ? '1' : '0'}::${normName}::${normUnit}`;
+
+ if (!groups.has(key)) {
+ groups.set(key, {
+ itemIds: [item.id],
+ rawTexts: [item.rawText],
+ itemName: normName,
+ unit: normUnit,
+ totalQty: item.quantity !== null && item.quantity !== undefined ? Number(item.quantity) : null,
+ hasNullQty: item.quantity === null || item.quantity === undefined,
+ category: item.category || 'Other',
+ sources: item.recipeTitle ? [item.recipeTitle] : [],
+ checked: !!item.checked
+ });
+ } else {
+ const g = groups.get(key);
+ g.itemIds.push(item.id);
+ g.rawTexts.push(item.rawText);
+ if (item.quantity !== null && item.quantity !== undefined && !g.hasNullQty) {
+ g.totalQty = (g.totalQty || 0) + Number(item.quantity);
+ } else {
+ g.hasNullQty = true;
+ }
+ if (item.recipeTitle && !g.sources.includes(item.recipeTitle)) {
+ g.sources.push(item.recipeTitle);
+ }
+ }
+ }
+
+ const results = [];
+ for (const g of groups.values()) {
+ let displayText = g.rawTexts[0];
+ if (g.itemIds.length > 1) {
+ if (!g.hasNullQty && g.totalQty !== null && g.totalQty > 0) {
+ const qtyStr = formatScaledNumber(g.totalQty);
+ const unitStr = g.unit ? (g.totalQty > 1 && !g.unit.endsWith('s') && ['clove', 'can', 'jar', 'bunch', 'slice', 'piece', 'head', 'stalk', 'sprig', 'stick', 'cup'].includes(g.unit) ? `${g.unit}s ` : `${g.unit} `) : '';
+ const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
+ displayText = `${qtyStr} ${unitStr}${displayItem}`;
+ } else {
+ const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
+ displayText = `${displayItem} (Γ${g.itemIds.length})`;
+ }
+ }
+ results.push({
+ itemIds: g.itemIds,
+ displayText,
+ category: g.category,
+ sources: g.sources,
+ checked: g.checked
+ });
+ }
+ return results;
+}
+
+function groupEntries(entries, mode) {
+ const grouped = new Map();
+ if (mode === 'recipe') {
+ for (const entry of entries) {
+ const groupLabels = entry.sources.length > 0 ? [entry.sources.join(' + ')] : ['Manual / Household Staples'];
+ for (const label of groupLabels) {
+ if (!grouped.has(label)) grouped.set(label, []);
+ grouped.get(label).push(entry);
+ }
+ }
+ return grouped;
+ }
+
+ for (const aisle of AISLE_ORDER) {
+ const matching = entries.filter(e => (e.category || 'Other') === aisle);
+ if (matching.length > 0) {
+ matching.sort((a, b) => Number(a.checked) - Number(b.checked));
+ grouped.set(aisle, matching);
+ }
+ }
+ return grouped;
+}
+
+function renderSharedList() {
+ const contentEl = document.getElementById('shared-list-content');
+ const emptyEl = document.getElementById('shared-list-empty');
+ if (!sharedListData || !Array.isArray(sharedListData.items) || sharedListData.items.length === 0) {
+ contentEl.innerHTML = '';
+ emptyEl.style.display = 'flex';
+ lucide.createIcons();
+ return;
+ }
+
+ emptyEl.style.display = 'none';
+ contentEl.innerHTML = '';
+
+ const entries = buildDisplayEntries(sharedListData.items, consolidateItems);
+ const grouped = groupEntries(entries, groupByMode);
+
+ for (const [groupName, groupEntries] of grouped.entries()) {
+ const card = document.createElement('div');
+ card.className = 'shopping-category-card';
+ const iconName = AISLE_ICONS[groupName] || 'utensils';
+ const uncheckedCount = groupEntries.filter(e => !e.checked).length;
+
+ card.innerHTML = `
+
+
+ `;
+
+ const ul = card.querySelector('.shopping-items-list');
+ groupEntries.forEach(entry => {
+ const li = document.createElement('li');
+ li.className = `shopping-item-row ${entry.checked ? 'checked' : ''}`;
+ const sourcesHtml = entry.sources.length > 0
+ ? `
${entry.sources.map(s => `${s}`).join('')}
`
+ : '';
+
+ li.innerHTML = `
+
+
+
+ ${entry.displayText}
+ ${sourcesHtml}
+
+
+ `;
+
+ li.addEventListener('click', () => toggleSharedItem(entry.itemIds, !entry.checked));
+ ul.appendChild(li);
+ });
+
+ contentEl.appendChild(card);
+ }
+ lucide.createIcons();
+}
+
+async function toggleSharedItem(itemIds, checked) {
+ try {
+ const res = await fetch(`/api/share/shopping-lists/${encodeURIComponent(sharedShareToken)}/items/check`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ itemIds, checked })
+ });
+ if (res.ok) {
+ sharedListData = await res.json();
+ renderSharedList();
+ }
+ } catch (err) {
+ console.error('Error toggling shared shopping list item:', err);
+ }
+}
+
+let copyTimeout = null;
+async function handleCopySharedText() {
+ if (!sharedListData || !Array.isArray(sharedListData.items)) return;
+ const entries = buildDisplayEntries(sharedListData.items, consolidateItems);
+ const grouped = groupEntries(entries, groupByMode);
+
+ const lines = [`π ${sharedListData.name}`];
+ for (const [groupName, groupEntries] of grouped.entries()) {
+ lines.push(`\n[${groupName}]`);
+ groupEntries.forEach(entry => {
+ lines.push(`${entry.checked ? '[x]' : '[ ]'} ${entry.displayText}`);
+ });
+ }
+
+ const text = lines.join('\n');
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+
+ const btn = document.getElementById('btn-shared-copy-text');
+ if (btn) {
+ if (copyTimeout) clearTimeout(copyTimeout);
+ btn.innerHTML = `
Copied!`;
+ btn.style.borderColor = 'var(--accent)';
+ btn.style.color = 'var(--accent)';
+ lucide.createIcons();
+ copyTimeout = setTimeout(() => {
+ btn.innerHTML = `
Copy Text`;
+ btn.style.borderColor = '';
+ btn.style.color = '';
+ lucide.createIcons();
+ }, 2000);
+ }
+}
diff --git a/public/style.css b/public/style.css
index a239f39..64763af 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2414,3 +2414,416 @@ h1, h2, h3, h4, h5, h6 {
gap: 8px !important;
}
}
+
+/* =============================================================
+ SHOPPING LISTS WORKSPACE & CONTROLS
+ ============================================================= */
+
+.shopping-badge {
+ background-color: var(--primary);
+ color: var(--text-white);
+ font-size: 0.72rem;
+ font-weight: 700;
+ padding: 2px 7px;
+ border-radius: var(--border-radius-full);
+ margin-left: 4px;
+ line-height: 1.2;
+}
+
+.shopping-badge-mobile {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ background-color: var(--primary);
+ color: var(--text-white);
+ font-size: 0.65rem;
+ font-weight: 700;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: var(--border-radius-full);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.btn-sm {
+ padding: 7px 13px;
+ font-size: 0.85rem;
+ border-radius: var(--border-radius-sm);
+}
+
+.btn-danger-text:hover {
+ border-color: var(--danger);
+ color: var(--danger);
+ background-color: rgba(239, 68, 68, 0.1);
+}
+
+.shopping-panel-container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.shopping-header-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 14px;
+ padding: 16px 20px;
+ background-color: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-md);
+}
+
+.shopping-list-selector-group,
+.shopping-share-group {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.shopping-select-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+}
+
+.shopping-select-label i,
+.shopping-select-label svg {
+ width: 16px;
+ height: 16px;
+ color: var(--primary);
+}
+
+.shopping-select {
+ background-color: var(--bg-app);
+ color: var(--text-main);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-sm);
+ padding: 8px 12px;
+ font-family: var(--font-body);
+ font-size: 0.92rem;
+ font-weight: 600;
+ outline: none;
+ cursor: pointer;
+}
+
+.shopping-select:focus {
+ border-color: var(--primary);
+}
+
+.shopping-select-sm {
+ padding: 6px 10px;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.shopping-stats-pill {
+ background: rgba(99, 102, 241, 0.12);
+ border: 1px dashed rgba(99, 102, 241, 0.3);
+ color: #6366f1;
+ padding: 5px 10px;
+ border-radius: var(--border-radius-full);
+ font-size: 0.8rem;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.shopping-stats-pill i,
+.shopping-stats-pill svg {
+ width: 14px;
+ height: 14px;
+}
+
+.shopping-controls-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 14px;
+}
+
+.shopping-add-form {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ min-width: 280px;
+ background-color: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-md);
+ padding: 6px 6px 6px 14px;
+}
+
+.shopping-add-form .input-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+}
+
+.shopping-add-form input {
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--text-main);
+ width: 100%;
+ font-size: 0.95rem;
+ padding: 8px 0;
+}
+
+.shopping-view-toggles {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.shopping-consolidate-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ padding: 6px 12px;
+ border-radius: var(--border-radius-sm);
+ background-color: var(--bg-card);
+ border: 1px solid var(--border-color);
+ user-select: none;
+}
+
+.shopping-consolidate-toggle input {
+ display: none;
+}
+
+.shopping-consolidate-toggle .toggle-track {
+ width: 34px;
+ height: 18px;
+ background-color: var(--border-color);
+ border-radius: var(--border-radius-full);
+ position: relative;
+ transition: background-color var(--transition-fast);
+}
+
+.shopping-consolidate-toggle .toggle-thumb {
+ width: 14px;
+ height: 14px;
+ background-color: var(--text-white);
+ border-radius: 50%;
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ transition: transform var(--transition-fast);
+}
+
+.shopping-consolidate-toggle input:checked + .toggle-track {
+ background-color: var(--primary);
+}
+
+.shopping-consolidate-toggle input:checked + .toggle-track .toggle-thumb {
+ transform: translateX(16px);
+}
+
+.shopping-consolidate-toggle .toggle-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.shopping-aisles-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ gap: 20px;
+ align-items: start;
+}
+
+.shopping-category-card {
+ background-color: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-md);
+ overflow: hidden;
+ box-shadow: var(--shadow-sm);
+}
+
+.shopping-category-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background-color: var(--bg-app);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.shopping-category-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-heading);
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-main);
+}
+
+.shopping-category-title i,
+.shopping-category-title svg {
+ width: 16px;
+ height: 16px;
+ color: var(--primary);
+}
+
+.shopping-category-count {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ background-color: var(--bg-card);
+ padding: 2px 8px;
+ border-radius: var(--border-radius-full);
+ border: 1px solid var(--border-color);
+}
+
+.shopping-items-list {
+ list-style: none;
+ margin: 0;
+ padding: 6px 0;
+}
+
+.shopping-item-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ cursor: pointer;
+ transition: background-color var(--transition-fast);
+}
+
+.shopping-item-row:last-child {
+ border-bottom: none;
+}
+
+.shopping-item-row:hover {
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+.shopping-item-main {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ flex: 1;
+}
+
+.shopping-item-row.checked .shopping-item-text {
+ text-decoration: line-through;
+ color: var(--text-muted);
+ opacity: 0.6;
+}
+
+.shopping-item-row.checked .checkbox-custom {
+ background-color: var(--accent);
+ border-color: var(--accent);
+ color: var(--text-white);
+}
+
+.shopping-item-text-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.shopping-item-text {
+ font-size: 0.95rem;
+ color: var(--text-main);
+ line-height: 1.35;
+}
+
+.shopping-item-sources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.shopping-source-tag {
+ font-size: 0.7rem;
+ color: var(--primary);
+ background: rgba(251, 146, 60, 0.12);
+ border: 1px solid rgba(251, 146, 60, 0.25);
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-weight: 500;
+}
+
+.shopping-item-delete {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ opacity: 0.5;
+ transition: all var(--transition-fast);
+}
+
+.shopping-item-delete:hover {
+ opacity: 1;
+ color: var(--danger);
+ background-color: rgba(239, 68, 68, 0.1);
+}
+
+.shopping-item-delete i,
+.shopping-item-delete svg {
+ width: 15px;
+ height: 15px;
+ display: block;
+}
+
+.detail-ingredients-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.detail-add-to-list-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 768px) {
+ .shopping-aisles-grid {
+ grid-template-columns: 1fr;
+ }
+ .shopping-header-bar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .shopping-list-selector-group,
+ .shopping-share-group {
+ width: 100%;
+ justify-content: space-between;
+ }
+ .shopping-select {
+ flex: 1;
+ }
+ .detail-ingredients-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .detail-add-to-list-bar {
+ width: 100%;
+ }
+ .detail-add-to-list-bar select {
+ flex: 1;
+ }
+}
diff --git a/server.js b/server.js
index b09eac6..24ecd9c 100644
--- a/server.js
+++ b/server.js
@@ -37,10 +37,21 @@ db.prepare(`
passwordHash TEXT NOT NULL,
isAdmin INTEGER NOT NULL DEFAULT 0,
isRecipeManager INTEGER NOT NULL DEFAULT 0,
+ defaultConsolidateLists INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL
)
`).run();
+// Ensure defaultConsolidateLists column exists on existing users table
+try {
+ const userCols = db.pragma('table_info(users)');
+ if (!userCols.some(c => c.name === 'defaultConsolidateLists')) {
+ db.prepare('ALTER TABLE users ADD COLUMN defaultConsolidateLists INTEGER NOT NULL DEFAULT 1').run();
+ }
+} catch (colErr) {
+ console.error('Failed to check/add defaultConsolidateLists column:', colErr);
+}
+
db.prepare(`
CREATE TABLE IF NOT EXISTS recipes (
id TEXT PRIMARY KEY,
@@ -80,6 +91,45 @@ db.prepare(`
)
`).run();
+db.prepare(`
+ CREATE TABLE IF NOT EXISTS shopping_lists (
+ id TEXT PRIMARY KEY,
+ userId TEXT NOT NULL,
+ createdBy TEXT NOT NULL,
+ name TEXT NOT NULL,
+ shareToken TEXT UNIQUE,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT,
+ FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
+ )
+`).run();
+
+db.prepare(`
+ CREATE TABLE IF NOT EXISTS shopping_list_items (
+ id TEXT PRIMARY KEY,
+ listId TEXT NOT NULL,
+ rawText TEXT NOT NULL,
+ itemName TEXT NOT NULL,
+ quantity REAL,
+ unit TEXT DEFAULT '',
+ category TEXT NOT NULL DEFAULT 'Other',
+ recipeId TEXT,
+ recipeTitle TEXT,
+ checked INTEGER NOT NULL DEFAULT 0,
+ createdAt TEXT NOT NULL,
+ FOREIGN KEY (listId) REFERENCES shopping_lists (id) ON DELETE CASCADE
+ )
+`).run();
+
+db.prepare(`
+ CREATE TABLE IF NOT EXISTS shopping_list_shares_visits (
+ shareToken TEXT NOT NULL,
+ visitorIp TEXT NOT NULL,
+ visitedAt TEXT NOT NULL,
+ PRIMARY KEY (shareToken, visitorIp)
+ )
+`).run();
+
// Initialize single settings record if empty
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
if (settingsCount === 0) {
@@ -398,7 +448,8 @@ app.post('/api/auth/register', requireAuth, (req, res) => {
id,
username: cleanUsername,
isAdmin: false,
- isRecipeManager: false
+ isRecipeManager: false,
+ defaultConsolidateLists: true
}
});
} catch (error) {
@@ -427,11 +478,13 @@ app.post('/api/auth/login', (req, res) => {
}
const token = crypto.randomBytes(32).toString('hex');
+ const defaultConsolidateLists = user.defaultConsolidateLists !== 0;
activeSessions.set(token, {
id: user.id,
username: user.username,
isAdmin: user.isAdmin === 1,
- isRecipeManager: user.isRecipeManager === 1
+ isRecipeManager: user.isRecipeManager === 1,
+ defaultConsolidateLists
});
res.json({
@@ -440,7 +493,8 @@ app.post('/api/auth/login', (req, res) => {
id: user.id,
username: user.username,
isAdmin: user.isAdmin === 1,
- isRecipeManager: user.isRecipeManager === 1
+ isRecipeManager: user.isRecipeManager === 1,
+ defaultConsolidateLists
}
});
} catch (error) {
@@ -1230,29 +1284,647 @@ async function fetchWithPuppeteer(url) {
}
}
-// API: Load System Settings
+// API: Load System Settings & User Preferences
app.get('/api/settings', requireAuth, (req, res) => {
try {
const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
- res.json({ geminiApiKey: settings ? settings.geminiApiKey : '' });
+ const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id);
+ const defaultConsolidateLists = userRow ? userRow.defaultConsolidateLists !== 0 : true;
+ res.json({
+ geminiApiKey: settings ? settings.geminiApiKey : '',
+ defaultConsolidateLists
+ });
} catch (e) {
console.error('Failed to load settings:', e);
res.status(500).json({ error: 'Failed to read settings' });
}
});
-// API: Save System Settings
+// API: Save System Settings & User Preferences
app.post('/api/settings', requireAuth, (req, res) => {
- const { geminiApiKey } = req.body;
+ const { geminiApiKey, defaultConsolidateLists } = req.body;
try {
- db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || '');
- res.json({ geminiApiKey: geminiApiKey || '' });
+ if (geminiApiKey !== undefined) {
+ db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || '');
+ }
+ if (typeof defaultConsolidateLists === 'boolean') {
+ db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id);
+ req.user.defaultConsolidateLists = defaultConsolidateLists;
+ }
+ const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
+ const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id);
+ res.json({
+ geminiApiKey: settings ? settings.geminiApiKey : '',
+ defaultConsolidateLists: userRow ? userRow.defaultConsolidateLists !== 0 : true
+ });
} catch (e) {
console.error('Failed to save settings:', e);
res.status(500).json({ error: 'Failed to save settings' });
}
});
+// API: Update User Preferences directly
+app.put('/api/user/preferences', requireAuth, (req, res) => {
+ const { defaultConsolidateLists } = req.body;
+ if (typeof defaultConsolidateLists !== 'boolean') {
+ return res.status(400).json({ error: 'defaultConsolidateLists must be a boolean.' });
+ }
+ try {
+ db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id);
+ req.user.defaultConsolidateLists = defaultConsolidateLists;
+ res.json({ success: true, defaultConsolidateLists });
+ } catch (e) {
+ console.error('Failed to update user preferences:', e);
+ res.status(500).json({ error: 'Failed to update preferences.' });
+ }
+});
+
+// -------------------------------------------------------------
+// SHOPPING LIST PARSER, CLASSIFIER & API ENDPOINTS
+// -------------------------------------------------------------
+
+const UNIT_ALIASES = {
+ tablespoon: 'tbsp', tablespoons: 'tbsp', tbsp: 'tbsp', tbs: 'tbsp', tb: 'tbsp',
+ teaspoon: 'tsp', teaspoons: 'tsp', tsp: 'tsp',
+ cup: 'cup', cups: 'cup', c: 'cup',
+ ounce: 'oz', ounces: 'oz', oz: 'oz',
+ pound: 'lb', pounds: 'lb', lb: 'lb', lbs: 'lb',
+ gram: 'g', grams: 'g', g: 'g',
+ kilogram: 'kg', kilograms: 'kg', kg: 'kg',
+ milliliter: 'ml', milliliters: 'ml', ml: 'ml',
+ liter: 'l', liters: 'l', l: 'l',
+ clove: 'clove', cloves: 'clove',
+ can: 'can', cans: 'can',
+ jar: 'jar', jars: 'jar',
+ bunch: 'bunch', bunches: 'bunch',
+ pinch: 'pinch', pinches: 'pinch',
+ dash: 'dash', dashes: 'dash',
+ slice: 'slice', slices: 'slice',
+ piece: 'piece', pieces: 'piece',
+ head: 'head', heads: 'head',
+ stalk: 'stalk', stalks: 'stalk',
+ sprig: 'sprig', sprigs: 'sprig',
+ package: 'pkg', packages: 'pkg', pkg: 'pkg', pkgs: 'pkg',
+ stick: 'stick', sticks: 'stick'
+};
+
+const AISLE_KEYWORDS = [
+ {
+ category: 'Produce',
+ keywords: [
+ 'onion', 'shallot', 'scallion', 'garlic', 'ginger', 'potato', 'sweet potato', 'carrot', 'celery',
+ 'bell pepper', 'jalapeno', 'chili pepper', 'poblano', 'habanero', 'serrano', 'tomato', 'cherry tomato',
+ 'lettuce', 'spinach', 'kale', 'arugula', 'cabbage', 'broccoli', 'cauliflower', 'zucchini', 'squash',
+ 'cucumber', 'mushroom', 'avocado', 'corn', 'asparagus', 'green bean', 'pea', 'eggplant', 'radish',
+ 'beet', 'turnip', 'leek', 'fennel', 'artichoke', 'cilantro', 'parsley', 'basil', 'mint', 'rosemary',
+ 'thyme', 'dill', 'chive', 'sage', 'apple', 'banana', 'lemon', 'lime', 'orange', 'grapefruit',
+ 'berry', 'strawberry', 'blueberry', 'raspberry', 'blackberry', 'grape', 'peach', 'pear', 'mango',
+ 'pineapple', 'watermelon', 'melon', 'pomegranate', 'fig', 'date'
+ ]
+ },
+ {
+ category: 'Meat & Seafood',
+ keywords: [
+ 'beef', 'ground beef', 'steak', 'chuck', 'sirloin', 'brisket', 'roast', 'pork', 'bacon', 'sausage',
+ 'ham', 'prosciutto', 'pancetta', 'chorizo', 'pork chop', 'tenderloin', 'chicken', 'turkey', 'duck',
+ 'lamb', 'veal', 'salmon', 'tuna', 'cod', 'halibut', 'tilapia', 'trout', 'mahimahi', 'sea bass',
+ 'shrimp', 'prawn', 'scallop', 'crab', 'lobster', 'clam', 'mussel', 'oyster', 'anchovy', 'fish'
+ ]
+ },
+ {
+ category: 'Dairy & Eggs',
+ keywords: [
+ 'milk', 'cream', 'heavy cream', 'half-and-half', 'half and half', 'butter', 'margarine', 'egg',
+ 'cheese', 'cheddar', 'mozzarella', 'parmesan', 'pecorino', 'feta', 'ricotta', 'gouda', 'gruyere',
+ 'swiss', 'provolone', 'brie', 'goat cheese', 'cream cheese', 'sour cream', 'yogurt', 'greek yogurt',
+ 'buttermilk', 'whipped cream', 'ghee', 'mascarpone'
+ ]
+ },
+ {
+ category: 'Canned & Jarred',
+ keywords: [
+ 'canned', 'crushed tomato', 'diced tomato', 'tomato paste', 'tomato sauce', 'marinara', 'salsa',
+ 'kidney bean', 'black bean', 'pinto bean', 'cannellini', 'chickpea', 'garbanzo', 'lentil',
+ 'broth', 'stock', 'chicken broth', 'beef broth', 'vegetable broth', 'coconut milk', 'olives',
+ 'capers', 'pickle', 'jam', 'jelly', 'peanut butter', 'almond butter', 'tahini', 'artichoke heart',
+ 'roasted red pepper', 'chipotle in adobo', 'beans'
+ ]
+ },
+ {
+ category: 'Bakery & Grains',
+ keywords: [
+ 'bread', 'loaf', 'baguette', 'ciabatta', 'sourdough', 'roll', 'bun', 'tortilla', 'pita', 'naan',
+ 'bagel', 'croissant', 'english muffin', 'pasta', 'spaghetti', 'penne', 'fettuccine', 'linguine',
+ 'macaroni', 'lasagna', 'orzo', 'noodle', 'ramen', 'udon', 'rice', 'jasmine rice', 'basmati',
+ 'arborio', 'brown rice', 'quinoa', 'couscous', 'barley', 'oats', 'oatmeal', 'breadcrumb', 'panko',
+ 'cracker', 'taco shell', 'tortilla chip'
+ ]
+ },
+ {
+ category: 'Pantry & Spices',
+ keywords: [
+ 'olive oil', 'vegetable oil', 'canola oil', 'sesame oil', 'coconut oil', 'avocado oil', 'oil',
+ 'vinegar', 'apple cider vinegar', 'balsamic', 'red wine vinegar', 'white wine vinegar', 'rice vinegar',
+ 'soy sauce', 'tamari', 'fish sauce', 'worcestershire', 'hot sauce', 'sriracha', 'mustard', 'dijon',
+ 'ketchup', 'mayonnaise', 'mayo', 'honey', 'maple syrup', 'molasses', 'agave', 'sugar', 'brown sugar',
+ 'powdered sugar', 'confectioners', 'flour', 'all-purpose flour', 'cornstarch', 'baking powder',
+ 'baking soda', 'yeast', 'vanilla', 'cocoa', 'chocolate', 'chip', 'nut', 'almond', 'walnut', 'pecan',
+ 'cashew', 'peanut', 'pine nut', 'pistachio', 'seed', 'sesame', 'chia', 'flax', 'sunflower',
+ 'salt', 'kosher salt', 'sea salt', 'black pepper', 'pepper', 'chili powder', 'cumin', 'paprika',
+ 'smoked paprika', 'cayenne', 'oregano', 'garlic powder', 'onion powder', 'cinnamon', 'nutmeg',
+ 'cloves', 'allspice', 'cardamom', 'turmeric', 'curry', 'coriander', 'bay leaf', 'bay leaves',
+ 'red pepper flakes', 'italian seasoning', 'thyme', 'rosemary', 'spice', 'seasoning', 'bouillon'
+ ]
+ },
+ {
+ category: 'Frozen',
+ keywords: [
+ 'frozen', 'ice cream', 'puff pastry', 'pie crust', 'frozen pea', 'frozen corn', 'frozen berry',
+ 'edamame', 'tater tot', 'fries'
+ ]
+ }
+];
+
+function classifyGroceryCategory(itemName, rawText) {
+ const haystack = `${itemName} ${rawText}`.toLowerCase();
+ // Check Canned & Jarred first if "can" or "canned" or "jar" is explicitly present
+ if (/\b(can|cans|canned|jar|jars|jarred)\b/.test(haystack)) {
+ for (const kw of AISLE_KEYWORDS.find(a => a.category === 'Canned & Jarred').keywords) {
+ if (haystack.includes(kw)) return 'Canned & Jarred';
+ }
+ }
+ for (const group of AISLE_KEYWORDS) {
+ for (const kw of group.keywords) {
+ const regex = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}s?\\b`, 'i');
+ if (regex.test(haystack)) {
+ return group.category;
+ }
+ }
+ }
+ return 'Other';
+}
+
+function parseIngredientLine(rawInput) {
+ const rawText = (rawInput || '').trim().replace(/\s+/g, ' ');
+ if (!rawText) {
+ return { rawText: '', itemName: '', quantity: null, unit: '', category: 'Other' };
+ }
+
+ // Normalize unicode vulgar fractions for parsing
+ const unicodeMap = {
+ 'Β½': ' 1/2', 'β
': ' 1/3', 'β
': ' 2/3', 'ΒΌ': ' 1/4', 'ΒΎ': ' 3/4',
+ 'β
': ' 1/5', 'β
': ' 2/5', 'β
': ' 3/5', 'β
': ' 4/5', 'β
': ' 1/6', 'β
': ' 5/6', 'β
': ' 1/8', 'β
': ' 3/8', 'β
': ' 5/8', 'β
': ' 7/8'
+ };
+ let working = rawText.replace(/[Β½β
β
ΒΌΒΎβ
β
β
β
β
β
β
β
β
β
]/g, m => unicodeMap[m] || m).trim().replace(/\s+/g, ' ');
+
+ // Remove parenthetical notes like "(about 1 1/2 cups)" or "(15-ounce)"
+ let withoutParens = working.replace(/\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim();
+
+ let quantity = null;
+ let rest = withoutParens;
+
+ // Match mixed fraction "1 1/2", simple fraction "3/4", range "1-2" or "1 to 2", or decimal/integer
+ const mixedMatch = rest.match(/^(\d+)\s+(\d+)\/(\d+)\b\s*(.*)/);
+ const fracMatch = !mixedMatch && rest.match(/^(\d+)\/(\d+)\b\s*(.*)/);
+ const rangeMatch = !mixedMatch && !fracMatch && rest.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)\b\s*(.*)/i);
+ const numMatch = !mixedMatch && !fracMatch && !rangeMatch && rest.match(/^(\d+(?:\.\d+)?)\b\s*(.*)/);
+
+ if (mixedMatch) {
+ quantity = parseInt(mixedMatch[1], 10) + (parseInt(mixedMatch[2], 10) / parseInt(mixedMatch[3], 10));
+ rest = mixedMatch[4];
+ } else if (fracMatch) {
+ quantity = parseInt(fracMatch[1], 10) / parseInt(fracMatch[2], 10);
+ rest = fracMatch[3];
+ } else if (rangeMatch) {
+ quantity = parseFloat(rangeMatch[2]);
+ rest = rangeMatch[3];
+ } else if (numMatch) {
+ quantity = parseFloat(numMatch[1]);
+ rest = numMatch[2];
+ }
+
+ if (quantity !== null && (!isFinite(quantity) || isNaN(quantity))) {
+ quantity = null;
+ }
+
+ // Extract unit if first token matches UNIT_ALIASES
+ let unit = '';
+ const unitTokenMatch = rest.match(/^([a-zA-Z]+)\.?\b\s*(.*)/);
+ if (unitTokenMatch) {
+ const candidate = unitTokenMatch[1].toLowerCase();
+ if (UNIT_ALIASES[candidate]) {
+ unit = UNIT_ALIASES[candidate];
+ rest = unitTokenMatch[2].replace(/^of\b\s*/i, '');
+ }
+ }
+
+ // Clean itemName: strip trailing prep instructions after comma (e.g. ", diced", ", finely chopped")
+ let itemName = rest.split(',')[0].trim();
+ // Remove leading descriptors like "medium ", "large ", "small ", "fresh ", "boneless skinless "
+ itemName = itemName
+ .replace(/^(?:of\s+|medium\s+|large\s+|small\s+|extra-large\s+|fresh\s+|dried\s+|finely\s+|chopped\s+|diced\s+|minced\s+|sliced\s+|grated\s+|shredded\s+)+/i, '')
+ .trim()
+ .toLowerCase();
+
+ // Basic singularization for better consolidation matching
+ if (itemName.length > 4) {
+ if (itemName.endsWith('tomatoes')) itemName = itemName.slice(0, -2);
+ else if (itemName.endsWith('potatoes')) itemName = itemName.slice(0, -2);
+ else if (itemName.endsWith('berries')) itemName = itemName.slice(0, -3) + 'y';
+ else if (itemName.endsWith('leaves')) itemName = itemName.slice(0, -3) + 'f';
+ else if (itemName.endsWith('s') && !itemName.endsWith('ss') && !itemName.endsWith('us') && !itemName.endsWith('beans') && !itemName.endsWith('oats') && !itemName.endsWith('flakes') && !itemName.endsWith('noodles') && !itemName.endsWith('chips')) {
+ itemName = itemName.slice(0, -1);
+ }
+ }
+
+ if (!itemName) {
+ itemName = rawText.toLowerCase();
+ }
+
+ const category = classifyGroceryCategory(itemName, rawText);
+ return {
+ rawText,
+ itemName,
+ quantity: quantity !== null ? Math.round(quantity * 100) / 100 : null,
+ unit,
+ category
+ };
+}
+
+function formatShoppingList(listRow) {
+ if (!listRow) return null;
+ const items = db.prepare('SELECT * FROM shopping_list_items WHERE listId = ? ORDER BY createdAt ASC').all(listRow.id).map(item => ({
+ ...item,
+ checked: item.checked === 1
+ }));
+
+ let visitorCount = 0;
+ if (listRow.shareToken) {
+ try {
+ const stats = db.prepare('SELECT COUNT(*) as count FROM shopping_list_shares_visits WHERE shareToken = ?').get(listRow.shareToken);
+ visitorCount = stats ? stats.count : 0;
+ } catch (e) {
+ console.error('Failed to query shopping list visitor count:', e);
+ }
+ }
+
+ return {
+ ...listRow,
+ items,
+ visitorCount
+ };
+}
+
+// API: Get all shopping lists for current user (creates a default list if none exist)
+app.get('/api/shopping-lists', requireAuth, (req, res) => {
+ try {
+ let rows = db.prepare('SELECT * FROM shopping_lists WHERE userId = ? ORDER BY createdAt ASC').all(req.user.id);
+ if (rows.length === 0) {
+ const defaultId = 'sl_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
+ const now = new Date().toISOString();
+ db.prepare(`
+ INSERT INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt)
+ VALUES (?, ?, ?, ?, NULL, ?, NULL)
+ `).run(defaultId, req.user.id, req.user.username, 'Weekly Groceries', now);
+ rows = db.prepare('SELECT * FROM shopping_lists WHERE userId = ? ORDER BY createdAt ASC').all(req.user.id);
+ }
+ res.json(rows.map(formatShoppingList));
+ } catch (error) {
+ console.error('Error fetching shopping lists:', error);
+ res.status(500).json({ error: 'Failed to retrieve shopping lists.' });
+ }
+});
+
+// API: Create a new shopping list
+app.post('/api/shopping-lists', requireAuth, (req, res) => {
+ const name = (req.body.name || '').trim();
+ if (!name) {
+ return res.status(400).json({ error: 'Shopping list name is required.' });
+ }
+ try {
+ const id = 'sl_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
+ const now = new Date().toISOString();
+ db.prepare(`
+ INSERT INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt)
+ VALUES (?, ?, ?, ?, NULL, ?, NULL)
+ `).run(id, req.user.id, req.user.username, name, now);
+ const created = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(id));
+ res.status(201).json(created);
+ } catch (error) {
+ console.error('Error creating shopping list:', error);
+ res.status(500).json({ error: 'Failed to create shopping list.' });
+ }
+});
+
+// API: Rename a shopping list
+app.put('/api/shopping-lists/:id', requireAuth, (req, res) => {
+ const name = (req.body.name || '').trim();
+ if (!name) {
+ return res.status(400).json({ error: 'Shopping list name is required.' });
+ }
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+ db.prepare('UPDATE shopping_lists SET name = ?, updatedAt = ? WHERE id = ?').run(name, new Date().toISOString(), req.params.id);
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id));
+ res.json(updated);
+ } catch (error) {
+ console.error('Error renaming shopping list:', error);
+ res.status(500).json({ error: 'Failed to rename shopping list.' });
+ }
+});
+
+// API: Delete a shopping list
+app.delete('/api/shopping-lists/:id', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+ const delTx = db.transaction(() => {
+ if (existing.shareToken) {
+ db.prepare('DELETE FROM shopping_list_shares_visits WHERE shareToken = ?').run(existing.shareToken);
+ }
+ db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(req.params.id);
+ });
+ delTx();
+ res.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting shopping list:', error);
+ res.status(500).json({ error: 'Failed to delete shopping list.' });
+ }
+});
+
+// API: Add one or more items to a shopping list
+app.post('/api/shopping-lists/:id/items', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+
+ const rawItems = Array.isArray(req.body.items)
+ ? req.body.items
+ : (req.body.text ? [req.body.text] : []);
+ const recipeId = req.body.recipeId || null;
+ const recipeTitle = req.body.recipeTitle || null;
+
+ if (rawItems.length === 0) {
+ return res.status(400).json({ error: 'No items provided.' });
+ }
+
+ const insertStmt = db.prepare(`
+ INSERT INTO shopping_list_items (
+ id, listId, rawText, itemName, quantity, unit, category, recipeId, recipeTitle, checked, createdAt
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
+ `);
+
+ const now = new Date().toISOString();
+ const insertMany = db.transaction((lines) => {
+ lines.forEach((line, idx) => {
+ if (!line || typeof line !== 'string' || !line.trim()) return;
+ const parsed = parseIngredientLine(line);
+ const itemId = 'sli_' + Date.now().toString(36) + '_' + idx + '_' + Math.random().toString(36).substr(2, 4);
+ insertStmt.run(
+ itemId,
+ existing.id,
+ parsed.rawText,
+ parsed.itemName,
+ parsed.quantity,
+ parsed.unit,
+ parsed.category,
+ recipeId,
+ recipeTitle,
+ now
+ );
+ });
+ db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(now, existing.id);
+ });
+
+ insertMany(rawItems);
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
+ res.status(201).json(updated);
+ } catch (error) {
+ console.error('Error adding shopping list items:', error);
+ res.status(500).json({ error: 'Failed to add items to shopping list.' });
+ }
+});
+
+// API: Toggle checked state for one or multiple shopping list items
+app.put('/api/shopping-lists/:id/items/check', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+
+ const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
+ const checked = req.body.checked ? 1 : 0;
+ if (itemIds.length === 0) {
+ return res.status(400).json({ error: 'itemIds array is required.' });
+ }
+
+ const updateStmt = db.prepare('UPDATE shopping_list_items SET checked = ? WHERE id = ? AND listId = ?');
+ const tx = db.transaction((ids) => {
+ for (const itemId of ids) {
+ updateStmt.run(checked, itemId, existing.id);
+ }
+ db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
+ });
+ tx(itemIds);
+
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
+ res.json(updated);
+ } catch (error) {
+ console.error('Error toggling shopping list items:', error);
+ res.status(500).json({ error: 'Failed to update item state.' });
+ }
+});
+
+// API: Delete one or multiple items from a shopping list
+app.post('/api/shopping-lists/:id/items/delete', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+
+ const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
+ const delStmt = db.prepare('DELETE FROM shopping_list_items WHERE id = ? AND listId = ?');
+ const tx = db.transaction((ids) => {
+ for (const itemId of ids) {
+ delStmt.run(itemId, existing.id);
+ }
+ db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
+ });
+ tx(itemIds);
+
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
+ res.json(updated);
+ } catch (error) {
+ console.error('Error deleting shopping list items:', error);
+ res.status(500).json({ error: 'Failed to delete items.' });
+ }
+});
+
+// API: Clear checked or all items from a shopping list
+app.post('/api/shopping-lists/:id/clear', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+
+ const onlyChecked = req.body.onlyChecked !== false;
+ if (onlyChecked) {
+ db.prepare('DELETE FROM shopping_list_items WHERE listId = ? AND checked = 1').run(existing.id);
+ } else {
+ db.prepare('DELETE FROM shopping_list_items WHERE listId = ?').run(existing.id);
+ }
+ db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
+
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
+ res.json(updated);
+ } catch (error) {
+ console.error('Error clearing shopping list:', error);
+ res.status(500).json({ error: 'Failed to clear shopping list.' });
+ }
+});
+
+// API: Generate or retrieve public share link token for a shopping list
+app.post('/api/shopping-lists/:id/share', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+
+ let shareToken = existing.shareToken;
+ if (!shareToken) {
+ shareToken = crypto.randomBytes(16).toString('hex');
+ db.prepare('UPDATE shopping_lists SET shareToken = ?, updatedAt = ? WHERE id = ?').run(shareToken, new Date().toISOString(), existing.id);
+ }
+
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
+ res.json(updated);
+ } catch (error) {
+ console.error('Error sharing shopping list:', error);
+ res.status(500).json({ error: 'Failed to generate shopping list share link.' });
+ }
+});
+
+// API: Revoke public share link for a shopping list
+app.post('/api/shopping-lists/:id/revoke-share', requireAuth, (req, res) => {
+ try {
+ const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
+ if (existing.userId !== req.user.id && !req.user.isAdmin) {
+ return res.status(403).json({ error: 'Permission denied.' });
+ }
+
+ const oldToken = existing.shareToken;
+ const revokeTx = db.transaction(() => {
+ db.prepare('UPDATE shopping_lists SET shareToken = NULL, updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
+ if (oldToken) {
+ db.prepare('DELETE FROM shopping_list_shares_visits WHERE shareToken = ?').run(oldToken);
+ }
+ });
+ revokeTx();
+
+ const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
+ res.json(updated);
+ } catch (error) {
+ console.error('Error revoking shopping list share link:', error);
+ res.status(500).json({ error: 'Failed to revoke shopping list share link.' });
+ }
+});
+
+// Public API: View shared shopping list by shareToken
+app.get('/api/share/shopping-lists/:shareToken', publicShareLimiter, (req, res) => {
+ const shareToken = (req.params.shareToken || '').trim();
+ if (!shareToken) {
+ return res.status(400).json({ error: 'Share token is required.' });
+ }
+ try {
+ const listRow = db.prepare('SELECT * FROM shopping_lists WHERE shareToken = ?').get(shareToken);
+ if (!listRow) {
+ return res.status(404).json({ error: 'Shopping list not found or sharing has been disabled.' });
+ }
+
+ try {
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
+ db.prepare(`
+ INSERT OR IGNORE INTO shopping_list_shares_visits (shareToken, visitorIp, visitedAt)
+ VALUES (?, ?, ?)
+ `).run(shareToken, ip, new Date().toISOString());
+ } catch (visitErr) {
+ console.error('Failed to log shared shopping list visitor IP:', visitErr);
+ }
+
+ const formatted = formatShoppingList(listRow);
+ res.json({
+ name: formatted.name,
+ createdBy: formatted.createdBy,
+ createdAt: formatted.createdAt,
+ updatedAt: formatted.updatedAt,
+ items: formatted.items
+ });
+ } catch (error) {
+ console.error('Error serving shared shopping list:', error);
+ res.status(500).json({ error: 'Internal server error while retrieving shared shopping list.' });
+ }
+});
+
+// Public API: Check/uncheck items on a shared shopping list
+app.put('/api/share/shopping-lists/:shareToken/items/check', publicShareLimiter, (req, res) => {
+ const shareToken = (req.params.shareToken || '').trim();
+ if (!shareToken) {
+ return res.status(400).json({ error: 'Share token is required.' });
+ }
+ try {
+ const listRow = db.prepare('SELECT * FROM shopping_lists WHERE shareToken = ?').get(shareToken);
+ if (!listRow) {
+ return res.status(404).json({ error: 'Shopping list not found or sharing has been disabled.' });
+ }
+
+ const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
+ const checked = req.body.checked ? 1 : 0;
+ if (itemIds.length === 0) {
+ return res.status(400).json({ error: 'itemIds array is required.' });
+ }
+
+ const updateStmt = db.prepare('UPDATE shopping_list_items SET checked = ? WHERE id = ? AND listId = ?');
+ const tx = db.transaction((ids) => {
+ for (const itemId of ids) {
+ updateStmt.run(checked, itemId, listRow.id);
+ }
+ db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), listRow.id);
+ });
+ tx(itemIds);
+
+ const formatted = formatShoppingList(listRow);
+ res.json({
+ name: formatted.name,
+ createdBy: formatted.createdBy,
+ createdAt: formatted.createdAt,
+ updatedAt: formatted.updatedAt,
+ items: formatted.items
+ });
+ } catch (error) {
+ console.error('Error updating shared shopping list items:', error);
+ res.status(500).json({ error: 'Failed to update shared shopping list.' });
+ }
+});
+
// API: Sanity-checked, unauthenticated public recipe sharing with rate limiting
app.get('/api/share/recipes/:shareToken', publicShareLimiter, (req, res) => {
const shareToken = req.params.shareToken;