tags
+ const matches = [...html.matchAll(/href="([^"]+)"/g)];
+ const imageExts = /\.(png|jpg|jpeg|gif|svg|webp)$/i;
+
+ this._imageListCache = matches
+ .map(m => {
+ // Normalise: strip any leading path so we keep just the filename
+ // e.g. "/local/images/shopping_list_manager/milk.png" → "milk.png"
+ let h = m[1];
+ const lastSlash = h.lastIndexOf('/');
+ if (lastSlash !== -1) h = h.substring(lastSlash + 1);
+ return h;
+ })
+ .filter(h => imageExts.test(h));
+
+ console.log('[ShoppingList] Parsed image list (' + this._imageListCache.length + ' files):', this._imageListCache);
+ } catch (e) {
+ console.warn('[ShoppingList] Could not fetch image list:', e, '— falling back to direct-guess mode');
+ this._imageListCache = [];
+ }
+ return this._imageListCache;
+ }
+
+ /**
+ * Score a single filename against the product name.
+ * Returns 0-100. Higher = better match.
+ *
+ * Strategy (order of priority):
+ * 100 – exact stem match "Milk" vs "milk.png"
+ * 80 – stem starts-with product "Milk" vs "milk_whole.png"
+ * 70 – product starts-with stem "Whole milk" vs "milk.png" (rare but possible)
+ * 50 – product contains stem "Semi skimmed milk" vs "milk.png"
+ * 40 – stem contains product "milk_chocolate" vs "milk" — product is a substring
+ * 20 – any shared token (word) "organic_milk" vs "milk_2litre"
+ * 0 – no overlap
+ */
+ _scoreImage(productName, filename) {
+ // Strip extension and path, normalise
+ const stem = filename.replace(/\.[^.]+$/, '').toLowerCase().replace(/[-]/g, '_');
+ const prod = productName.toLowerCase().trim().replace(/[\s-]+/g, '_');
+
+ if (stem === prod) return 100;
+ if (stem.startsWith(prod)) return 80;
+ if (prod.startsWith(stem)) return 70;
+ if (prod.includes(stem)) return 50;
+ if (stem.includes(prod)) return 40;
+
+ // Token-level overlap
+ const stemTokens = new Set(stem.split('_').filter(Boolean));
+ const prodTokens = new Set(prod.split('_').filter(Boolean));
+ for (const t of prodTokens) {
+ if (stemTokens.has(t)) return 20;
+ }
+ return 0;
+ }
+
+ /**
+ * Find the best-matching local image for a product name.
+ * Fetches the directory listing once, then scores every file.
+ * Results are cached per product name for the session.
+ */
+ /**
+ * Try to load a single URL; resolves true/false within 2 s.
+ */
+ _testImageUrl(url) {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => resolve(true);
+ img.onerror = () => resolve(false);
+ img.src = url;
+ setTimeout(() => resolve(false), 2000);
+ });
+ }
+
+ /**
+ * Direct-guess fallback: try the product name (and common
+ * singular/plural tweaks) with every extension directly.
+ * Returns the first URL that loads, or null.
+ */
+ async _guessImageDirect(productName) {
+ const dir = '/local/images/shopping_list_manager/';
+ const base = productName.toLowerCase().trim().replace(/[\s-]+/g, '_');
+ const exts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
+
+ // Build candidate stems: exact, +s, -s, -es, -ies→y
+ const stems = [base];
+ if (!base.endsWith('s')) stems.push(base + 's'); // carrot → carrots
+ if (base.endsWith('s')) stems.push(base.slice(0,-1)); // carrots → carrot
+ if (base.endsWith('es')) stems.push(base.slice(0,-2)); // tomatoes → tomat (covered by -s too)
+ if (base.endsWith('ies')) stems.push(base.slice(0,-3) + 'y'); // berries → berry
+
+ for (const stem of stems) {
+ for (const ext of exts) {
+ const url = `${dir}${stem}.${ext}`;
+ if (await this._testImageUrl(url)) {
+ console.log('[ShoppingList] Direct-guess hit:', url);
+ return url;
+ }
+ }
+ }
+ return null;
+ }
+
+ async _findLocalImage(productName) {
+ const cacheKey = productName.toLowerCase().trim();
+ if (this._localImageCache[cacheKey] !== undefined) {
+ return this._localImageCache[cacheKey];
+ }
+
+ const dir = '/local/images/shopping_list_manager/';
+ const files = await this._fetchImageList();
+
+ let bestScore = 0;
+ let bestFile = null;
+
+ // --- Pass 1: score against the directory listing (if we got one) ---
+ if (files.length > 0) {
+ for (const file of files) {
+ const score = this._scoreImage(productName, file);
+ if (score > bestScore) {
+ bestScore = score;
+ bestFile = file;
+ if (score === 100) break;
+ }
+ }
+ console.log('[ShoppingList] Best listing match for "' + productName + '":', bestFile, 'score=' + bestScore);
+ }
+
+ // Accept listing match if score >= 20
+ if (bestScore >= 20 && bestFile) {
+ const result = `${dir}${bestFile}`;
+ this._localImageCache[cacheKey] = result;
+ return result;
+ }
+
+ // --- Pass 2: direct HTTP guess (works even when listing is empty/404) ---
+ console.log('[ShoppingList] Listing empty or no match — trying direct guess for "' + productName + '"');
+ const guessed = await this._guessImageDirect(productName);
+ this._localImageCache[cacheKey] = guessed;
+ return guessed;
+ }
+
+ /**
+ * Search for appropriate emoji based on product name
+ */
+ _searchEmoji(productName) {
+ const name = productName.toLowerCase().trim();
+
+ // Common food and grocery emojis mapped to keywords
+ const emojiMap = {
+ // Fruits
+ 'apple': '🍎', 'banana': '🍌', 'orange': '🍊', 'lemon': '🍋', 'lime': '🍋',
+ 'watermelon': '🍉', 'grapes': '🍇', 'grape': '🍇', 'strawberry': '🍓', 'strawberries': '🍓',
+ 'melon': '🍈', 'cherry': '🍒', 'cherries': '🍒', 'peach': '🍑', 'pear': '🍐',
+ 'pineapple': '🍍', 'kiwi': '🥝', 'avocado': '🥑', 'mango': '🥭', 'coconut': '🥥',
+
+ // Vegetables
+ 'tomato': '🍅', 'tomatoes': '🍅', 'cucumber': '🥒', 'carrot': '🥕', 'carrots': '🥕',
+ 'potato': '🥔', 'potatoes': '🥔', 'corn': '🌽', 'pepper': '🫑', 'peppers': '🫑',
+ 'broccoli': '🥦', 'lettuce': '🥬', 'salad': '🥗', 'onion': '🧅', 'onions': '🧅',
+ 'garlic': '🧄', 'mushroom': '🍄', 'mushrooms': '🍄', 'eggplant': '🍆', 'aubergine': '🍆',
+
+ // Meat & Protein
+ 'meat': '🥩', 'beef': '🥩', 'steak': '🥩', 'chicken': '🍗', 'poultry': '🍗',
+ 'bacon': '🥓', 'pork': '🥓', 'ham': '🍖', 'sausage': '🌭', 'egg': '🥚', 'eggs': '🥚',
+ 'fish': '🐟', 'salmon': '🐟', 'tuna': '🐟', 'shrimp': '🦐', 'prawn': '🦐',
+
+ // Dairy
+ 'milk': '🥛', 'cheese': '🧀', 'butter': '🧈', 'yogurt': '🥛', 'yoghurt': '🥛',
+ 'cream': '🥛', 'ice cream': '🍦', 'icecream': '🍦',
+
+ // Bakery
+ 'bread': '🍞', 'baguette': '🥖', 'croissant': '🥐', 'bagel': '🥯', 'pretzel': '🥨',
+ 'roll': '🥖', 'rolls': '🥖', 'bun': '🍔', 'buns': '🍔', 'cake': '🎂', 'cookie': '🍪',
+ 'cookies': '🍪', 'donut': '🍩', 'doughnut': '🍩', 'pie': '🥧', 'muffin': '🧁',
+
+ // Pantry
+ 'pasta': '🍝', 'spaghetti': '🍝', 'rice': '🍚', 'noodles': '🍜', 'ramen': '🍜',
+ 'pizza': '🍕', 'burger': '🍔', 'sandwich': '🥪', 'taco': '🌮', 'burrito': '🌯',
+ 'soup': '🍲', 'stew': '🍲', 'can': '🥫', 'canned': '🥫', 'jar': '🫙',
+
+ // Snacks & Sweets
+ 'chocolate': '🍫', 'candy': '🍬', 'lollipop': '🍭', 'chips': '🍟', 'crisps': '🍟',
+ 'popcorn': '🍿', 'nuts': '🥜', 'peanut': '🥜', 'honey': '🍯',
+
+ // Drinks
+ 'coffee': '☕', 'tea': '🍵', 'beer': '🍺', 'wine': '🍷', 'champagne': '🍾',
+ 'juice': '🧃', 'soda': '🥤', 'water': '💧', 'bottle': '🍾',
+
+ // Condiments
+ 'salt': '🧂', 'pepper': '🫑', 'oil': '🫒', 'vinegar': '🫗', 'sauce': '🥫',
+ 'ketchup': '🍅', 'mustard': '🌭', 'mayo': '🥚', 'mayonnaise': '🥚',
+
+ // Household
+ 'toilet': '🧻', 'paper': '🧻', 'soap': '🧼', 'shampoo': '🧴', 'detergent': '🧴',
+ 'cleaning': '🧹', 'bleach': '🧴', 'sponge': '🧽', 'trash': '🗑️', 'bin': '🗑️',
+
+ // Baby
+ 'baby': '👶', 'diaper': '🍼', 'nappy': '🍼', 'formula': '🍼', 'wipes': '🧻',
+
+ // Pet
+ 'dog': '🐕', 'cat': '🐈', 'pet': '🐾', 'food': '🐾'
+ };
+
+ // Try exact match first
+ if (emojiMap[name]) {
+ return emojiMap[name];
+ }
+
+ // Try partial matches (check if any keyword is in the product name)
+ for (const [keyword, emoji] of Object.entries(emojiMap)) {
+ if (name.includes(keyword)) {
+ return emoji;
+ }
+ }
+
+ // Default fallback
+ return '🛒';
+ }
+
+ /**
+ * Add a new product - opens the unified dialog in "new" mode
+ */
+ async _addNewProduct(name) {
+ const key = this._generateKey(name);
+
+ if (this._products[key]) {
+ alert('Product already exists');
+ return;
+ }
+
+ // Auto-search: try local image first, then fall back to emoji
+ const localImage = await this._findLocalImage(name);
+ const autoImage = localImage || this._searchEmoji(name);
+
+ // Open the same dialog used for editing, but in "new" mode
+ const result = await this._showEditDialog(null, { name, image: autoImage });
+
+ // Always clear search after dialog closes (cancel or save)
+ this._searchQuery = '';
+ const _sb = this.shadowRoot.querySelector('.search-bar');
+ if (_sb) _sb.value = '';
+ const _sc = this.shadowRoot.querySelector('.search-clear');
+ if (_sc) _sc.style.display = 'none';
+ const _addBtn = this.shadowRoot.querySelector('.settings-btn');
+ if (_addBtn) { _addBtn.textContent = '⚙️'; _addBtn.title = 'Settings'; }
+ this._updateContent();
+
+ if (!result || result.action !== 'save') return;
+
+ try {
+ await this._hass.connection.sendMessagePromise({
+ type: 'shopping_list_manager/add_product',
+ key: key,
+ name: result.name,
+ category: result.category,
+ unit: 'pcs',
+ image: result.image || ''
+ });
+
+ await this._hass.connection.sendMessagePromise({
+ type: 'shopping_list_manager/set_qty',
+ key: key,
+ qty: 1
+ });
+
+ await this._loadData();
+ this._hapticFeedback();
+ } catch (error) {
+ console.error('Failed to add product:', error);
+ alert('Failed to add product: ' + error.message);
+ }
+ }
+
+ /**
+ * Show product edit dialog
+ */
+ async _showEditDialog(productKey, newProductDefaults = null) {
+ // "new" mode when productKey is null and defaults are passed
+ const isNew = !productKey && newProductDefaults;
+ const product = isNew
+ ? { name: newProductDefaults.name || '', image: newProductDefaults.image || '', category: 'other' }
+ : this._products[productKey];
+ if (!product) return;
+
+ return new Promise((resolve) => {
+ const modal = document.createElement('div');
+ modal.style.cssText = `
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100000;
+ isolation: isolate;
+ `;
+
+ const dialog = document.createElement('div');
+ dialog.style.cssText = `
+ background: var(--card-background-color);
+ border-radius: 12px;
+ padding: 24px;
+ max-width: 400px;
+ width: 90%;
+ `;
+
+ const currentCategory = CATEGORY_MAP[product.category] || CATEGORY_MAP['other'];
+
+ dialog.innerHTML = `
+
+ ${isNew ? 'New Product' : 'Edit Product'}
+
+
+
+
+
+
+
+
+
+
+
+ Enter URL (http://...) or emoji. Leave empty for 🛒
+
+
+
+
+
+
+
+
+
+ ${isNew ? '' : ``}
+
+
+
+
+
+ `;
+
+ modal.appendChild(dialog);
+ document.body.appendChild(modal);
+
+ // Save button
+ dialog.querySelector('.save-btn').addEventListener('click', () => {
+ const newName = dialog.querySelector('.edit-name').value.trim();
+ const newImage = dialog.querySelector('.edit-image').value.trim();
+ const newCategory = dialog.querySelector('.edit-category').value;
+ if (!newName) {
+ alert('Product name cannot be empty');
+ return;
+ }
+ document.body.removeChild(modal);
+ resolve({ action: 'save', name: newName, category: newCategory, image: newImage });
+ });
+
+ // Delete button (only present in edit mode)
+ const deleteBtn = dialog.querySelector('.delete-btn');
+ if (deleteBtn) {
+ deleteBtn.addEventListener('click', () => {
+ if (confirm(`Delete "${product.name}"?`)) {
+ document.body.removeChild(modal);
+ resolve({ action: 'delete' });
+ }
+ });
+ }
+
+ // Cancel button
+ dialog.querySelector('.cancel-btn').addEventListener('click', () => {
+ document.body.removeChild(modal);
+ resolve(null);
+ });
+
+ // Backdrop click
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ document.body.removeChild(modal);
+ resolve(null);
+ }
+ });
+ });
+ }
+
+ /**
+ * Edit a product
+ */
+ async _editProduct(productKey) {
+ const result = await this._showEditDialog(productKey);
+ if (!result) return;
+
+ try {
+ if (result.action === 'delete') {
+ // Delete product
+ await this._hass.connection.sendMessagePromise({
+ type: 'shopping_list_manager/delete_product',
+ key: productKey
+ });
+
+ // Reload data
+ await this._loadData();
+ this._hapticFeedback();
+ } else if (result.action === 'save') {
+ // Update product
+ await this._hass.connection.sendMessagePromise({
+ type: 'shopping_list_manager/add_product',
+ key: productKey,
+ name: result.name,
+ category: result.category,
+ unit: 'pcs',
+ image: result.image || ''
+ });
+
+ // Reload data
+ await this._loadData();
+ this._hapticFeedback();
+ }
+ } catch (error) {
+ console.error('Failed to edit product:', error);
+ alert('Failed to edit product: ' + error.message);
+ }
+ }
+
+ /**
+ * Set quantity for a product
+ */
+ async _setQuantity(productKey, qty) {
+ const currentQty = this._activeList[productKey]?.qty || 0;
+
+ // Optimistic update
+ if (qty === 0) {
+ delete this._activeList[productKey];
+ } else {
+ this._activeList[productKey] = { qty };
+ }
+ this._render();
+ this._hapticFeedback();
+
+ try {
+ await this._hass.connection.sendMessagePromise({
+ type: 'shopping_list_manager/set_qty',
+ key: productKey,
+ qty: qty
+ });
+ } catch (error) {
+ console.error('Failed to set quantity:', error);
+ // Revert on error
+ if (currentQty === 0) {
+ delete this._activeList[productKey];
+ } else {
+ this._activeList[productKey] = { qty: currentQty };
+ }
+ this._render();
+ alert('Failed to update quantity');
+ }
+ }
+
+ /**
+ * Toggle product on/off list
+ */
+ async _toggleProduct(productKey) {
+ const currentQty = this._activeList[productKey]?.qty || 0;
+ const newQty = currentQty > 0 ? 0 : 1;
+ await this._setQuantity(productKey, newQty);
+ }
+
+ /**
+ * Increment quantity
+ */
+ async _incrementProduct(productKey) {
+ const currentQty = this._activeList[productKey]?.qty || 0;
+ await this._setQuantity(productKey, currentQty + 1);
+ }
+
+ /**
+ * Decrement quantity
+ */
+ async _decrementProduct(productKey) {
+ const currentQty = this._activeList[productKey]?.qty || 0;
+ if (currentQty > 0) {
+ await this._setQuantity(productKey, currentQty - 1);
+ }
+ }
+
+ /**
+ * Generate product key from name
+ */
+ _generateKey(name) {
+ return name.toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+ }
+
+ /**
+ * Haptic feedback based on settings
+ */
+ _hapticFeedback(intensity = 'medium') {
+ if (this._settings.haptics === 'off') return;
+
+ if ('vibrate' in navigator) {
+ const patterns = {
+ low: 5,
+ medium: 10,
+ high: 20
+ };
+
+ const duration = patterns[this._settings.haptics] || patterns.medium;
+ navigator.vibrate(duration);
+ }
+ }
+
+ /**
+ * Show settings modal
+ */
+ _showSettings() {
+ return new Promise((resolve) => {
+ const modal = document.createElement('div');
+ modal.style.cssText = `
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ z-index: 100000;
+ isolation: isolate;
+ `;
+
+ const dialog = document.createElement('div');
+ dialog.style.cssText = `
+ background: var(--card-background-color);
+ border-radius: 16px 16px 0 0;
+ padding: 12px 24px 32px 24px;
+ width: 100%;
+ max-width: 480px;
+ max-height: 70vh;
+ overflow-y: auto;
+ position: relative;
+ `;
+
+ dialog.innerHTML = `
+
+
+
+
Settings
+
+
+
+
+
+
+
+ ${['off', 'low', 'medium', 'high'].map(level => `
+
+ `).join('')}
+
+
+
+
+
+
+
+ ${['auto', '2', '3', '4', '5'].map(count => `
+
+ `).join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hide Completed
+
Hide products with quantity 0
+
+
+
+
+
+
+
+
Compact Headers
+
Show category names only (no emoji)
+
+
+
+
+
+ `;
+
+ modal.appendChild(dialog);
+ document.body.appendChild(modal);
+
+ // Haptics buttons
+ dialog.querySelectorAll('.haptic-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this._settings.haptics = btn.dataset.level;
+ this._saveSettings();
+
+ // Update visual selection
+ dialog.querySelectorAll('.haptic-btn').forEach(b => {
+ b.style.borderColor = 'var(--divider-color)';
+ b.style.background = 'var(--card-background-color)';
+ });
+ btn.style.borderColor = 'var(--primary-color)';
+ btn.style.background = 'var(--primary-color)';
+
+ // Test vibration
+ this._hapticFeedback();
+ });
+ });
+
+ // Products per row buttons
+ dialog.querySelectorAll('.perrow-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this._settings.productsPerRow = btn.dataset.count;
+ this._saveSettings();
+ this._updateContent(); // apply grid change immediately
+
+ // Update visual selection
+ dialog.querySelectorAll('.perrow-btn').forEach(b => {
+ b.style.borderColor = 'var(--divider-color)';
+ b.style.background = 'var(--card-background-color)';
+ });
+ btn.style.borderColor = 'var(--primary-color)';
+ btn.style.background = 'var(--primary-color)';
+
+ this._hapticFeedback();
+ });
+ });
+
+ // Hide completed toggle
+ const hideCompletedToggle = dialog.querySelector('.hide-completed-toggle');
+ if (hideCompletedToggle) {
+ hideCompletedToggle.addEventListener('change', (e) => {
+ this._settings.hideCompleted = e.target.checked;
+ this._saveSettings();
+
+ // Update toggle visual state
+ const toggle = e.target.parentElement;
+ const slider = toggle.querySelectorAll('span')[0];
+ const knob = toggle.querySelectorAll('span')[1];
+ slider.style.backgroundColor = e.target.checked ? 'var(--primary-color)' : 'var(--divider-color)';
+ knob.style.left = e.target.checked ? '23px' : '3px';
+
+ this._updateContent();
+ this._hapticFeedback();
+ });
+ }
+
+ // Compact headers toggle
+ const compactHeadersToggle = dialog.querySelector('.compact-headers-toggle');
+ if (compactHeadersToggle) {
+ compactHeadersToggle.addEventListener('change', (e) => {
+ this._settings.compactHeaders = e.target.checked;
+ this._saveSettings();
+
+ // Update toggle visual state
+ const toggle = e.target.parentElement;
+ const slider = toggle.querySelectorAll('span')[0];
+ const knob = toggle.querySelectorAll('span')[1];
+ slider.style.backgroundColor = e.target.checked ? 'var(--primary-color)' : 'var(--divider-color)';
+ knob.style.left = e.target.checked ? '23px' : '3px';
+
+ this._updateContent();
+ this._hapticFeedback();
+ });
+ }
+
+ // Layout buttons
+ dialog.querySelectorAll('.layout-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this._settings.layout = btn.dataset.layout;
+ this._saveSettings();
+ this._updateContent(); // switch grid↔list immediately
+
+ // Update visual selection
+ dialog.querySelectorAll('.layout-btn').forEach(b => {
+ b.style.borderColor = 'var(--divider-color)';
+ b.style.background = 'var(--card-background-color)';
+ });
+ btn.style.borderColor = 'var(--primary-color)';
+ btn.style.background = 'var(--primary-color)';
+
+ this._hapticFeedback();
+ });
+ });
+
+ // Close button (Done)
+ dialog.querySelector('.close-btn').addEventListener('click', () => {
+ document.body.removeChild(modal);
+ this._updateContent();
+ resolve();
+ });
+
+ // ✕ top-right close button
+ dialog.querySelector('.close-x-btn').addEventListener('click', () => {
+ document.body.removeChild(modal);
+ this._updateContent();
+ resolve();
+ });
+
+ // Backdrop click
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ document.body.removeChild(modal);
+ this._updateContent();
+ resolve();
+ }
+ });
+ });
+ }
+
+ /**
+ * Fuzzy search - matches even with typos or partial matches
+ */
+ _fuzzyMatch(text, query) {
+ text = text.toLowerCase();
+ query = query.toLowerCase();
+
+ // Exact match or substring
+ if (text.includes(query)) {
+ return true;
+ }
+
+ // Fuzzy match - allows for missing characters
+ let queryIndex = 0;
+ for (let i = 0; i < text.length && queryIndex < query.length; i++) {
+ if (text[i] === query[queryIndex]) {
+ queryIndex++;
+ }
+ }
+ return queryIndex === query.length;
+ }
+
+ /**
+ * Filter products by search and split into active/inactive
+ */
+ _getFilteredProducts() {
+ let products = Object.values(this._products);
+
+ // Filter by search query with fuzzy matching
+ if (this._searchQuery) {
+ const query = this._searchQuery.toLowerCase();
+ products = products.filter(product =>
+ this._fuzzyMatch(product.name, query)
+ );
+ }
+
+ // Filter by selected category
+ if (this._selectedCategory) {
+ products = products.filter(product =>
+ product.category === this._selectedCategory
+ );
+ }
+
+ // Sort products
+ if (this._sortBy === 'category') {
+ // Sort by category order, then by name
+ products.sort((a, b) => {
+ const catA = CATEGORY_MAP[a.category] || CATEGORY_MAP['other'];
+ const catB = CATEGORY_MAP[b.category] || CATEGORY_MAP['other'];
+
+ if (catA.order !== catB.order) {
+ return catA.order - catB.order;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ } else {
+ // Alphabetical sort
+ products.sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ return products;
+ }
+
+ /**
+ * Get active products (in shopping list)
+ */
+ _getActiveProducts() {
+ const filtered = this._getFilteredProducts();
+ return filtered.filter(product => {
+ const qty = this._activeList[product.key]?.qty || 0;
+ return qty > 0;
+ });
+ }
+
+ /**
+ * Get inactive products (not in shopping list)
+ */
+ _getInactiveProducts() {
+ const filtered = this._getFilteredProducts();
+ return filtered.filter(product => {
+ const qty = this._activeList[product.key]?.qty || 0;
+ return qty === 0;
+ });
+ }
+
+ /**
+ * Check if should show "Add New" button
+ */
+ _shouldShowAddNew() {
+ if (!this._searchQuery || this._searchQuery.length < 2) {
+ return false;
+ }
+
+ const query = this._searchQuery.toLowerCase();
+ const exactMatch = Object.values(this._products).some(
+ product => product.name.toLowerCase() === query
+ );
+
+ return !exactMatch;
+ }
+
+ /**
+ * Render the card
+ */
+ _render() {
+ // Only do initial render if the structure doesn't exist
+ if (!this.shadowRoot.querySelector('.card-content')) {
+ this._initialRender();
+ return;
+ }
+
+ // Otherwise just update the content area
+ this._updateContent();
+ }
+
+ /**
+ * Initial render - creates the persistent structure
+ */
+ _initialRender() {
+ this.shadowRoot.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ this._attachPersistentListeners();
+ this._updateContent();
+ }
+
+ /**
+ * Update only the content area (not search bar)
+ */
+ _updateContent() {
+ const contentArea = this.shadowRoot.querySelector('.content-area');
+ if (!contentArea) return;
+
+ if (this._isLoading) {
+ contentArea.innerHTML = 'Loading...
';
+ return;
+ }
+
+ const activeProducts = this._getActiveProducts();
+ const inactiveProducts = this._getInactiveProducts();
+ let html = '';
+
+
+
+ // Show active products first
+ if (activeProducts.length > 0) {
+ if (this._sortBy === 'category') {
+ html += this._renderProductsByCategory(activeProducts, false);
+ } else {
+ const containerClass = this._settings.layout === 'list' ? 'product-list' : 'product-grid';
+ html += ``;
+ html += activeProducts.map(product => this._renderProductTile(product)).join('');
+ html += '
';
+ }
+ }
+
+ // Show inactive products in "Recently Used" section (unless hidden)
+ if (inactiveProducts.length > 0 && !this._searchQuery && !this._settings.hideCompleted) {
+ html += `
+
+
+ Recently Used
+
+ ${this._sortBy === 'category' ?
+ this._renderProductsByCategory(inactiveProducts, true) :
+ `
+ ${inactiveProducts.map(product => this._renderProductTile(product)).join('')}
+
`
+ }
+
+ `;
+ }
+
+ // Empty state
+ if (activeProducts.length === 0 && inactiveProducts.length === 0) {
+ html += `
+
+ ${this._searchQuery ? 'No products found' : 'No products yet. Search to add your first product!'}
+
+ `;
+ }
+
+ contentArea.innerHTML = html;
+
+ // Stamp live grid columns onto every .product-grid so the setting
+ // takes effect immediately without needing a full re-render.
+ const gridCols = this._settings.productsPerRow === 'auto'
+ ? 'repeat(auto-fill, minmax(120px, 1fr))'
+ : `repeat(${this._settings.productsPerRow}, 1fr)`;
+ contentArea.querySelectorAll('.product-grid').forEach(g => {
+ g.style.gridTemplateColumns = gridCols;
+ });
+
+ this._attachContentListeners();
+ }
+
+ /**
+ * Render products by category (helper for active/inactive)
+ */
+ _renderProductsByCategory(products, isRecentlyUsed) {
+ const containerClass = this._settings.layout === 'list' ? 'product-list' : 'product-grid';
+ const productsByCategory = {};
+
+ products.forEach(product => {
+ const categoryId = product.category || 'other';
+ if (!productsByCategory[categoryId]) {
+ productsByCategory[categoryId] = [];
+ }
+ productsByCategory[categoryId].push(product);
+ });
+
+ return CATEGORIES
+ .filter(cat => productsByCategory[cat.id] && productsByCategory[cat.id].length > 0)
+ .map(cat => {
+ const categoryProducts = productsByCategory[cat.id];
+ const headerHtml = this._settings.compactHeaders
+ ? ``
+ : ``;
+ return `
+
+ ${headerHtml}
+
+ ${categoryProducts.map(product => this._renderProductTile(product)).join('')}
+
+
+ `;
+ }).join('');
+ }
+
+ /**
+ * Attach listeners that persist (search bar, sort buttons)
+ */
+ _attachPersistentListeners() {
+ const searchBar = this.shadowRoot.querySelector('.search-bar');
+ const searchClear = this.shadowRoot.querySelector('.search-clear');
+ const settingsBtn = this.shadowRoot.querySelector('.settings-btn');
+
+ if (searchBar) {
+ searchBar.addEventListener('input', (e) => {
+ this._searchQuery = e.target.value;
+ // ✕ clear button: show/hide based on text
+ if (searchClear) {
+ searchClear.style.display = this._searchQuery.length > 0 ? 'block' : 'none';
+ }
+ // Settings/Add button: swap ⚙️ ↔ ➕ based on whether "add new" applies
+ if (settingsBtn) {
+ if (this._searchQuery.length > 0 && this._shouldShowAddNew()) {
+ settingsBtn.textContent = '➕';
+ settingsBtn.title = 'Add new product';
+ } else {
+ settingsBtn.textContent = '⚙️';
+ settingsBtn.title = 'Settings';
+ }
+ }
+ this._updateContent();
+ });
+ }
+
+ // ✕ clear search
+ if (searchClear) {
+ searchClear.addEventListener('click', () => {
+ this._searchQuery = '';
+ searchBar.value = '';
+ searchClear.style.display = 'none';
+ if (settingsBtn) { settingsBtn.textContent = '⚙️'; settingsBtn.title = 'Settings'; }
+ searchBar.focus();
+ this._updateContent();
+ this._hapticFeedback();
+ });
+ }
+
+ // Settings / Add button (toggles based on search state)
+ if (settingsBtn) {
+ settingsBtn.addEventListener('click', () => {
+ if (settingsBtn.textContent === '➕') {
+ this._addNewProduct(this._searchQuery);
+ } else {
+ this._showSettings();
+ }
+ this._hapticFeedback();
+ });
+ }
+
+ const sortButtons = this.shadowRoot.querySelectorAll('.control-btn[data-sort]');
+ sortButtons.forEach(btn => {
+ btn.addEventListener('click', () => {
+ this._sortBy = btn.dataset.sort;
+ // Update button states
+ sortButtons.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ this._updateContent();
+ this._hapticFeedback();
+ });
+ });
+ }
+
+ /**
+ * Attach listeners for content area (products, buttons)
+ */
+ _attachContentListeners() {
+ // Product tiles - add long-press for edit
+ const tiles = this.shadowRoot.querySelectorAll('.product-tile');
+ tiles.forEach(tile => {
+ const key = tile.dataset.key;
+ let longPressTimer = null;
+ let longPressTriggered = false;
+
+ // Touch events for mobile long-press
+ tile.addEventListener('touchstart', (e) => {
+ longPressTriggered = false;
+ longPressTimer = setTimeout(() => {
+ longPressTriggered = true;
+ this._hapticFeedback();
+ this._editProduct(key);
+ }, 500); // 500ms long press
+ }, { passive: true });
+
+ tile.addEventListener('touchend', () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ }
+ });
+
+ tile.addEventListener('touchmove', () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ }
+ }, { passive: true });
+
+ // Mouse events for desktop
+ tile.addEventListener('mousedown', (e) => {
+ longPressTriggered = false;
+ longPressTimer = setTimeout(() => {
+ longPressTriggered = true;
+ this._editProduct(key);
+ }, 500);
+ });
+
+ tile.addEventListener('mouseup', () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ }
+ });
+
+ tile.addEventListener('mouseleave', () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ }
+ });
+
+ // Right-click for edit
+ tile.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ this._editProduct(key);
+ });
+
+ // Regular click
+ tile.addEventListener('click', (e) => {
+ // Don't toggle if it was a long press or clicking buttons
+ if (!longPressTriggered && !e.target.closest('.qty-button')) {
+ // Check if click is in bottom 30px area (where buttons would be)
+ const rect = tile.getBoundingClientRect();
+ const clickY = e.clientY - rect.top;
+ const isBottomArea = clickY > (rect.height - 35);
+
+ // Only toggle if not clicking in button area
+ if (!isBottomArea || !isActive) {
+ this._toggleProduct(key);
+ }
+ }
+ });
+ });
+
+ // Plus buttons
+ const plusButtons = this.shadowRoot.querySelectorAll('.plus-btn');
+ plusButtons.forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._incrementProduct(btn.dataset.key);
+ });
+ });
+
+ // Minus buttons
+ const minusButtons = this.shadowRoot.querySelectorAll('.minus-btn');
+ minusButtons.forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._decrementProduct(btn.dataset.key);
+ });
+ });
+ }
+
+ /**
+ * Render a single product tile
+ */
+ _renderProductTile(product) {
+ const qty = this._activeList[product.key]?.qty || 0;
+ const isActive = qty > 0;
+ const hasImage = product.image && product.image.trim().length > 0;
+
+ // Determine if image is URL or emoji
+ const isUrl = hasImage && (product.image.startsWith('http') || product.image.startsWith('/'));
+ const displayImage = hasImage ? product.image : '🛒';
+
+ return `
+
+
${product.name}
+
+
+ ${isUrl ?
+ `

+
🛒
` :
+ `
${displayImage}
`
+ }
+
+
+ ${isActive ? `
+
+
+
+
+ ` : `
+
Tap to add
+ `}
+
+ `;
+ }
+
+ disconnectedCallback() {
+ if (this._pollInterval) {
+ clearInterval(this._pollInterval);
+ }
+ if (this._visibilityHandler) {
+ document.removeEventListener('visibilitychange', this._visibilityHandler);
+ }
+ }
+
+ // Temporarily disabled GUI editor - use YAML configuration
+ // static getConfigElement() {
+ // return document.createElement('shopping-list-card-editor');
+ // }
+
+ static getStubConfig() {
+ return { title: 'Shopping List' };
+ }
+
+ getCardSize() {
+ return 3;
+ }
+}
+
+// ── GUI Config Editor ─────────────────────────────────────────
+// This is the standard HA card-editor pattern. HA renders this
+// element inside its "configure card" panel automatically when the
+// card exposes a static getConfigElement() method.
+class ShoppingListCardEditor extends HTMLElement {
+ setConfig(config) {
+ console.log('[Editor] setConfig called, _rendered:', this._rendered, 'config:', config);
+ this._config = { ...(config || {}) }; // shallow-copy: HA 2026+ freezes the original
+ if (!this._rendered) {
+ console.log('[Editor] First render, calling _render()');
+ this._render(); // only render once, then STOP
+ } else {
+ console.log('[Editor] Already rendered, ignoring setConfig');
+ }
+ // After first render, ignore setConfig calls - the updateConfig closure
+ // already handles live updates by mutating this._config directly
+ }
+
+ set hass(hass) {
+ console.log('[Editor] hass setter called, _rendered:', this._rendered);
+ this._hass = hass;
+ if (!this._rendered) this._render();
+ }
+
+ _render() {
+ if (!this._config) return; // nothing to render yet
+
+ // Attach shadow DOM once, reuse on subsequent renders
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ }
+
+ const title = this._config.title || '';
+ const productsPerRow = String(this._config.products_per_row || '3');
+ const layout = this._config.layout || 'grid';
+ const haptics = this._config.haptics || 'medium';
+ const hideCompleted = !!this._config.hide_completed;
+ const compactHeaders = !!this._config.compact_headers;
+
+ this.shadowRoot.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ Auto
+ 2
+ 3
+ 4
+ 5
+
+
+
+
+ Grid
+ List
+
+
+
+
+
+
+ Off
+ Low
+ Medium
+ High
+
+
+
+
+
+
+
+
+ Hide completed items
+ Hides the "Recently Used" section showing products not currently on the list.
+
+
+
+
+
+
+
+ Compact headers
+ Show category names only (no emoji)
+
+
+
+
+
+
+ ${this._showCodeEditor ? `${this._toYaml()}
` : ''}
+ `;
+
+ // ── Set values imperatively (reliable with shadow DOM + HA components) ──
+ const titleEl = this.shadowRoot.querySelector('#ed-title');
+ const perrowEl = this.shadowRoot.querySelector('#ed-perrow');
+ const layoutEl = this.shadowRoot.querySelector('#ed-layout');
+ const hapticsEl = this.shadowRoot.querySelector('#ed-haptics');
+ const hideCompletedEl = this.shadowRoot.querySelector('#ed-hide-completed');
+ const hideHeadersEl = this.shadowRoot.querySelector('#ed-hide-headers');
+
+ titleEl.value = title;
+ perrowEl.value = productsPerRow;
+ layoutEl.value = layout;
+ hapticsEl.value = haptics;
+ hideCompletedEl.checked = hideCompleted;
+ hideHeadersEl.checked = compactHeaders;
+
+ // ── Single updateConfig closure — mirrors your working pattern ──
+ const updateConfig = () => {
+ console.log('[Editor] updateConfig called');
+ this._config = {
+ ...this._config,
+ title: titleEl.value || 'Shopping List',
+ products_per_row: perrowEl.value,
+ layout: layoutEl.value,
+ haptics: hapticsEl.value,
+ hide_completed: hideCompletedEl.checked,
+ compact_headers: hideHeadersEl.checked
+ };
+ console.log('[Editor] Dispatching config-changed:', this._config);
+ this.dispatchEvent(new CustomEvent('config-changed', {
+ detail: { config: this._config },
+ bubbles: true,
+ composed: true
+ }));
+ };
+
+ titleEl.addEventListener('input', updateConfig);
+ perrowEl.addEventListener('value-changed', updateConfig);
+ layoutEl.addEventListener('value-changed', updateConfig);
+ hapticsEl.addEventListener('value-changed', updateConfig);
+ hideCompletedEl.addEventListener('checked-changed', updateConfig);
+ hideHeadersEl.addEventListener('checked-changed', updateConfig);
+
+ this.shadowRoot.querySelector('#ed-code-toggle').addEventListener('click', () => {
+ this._showCodeEditor = !this._showCodeEditor;
+ this._render();
+ });
+
+ this._rendered = true;
+ }
+
+ _toYaml() {
+ const lines = ['type: custom:shopping-list-card'];
+ if (this._config.title) lines.push('title: ' + this._config.title);
+ if (this._config.products_per_row) lines.push('products_per_row: ' + this._config.products_per_row);
+ if (this._config.layout) lines.push('layout: ' + this._config.layout);
+ if (this._config.haptics) lines.push('haptics: ' + this._config.haptics);
+ if (this._config.hide_completed) lines.push('hide_completed: true');
+ if (this._config.compact_headers) lines.push('compact_headers: true');
+ return lines.join('\n');
+ }
+}
+
+customElements.define('shopping-list-card-editor', ShoppingListCardEditor);
+customElements.define('shopping-list-card', ShoppingListCard);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: 'shopping-list-card',
+ name: 'Shopping List',
+ description: 'A shopping list card with search, categories, and product images.',
+ preview: false
+});
diff --git a/custom_components/shopping_list_manager/manager.py b/custom_components/shopping_list_manager/manager.py
new file mode 100644
index 0000000..7f228ef
--- /dev/null
+++ b/custom_components/shopping_list_manager/manager.py
@@ -0,0 +1,300 @@
+"""Core Shopping List Manager with invariant enforcement."""
+import asyncio
+import logging
+from typing import Dict, Optional, List
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import storage
+
+from .const import (
+ DOMAIN,
+ EVENT_SHOPPING_LIST_UPDATED,
+ STORAGE_KEY_ACTIVE,
+ STORAGE_KEY_PRODUCTS,
+ STORAGE_VERSION,
+)
+from .models import Product, ActiveItem, InvariantError, validate_invariant
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ShoppingListManager:
+ """
+ Manages multiple independent shopping lists.
+
+ Each list_id gets its own pair of storage files:
+ - shopping_list_manager.{list_id}.products
+ - shopping_list_manager.{list_id}.active_list
+
+ The default "groceries" list uses the original flat keys for backward compat:
+ - shopping_list_manager.products → groceries products
+ - shopping_list_manager.active_list → groceries active
+
+ Architecture principles:
+ 1. Products and active_list are separate concerns per list
+ 2. Products are authoritative, persistent data
+ 3. Active list is ephemeral state
+ 4. Invariant (active ⊆ products) enforced on every mutation
+ 5. Lock ensures atomic operations per list
+ """
+
+ def __init__(self, hass: HomeAssistant):
+ """Initialize the manager."""
+ self.hass = hass
+ # Per-list in-memory caches: list_id -> {key: Product}
+ self._products: Dict[str, Dict[str, Product]] = {}
+ self._active_list: Dict[str, Dict[str, ActiveItem]] = {}
+ # Per-list locks
+ self._locks: Dict[str, asyncio.Lock] = {}
+ # Per-list storage Store instances (created lazily, except groceries)
+ self._store_products: Dict[str, storage.Store] = {}
+ self._store_active: Dict[str, storage.Store] = {}
+
+ # Pre-create stores for the default "groceries" list using the original flat keys
+ # for backward compatibility — existing data just works
+ self._store_products["groceries"] = storage.Store(
+ hass, STORAGE_VERSION, STORAGE_KEY_PRODUCTS # "shopping_list_manager.products"
+ )
+ self._store_active["groceries"] = storage.Store(
+ hass, STORAGE_VERSION, STORAGE_KEY_ACTIVE # "shopping_list_manager.active_list"
+ )
+
+ def _lock_for(self, list_id: str) -> asyncio.Lock:
+ """Get or create lock for a list."""
+ if list_id not in self._locks:
+ self._locks[list_id] = asyncio.Lock()
+ return self._locks[list_id]
+
+ def _store_products_for(self, list_id: str) -> storage.Store:
+ """Get or create products Store for a list."""
+ if list_id not in self._store_products:
+ # Non-groceries lists use namespaced keys
+ key = f"{DOMAIN}.{list_id}.products"
+ self._store_products[list_id] = storage.Store(self.hass, STORAGE_VERSION, key)
+ return self._store_products[list_id]
+
+ def _store_active_for(self, list_id: str) -> storage.Store:
+ """Get or create active Store for a list."""
+ if list_id not in self._store_active:
+ key = f"{DOMAIN}.{list_id}.active_list"
+ self._store_active[list_id] = storage.Store(self.hass, STORAGE_VERSION, key)
+ return self._store_active[list_id]
+
+ async def _ensure_loaded(self, list_id: str) -> None:
+ """Lazily load a list from storage if not yet in memory."""
+ if list_id in self._products:
+ return # already loaded
+
+ products_data = await self._store_products_for(list_id).async_load()
+ self._products[list_id] = {
+ key: Product.from_dict(data) for key, data in (products_data or {}).items()
+ }
+
+ active_data = await self._store_active_for(list_id).async_load()
+ self._active_list[list_id] = {
+ key: ActiveItem.from_dict(data) for key, data in (active_data or {}).items()
+ }
+
+ # Repair any orphaned active items
+ await self._async_repair_invariant(list_id)
+
+ _LOGGER.info(
+ "Loaded list '%s': %d products, %d active",
+ list_id, len(self._products[list_id]), len(self._active_list[list_id])
+ )
+
+ async def async_load(self) -> None:
+ """Pre-load the default groceries list for backward compat."""
+ async with self._lock_for("groceries"):
+ await self._ensure_loaded("groceries")
+
+ async def _async_repair_invariant(self, list_id: str) -> None:
+ """Remove active items whose product no longer exists."""
+ orphaned = [k for k in self._active_list[list_id] if k not in self._products[list_id]]
+ if orphaned:
+ _LOGGER.warning(
+ "List '%s': removing %d orphaned active items: %s",
+ list_id, len(orphaned), orphaned
+ )
+ for k in orphaned:
+ del self._active_list[list_id][k]
+ await self._async_save_active(list_id)
+
+ async def _async_save_products(self, list_id: str) -> None:
+ """Persist products to storage."""
+ data = {key: p.to_dict() for key, p in self._products[list_id].items()}
+ await self._store_products_for(list_id).async_save(data)
+
+ async def _async_save_active(self, list_id: str) -> None:
+ """Persist active list to storage."""
+ data = {key: a.to_dict() for key, a in self._active_list[list_id].items()}
+ await self._store_active_for(list_id).async_save(data)
+
+ def _fire_update_event(self) -> None:
+ """Fire event to notify listeners of changes."""
+ self.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED)
+
+ # ========================================================================
+ # PUBLIC API - All operations enforce invariants
+ # ========================================================================
+
+ async def async_add_product(
+ self,
+ list_id: str,
+ key: str,
+ name: str,
+ category: str = "other",
+ unit: str = "pcs",
+ image: str = ""
+ ) -> Product:
+ """
+ Add or update a product in a list's catalog.
+
+ This operation:
+ - Creates/updates product metadata
+ - Does NOT modify quantities
+ - Is idempotent
+ - Persists to storage
+
+ Args:
+ list_id: List identifier
+ key: Unique product identifier
+ name: Display name
+ category: Product category
+ unit: Unit of measurement
+ image: Image URL
+
+ Returns:
+ The created/updated Product
+ """
+ async with self._lock_for(list_id):
+ await self._ensure_loaded(list_id)
+
+ product = Product(
+ key=key,
+ name=name,
+ category=category,
+ unit=unit,
+ image=image
+ )
+
+ self._products[list_id][key] = product
+ await self._async_save_products(list_id)
+
+ _LOGGER.debug("List '%s': added/updated product %s (%s)", list_id, name, key)
+ self._fire_update_event()
+
+ return product
+
+ async def async_set_qty(self, list_id: str, key: str, qty: int) -> None:
+ """
+ Set quantity for a product on the shopping list.
+
+ This operation:
+ - REQUIRES product to exist (enforces invariant)
+ - qty > 0: adds/updates active_list
+ - qty == 0: removes from active_list
+ - Persists state
+ - Fires update event
+
+ Args:
+ list_id: List identifier
+ key: Product key (must exist in catalog)
+ qty: New quantity (0 to remove, >0 to add/update)
+
+ Raises:
+ InvariantError: If product doesn't exist
+ ValueError: If qty is negative
+ """
+ if qty < 0:
+ raise ValueError(f"Quantity cannot be negative: {qty}")
+
+ async with self._lock_for(list_id):
+ await self._ensure_loaded(list_id)
+
+ # INVARIANT ENFORCEMENT: Product must exist
+ if key not in self._products[list_id]:
+ raise InvariantError(
+ f"Cannot set quantity for unknown product '{key}' in list '{list_id}'. "
+ f"Product must be created first with add_product."
+ )
+
+ # Update or remove from active list
+ if qty > 0:
+ self._active_list[list_id][key] = ActiveItem(qty=qty)
+ _LOGGER.debug("List '%s': set qty for %s: %d", list_id, key, qty)
+ else:
+ # qty == 0: remove from list
+ if key in self._active_list[list_id]:
+ del self._active_list[list_id][key]
+ _LOGGER.debug("List '%s': removed %s from active list", list_id, key)
+
+ await self._async_save_active(list_id)
+ self._fire_update_event()
+
+ async def async_delete_product(self, list_id: str, key: str) -> None:
+ """
+ Delete a product from the catalog.
+
+ This operation:
+ - Removes product from catalog
+ - Removes from active list (maintains invariant)
+ - Persists both changes
+
+ Args:
+ list_id: List identifier
+ key: Product key to delete
+ """
+ async with self._lock_for(list_id):
+ await self._ensure_loaded(list_id)
+
+ if key not in self._products[list_id]:
+ _LOGGER.warning("List '%s': attempted to delete non-existent product: %s", list_id, key)
+ return
+
+ # Remove from catalog
+ del self._products[list_id][key]
+
+ # Remove from active list (maintain invariant)
+ if key in self._active_list[list_id]:
+ del self._active_list[list_id][key]
+
+ await self._async_save_products(list_id)
+ await self._async_save_active(list_id)
+
+ _LOGGER.debug("List '%s': deleted product: %s", list_id, key)
+ self._fire_update_event()
+
+ async def async_get_products(self, list_id: str) -> Dict[str, dict]:
+ """
+ Get all products in a list's catalog.
+
+ Args:
+ list_id: List identifier
+
+ Returns:
+ Dictionary of product key -> product data
+ """
+ async with self._lock_for(list_id):
+ await self._ensure_loaded(list_id)
+ return {key: product.to_dict() for key, product in self._products[list_id].items()}
+
+ async def async_get_active(self, list_id: str) -> Dict[str, dict]:
+ """
+ Get active shopping list (quantities only).
+
+ Args:
+ list_id: List identifier
+
+ Returns:
+ Dictionary of product key -> active item data (qty only)
+ """
+ async with self._lock_for(list_id):
+ await self._ensure_loaded(list_id)
+ return {key: item.to_dict() for key, item in self._active_list[list_id].items()}
+
+ # NOTE: The following methods were removed as they're not used by the websocket API
+ # and would need updating to support per-list structure:
+ # - async_get_full_state()
+ # - get_product()
+ # - get_active_qty()
\ No newline at end of file
diff --git a/custom_components/shopping_list_manager/manifest.json b/custom_components/shopping_list_manager/manifest.json
new file mode 100644
index 0000000..460556f
--- /dev/null
+++ b/custom_components/shopping_list_manager/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "shopping_list_manager",
+ "name": "Shopping List Manager",
+ "version": "1.0.0",
+ "documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
+ "issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": ["@thekiwismarthome"],
+ "config_flow": true,
+ "iot_class": "local_polling"
+}
\ No newline at end of file
diff --git a/custom_components/shopping_list_manager/models.py b/custom_components/shopping_list_manager/models.py
new file mode 100644
index 0000000..d7ceef5
--- /dev/null
+++ b/custom_components/shopping_list_manager/models.py
@@ -0,0 +1,104 @@
+"""Data models for Shopping List Manager."""
+from dataclasses import dataclass, asdict
+from typing import Dict
+
+
+@dataclass
+class Product:
+ """
+ Product catalog entry - authoritative product definition.
+
+ Products exist independently of the shopping list.
+ They define WHAT can be shopped, not HOW MUCH is needed.
+ """
+ key: str
+ name: str
+ category: str = "other"
+ unit: str = "pcs"
+ image: str = ""
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for storage/transmission."""
+ return {
+ "key": self.key,
+ "name": self.name,
+ "category": self.category,
+ "unit": self.unit,
+ "image": self.image
+ }
+
+ @staticmethod
+ def from_dict(data: dict) -> 'Product':
+ """Create Product from dictionary."""
+ return Product(
+ key=data["key"],
+ name=data["name"],
+ category=data.get("category", "other"),
+ unit=data.get("unit", "pcs"),
+ image=data.get("image", "")
+ )
+
+ def __post_init__(self):
+ """Validate product data."""
+ if not self.key:
+ raise ValueError("Product key cannot be empty")
+ if not self.name:
+ raise ValueError("Product name cannot be empty")
+
+
+@dataclass
+class ActiveItem:
+ """
+ Shopping list state - quantity only.
+
+ Contains NO product metadata, only references products by key.
+ qty > 0 means "on the list"
+ qty == 0 means "not on the list" (should be removed)
+ """
+ qty: int
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for storage/transmission."""
+ return {"qty": self.qty}
+
+ @staticmethod
+ def from_dict(data: dict) -> 'ActiveItem':
+ """Create ActiveItem from dictionary."""
+ return ActiveItem(qty=data["qty"])
+
+ def __post_init__(self):
+ """Validate quantity."""
+ if self.qty < 0:
+ raise ValueError("Quantity cannot be negative")
+
+
+class InvariantError(Exception):
+ """
+ Raised when the core data model invariant is violated.
+
+ Invariant: Every key in active_list MUST exist in products.
+
+ If this exception is raised, the system is in an inconsistent state
+ and must be repaired before continuing.
+ """
+ pass
+
+
+def validate_invariant(products: Dict[str, Product],
+ active_list: Dict[str, ActiveItem]) -> None:
+ """
+ Validate the core data model invariant.
+
+ Args:
+ products: Product catalog dictionary
+ active_list: Active shopping list dictionary
+
+ Raises:
+ InvariantError: If any key in active_list doesn't exist in products
+ """
+ for key in active_list:
+ if key not in products:
+ raise InvariantError(
+ f"Invariant violated: active_list contains unknown product key '{key}'. "
+ f"This product must be added to the catalog first."
+ )
diff --git a/custom_components/shopping_list_manager/websocket_api.py b/custom_components/shopping_list_manager/websocket_api.py
new file mode 100644
index 0000000..f248d32
--- /dev/null
+++ b/custom_components/shopping_list_manager/websocket_api.py
@@ -0,0 +1,256 @@
+"""WebSocket API for Shopping List Manager."""
+import logging
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+
+from .const import DOMAIN
+from .models import InvariantError
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@websocket_api.websocket_command({
+ vol.Required("type"): "shopping_list_manager/add_product",
+ vol.Optional("list_id", default="groceries"): str,
+ vol.Required("key"): str,
+ vol.Required("name"): str,
+ vol.Optional("category", default="other"): str,
+ vol.Optional("unit", default="pcs"): str,
+ vol.Optional("image", default=""): str,
+})
+@websocket_api.async_response
+async def websocket_add_product(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """
+ Add or update a product in the catalog.
+
+ Does NOT modify quantity - use set_qty for that.
+
+ Request:
+ {
+ "type": "shopping_list_manager/add_product",
+ "list_id": "groceries", # optional, defaults to "groceries"
+ "key": "milk",
+ "name": "Milk",
+ "category": "dairy",
+ "unit": "pcs",
+ "image": ""
+ }
+
+ Response:
+ {
+ "success": true,
+ "result": {
+ "key": "milk",
+ "name": "Milk",
+ "category": "dairy",
+ "unit": "pcs",
+ "image": ""
+ }
+ }
+ """
+ manager = hass.data[DOMAIN]["manager"]
+ list_id = msg.get("list_id", "groceries")
+
+ try:
+ product = await manager.async_add_product(
+ list_id=list_id,
+ key=msg["key"],
+ name=msg["name"],
+ category=msg.get("category", "other"),
+ unit=msg.get("unit", "pcs"),
+ image=msg.get("image", "")
+ )
+
+ connection.send_result(msg["id"], product.to_dict())
+
+ except Exception as err:
+ _LOGGER.error("Error adding product to list '%s': %s", list_id, err)
+ connection.send_error(msg["id"], "add_product_failed", str(err))
+
+
+@websocket_api.websocket_command({
+ vol.Required("type"): "shopping_list_manager/set_qty",
+ vol.Optional("list_id", default="groceries"): str,
+ vol.Required("key"): str,
+ vol.Required("qty"): vol.All(int, vol.Range(min=0)),
+})
+@websocket_api.async_response
+async def websocket_set_qty(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """
+ Set quantity for a product on the shopping list.
+
+ Product MUST exist in catalog first.
+ qty = 0 removes from list.
+ qty > 0 adds/updates on list.
+
+ Request:
+ {
+ "type": "shopping_list_manager/set_qty",
+ "list_id": "groceries", # optional, defaults to "groceries"
+ "key": "milk",
+ "qty": 2
+ }
+
+ Response:
+ {
+ "success": true
+ }
+
+ Error (if product doesn't exist):
+ {
+ "success": false,
+ "error": {
+ "code": "invariant_violation",
+ "message": "Cannot set quantity for unknown product 'milk'..."
+ }
+ }
+ """
+ manager = hass.data[DOMAIN]["manager"]
+ list_id = msg.get("list_id", "groceries")
+
+ try:
+ await manager.async_set_qty(
+ list_id=list_id,
+ key=msg["key"],
+ qty=msg["qty"]
+ )
+
+ connection.send_result(msg["id"], {"success": True})
+
+ except InvariantError as err:
+ # This is expected if frontend tries to set qty for non-existent product
+ _LOGGER.warning("Invariant violation in set_qty (list '%s'): %s", list_id, err)
+ connection.send_error(msg["id"], "invariant_violation", str(err))
+
+ except Exception as err:
+ _LOGGER.error("Error setting quantity in list '%s': %s", list_id, err)
+ connection.send_error(msg["id"], "set_qty_failed", str(err))
+
+
+@websocket_api.websocket_command({
+ vol.Required("type"): "shopping_list_manager/get_products",
+ vol.Optional("list_id", default="groceries"): str,
+})
+@websocket_api.async_response
+async def websocket_get_products(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """
+ Get all products in the catalog.
+
+ Request:
+ {
+ "type": "shopping_list_manager/get_products",
+ "list_id": "groceries" # optional, defaults to "groceries"
+ }
+
+ Response:
+ {
+ "milk": {
+ "key": "milk",
+ "name": "Milk",
+ "category": "dairy",
+ "unit": "pcs",
+ "image": ""
+ },
+ ...
+ }
+ """
+ manager = hass.data[DOMAIN]["manager"]
+ list_id = msg.get("list_id", "groceries")
+
+ try:
+ products = await manager.async_get_products(list_id=list_id)
+ connection.send_result(msg["id"], products)
+
+ except Exception as err:
+ _LOGGER.error("Error getting products for list '%s': %s", list_id, err)
+ connection.send_error(msg["id"], "get_products_failed", str(err))
+
+
+@websocket_api.websocket_command({
+ vol.Required("type"): "shopping_list_manager/get_active",
+ vol.Optional("list_id", default="groceries"): str,
+})
+@websocket_api.async_response
+async def websocket_get_active(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """
+ Get active shopping list (quantities only).
+
+ Request:
+ {
+ "type": "shopping_list_manager/get_active",
+ "list_id": "groceries" # optional, defaults to "groceries"
+ }
+
+ Response:
+ {
+ "milk": {"qty": 2},
+ "bread": {"qty": 1},
+ ...
+ }
+ """
+ manager = hass.data[DOMAIN]["manager"]
+ list_id = msg.get("list_id", "groceries")
+
+ try:
+ active = await manager.async_get_active(list_id=list_id)
+ connection.send_result(msg["id"], active)
+
+ except Exception as err:
+ _LOGGER.error("Error getting active list for '%s': %s", list_id, err)
+ connection.send_error(msg["id"], "get_active_failed", str(err))
+
+
+@websocket_api.websocket_command({
+ vol.Required("type"): "shopping_list_manager/delete_product",
+ vol.Optional("list_id", default="groceries"): str,
+ vol.Required("key"): str,
+})
+@websocket_api.async_response
+async def websocket_delete_product(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """
+ Delete a product from catalog (and remove from active list).
+
+ Request:
+ {
+ "type": "shopping_list_manager/delete_product",
+ "list_id": "groceries", # optional, defaults to "groceries"
+ "key": "milk"
+ }
+
+ Response:
+ {
+ "success": true
+ }
+ """
+ manager = hass.data[DOMAIN]["manager"]
+ list_id = msg.get("list_id", "groceries")
+
+ try:
+ await manager.async_delete_product(list_id=list_id, key=msg["key"])
+ connection.send_result(msg["id"], {"success": True})
+
+ except Exception as err:
+ _LOGGER.error("Error deleting product from list '%s': %s", list_id, err)
+ connection.send_error(msg["id"], "delete_product_failed", str(err))
\ No newline at end of file
diff --git a/hacs.json b/hacs.json
new file mode 100644
index 0000000..43d61b4
--- /dev/null
+++ b/hacs.json
@@ -0,0 +1,7 @@
+{
+ "name": "Shopping List Manager",
+ "content_in_root": false,
+ "render_readme": true,
+ "domains": ["shopping_list_manager"],
+ "homeassistant": "2024.1.0"
+}
diff --git a/info.md b/info.md
new file mode 100644
index 0000000..472b830
--- /dev/null
+++ b/info.md
@@ -0,0 +1,11 @@
+# Shopping List Manager
+
+A modern shopping list integration with visual tiles, auto-image search, and multi-list support.
+
+## Features
+- Visual grid/list layouts
+- Auto-image search from local files
+- Multiple independent lists
+- Cog settings for quick configuration
+- Category organization
+- Haptic feedback