From a89129b284a9328fd1871faaef8645882ca80585 Mon Sep 17 00:00:00 2001 From: thekiwismarthome <134335563+thekiwismarthome@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:39:05 +1300 Subject: [PATCH] Add files via upload --- README.md | 36 +- .../shopping_list_manager/__init__.py | 64 + .../shopping_list_manager/config_flow.py | 25 + .../shopping_list_manager/const.py | 11 + .../frontend/shopping_list_card.js | 2202 +++++++++++++++++ .../shopping_list_manager/manager.py | 300 +++ .../shopping_list_manager/manifest.json | 12 + .../shopping_list_manager/models.py | 104 + .../shopping_list_manager/websocket_api.py | 256 ++ hacs.json | 7 + info.md | 11 + 11 files changed, 3027 insertions(+), 1 deletion(-) create mode 100644 custom_components/shopping_list_manager/__init__.py create mode 100644 custom_components/shopping_list_manager/config_flow.py create mode 100644 custom_components/shopping_list_manager/const.py create mode 100644 custom_components/shopping_list_manager/frontend/shopping_list_card.js create mode 100644 custom_components/shopping_list_manager/manager.py create mode 100644 custom_components/shopping_list_manager/manifest.json create mode 100644 custom_components/shopping_list_manager/models.py create mode 100644 custom_components/shopping_list_manager/websocket_api.py create mode 100644 hacs.json create mode 100644 info.md diff --git a/README.md b/README.md index 80acc1c..1803a8d 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ -# Shopping-List-Manager \ No newline at end of file +## Installation + +### 1. Install via HACS + +[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration) + +Click the button above or manually add the custom repository in HACS. +Restart HA + +### 2. Add the Card Resource + +After installing via HACS, add the frontend resource: + +1. Go to Settings → Dashboards +2. Click ⋮ (three dots, top right) → Resources +3. Click "+ Add Resource" +4. URL: `/hacsfiles/shopping-list-manager/shopping_list_card.js` +5. Resource type: JavaScript Module +6. Click "Create" + +### 3. Add the Integration + +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=shopping_list_manager) + +Click the button above or manually add via Settings → Devices & Services. + +### 4. Add Card to Dashboard +```yaml +type: custom:shopping-list-card +title: Shopping List +list_id: groceries +``` + + +Use the ⚙️ cog button in the card to configure settings. diff --git a/custom_components/shopping_list_manager/__init__.py b/custom_components/shopping_list_manager/__init__.py new file mode 100644 index 0000000..6d93714 --- /dev/null +++ b/custom_components/shopping_list_manager/__init__.py @@ -0,0 +1,64 @@ +""" +Shopping List Manager - Home Assistant Custom Integration +Clean-slate architecture with enforced invariants +""" +import logging +from pathlib import Path +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.components import websocket_api + +from .const import DOMAIN +from .manager import ShoppingListManager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the Shopping List Manager component.""" + # Register frontend path for the card + frontend_path = Path(__file__).parent / "frontend" + hass.http.register_static_path( + f"/hacsfiles/{DOMAIN}", + str(frontend_path), + cache_headers=False, + ) + _LOGGER.info(f"Registered frontend path: /hacsfiles/{DOMAIN}") + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shopping List Manager from a config entry.""" + # Initialize the manager + manager = ShoppingListManager(hass) + await manager.async_load() + + # Store manager in hass.data + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["manager"] = manager + + # Register WebSocket commands + from .websocket_api import ( + websocket_add_product, + websocket_set_qty, + websocket_get_products, + websocket_get_active, + websocket_delete_product, + ) + + websocket_api.async_register_command(hass, websocket_add_product) + websocket_api.async_register_command(hass, websocket_set_qty) + websocket_api.async_register_command(hass, websocket_get_products) + websocket_api.async_register_command(hass, websocket_get_active) + websocket_api.async_register_command(hass, websocket_delete_product) + + _LOGGER.info("Shopping List Manager setup complete - registered 5 WebSocket commands") + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Shopping List Manager.""" + hass.data[DOMAIN].pop("manager", None) + return True diff --git a/custom_components/shopping_list_manager/config_flow.py b/custom_components/shopping_list_manager/config_flow.py new file mode 100644 index 0000000..8492172 --- /dev/null +++ b/custom_components/shopping_list_manager/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for Shopping List Manager.""" +from homeassistant import config_entries + +from .const import DOMAIN + + +class ShoppingListManagerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Shopping List Manager.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + # Only allow one instance + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry( + title="Shopping List Manager", + data={} + ) + + # Show simple form + return self.async_show_form(step_id="user") \ No newline at end of file diff --git a/custom_components/shopping_list_manager/const.py b/custom_components/shopping_list_manager/const.py new file mode 100644 index 0000000..36c7872 --- /dev/null +++ b/custom_components/shopping_list_manager/const.py @@ -0,0 +1,11 @@ +"""Constants for Shopping List Manager.""" + +DOMAIN = "shopping_list_manager" + +# Storage keys +STORAGE_VERSION = 1 +STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products" +STORAGE_KEY_ACTIVE = f"{DOMAIN}.active_list" + +# Events +EVENT_SHOPPING_LIST_UPDATED = f"{DOMAIN}_updated" diff --git a/custom_components/shopping_list_manager/frontend/shopping_list_card.js b/custom_components/shopping_list_manager/frontend/shopping_list_card.js new file mode 100644 index 0000000..b88d4a8 --- /dev/null +++ b/custom_components/shopping_list_manager/frontend/shopping_list_card.js @@ -0,0 +1,2202 @@ +/** + * Shopping List Manager - Custom Lovelace Card + * + * Uses proper Home Assistant WebSocket API + * Version: 2.1.0 - Fixed syntax errors + */ + +// Category definitions +const CATEGORIES = [ + { id: "fruitveg", emoji: "🥬", name: "Fruit & Vegetables", order: 1 }, + { id: "meat", emoji: "🥩", name: "Meat, Poultry & Seafood", order: 2 }, + { id: "fridge", emoji: "🥛", name: "Fridge, Deli & Eggs", order: 3 }, + { id: "bakery", emoji: "🥖", name: "Bakery", order: 4 }, + { id: "frozen", emoji: "🧊", name: "Frozen", order: 5 }, + { id: "pantry", emoji: "🥫", name: "Pantry", order: 6 }, + { id: "drinks", emoji: "☕", name: "Hot & Cold Drinks", order: 7 }, + { id: "alcohol", emoji: "🍺", name: "Beer, Wine & Cider", order: 8 }, + { id: "health", emoji: "🧴", name: "Health & Body", order: 9 }, + { id: "baby", emoji: "🍼", name: "Baby & Toddler", order: 10 }, + { id: "pets", emoji: "🐾", name: "Pets", order: 11 }, + { id: "household", emoji: "🧹", name: "Household & Cleaning", order: 12 }, + { id: "snacks", emoji: "🍫", name: "Snacks, Treats & Easy Meals", order: 13 }, + { id: "other", emoji: "📦", name: "Other", order: 99 } +]; + +// Create category lookup map +const CATEGORY_MAP = CATEGORIES.reduce((map, cat) => { + map[cat.id] = cat; + return map; +}, {}); + +class ShoppingListCard extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + + // State + this._hass = null; + this._config = null; + this._products = {}; + this._activeList = {}; + this._searchQuery = ''; + this._pollInterval = null; + this._isLoading = true; + this._sortBy = 'category'; // 'category' or 'alphabet' + this._selectedCategory = null; // null = show all + this._searchDebounceTimer = null; + this._localImageCache = {}; // Cache for local image lookups + this._imageListCache = null; // Cached directory listing from /local/images/shopping_list_manager/ + this._cardSize = 'small'; // 'small' or 'large' - detected from card width + + // Settings (load from localStorage or defaults) + this._settings = this._loadSettings(); + } + + /** + * Load settings from localStorage + */ + _loadSettings() { + // Baseline defaults + const defaults = { + haptics: 'medium', + productsPerRow: '3', + layout: 'grid', + hideCompleted: false, + compactHeaders: false, + }; + + // Layer 1: read from the card config (this is what HA restores + // from the persisted YAML on every page load) + if (this._config) { + if (this._config.haptics) defaults.haptics = this._config.haptics; + if (this._config.products_per_row) defaults.productsPerRow = String(this._config.products_per_row); + if (this._config.layout) defaults.layout = this._config.layout; + if (this._config.hide_completed !== undefined) defaults.hideCompleted = this._config.hide_completed; + if (this._config.compact_headers !== undefined) defaults.compactHeaders = this._config.compact_headers; + } + + // Layer 2: localStorage ALWAYS WINS - cog settings are the source of truth + // This keeps settings persistent and instant without needing YAML edits + const key = this._settingsKey || 'shopping_list_settings_default'; + const saved = localStorage.getItem(key); + if (saved) { + try { + return { ...defaults, ...JSON.parse(saved) }; + } catch (e) { + console.error('Failed to load settings:', e); + } + } + + return defaults; + } + + /** + * Save settings to localStorage + */ + _saveSettings() { + // Persist to localStorage - this is the source of truth for settings + const key = this._settingsKey || 'shopping_list_settings_default'; + localStorage.setItem(key, JSON.stringify(this._settings)); + } + + set hass(hass) { + const oldHass = this._hass; + this._hass = hass; + + // Load data when hass is first set + if (!oldHass && hass) { + this._loadData(); + this._startPolling(); + } + } + + setConfig(config) { + this._config = { ...config }; // shallow-copy: HA 2026+ freezes the original + // Derive a unique settings key for THIS card instance so each card on + // the dashboard keeps its own independent settings in localStorage. + // Set `card_id` in YAML for an explicit stable label; otherwise title is used. + const id = (config.card_id || config.title || 'shopping_list').toString().trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'); + this._settingsKey = `shopping_list_settings_${id}`; + // Re-load settings now that we have the correct key + this._settings = this._loadSettings(); + } + + /** + * Load data using Home Assistant's connection.sendMessagePromise + */ + /** + * Compare two objects for actual data changes (ignoring order and timestamps) + */ + _hasDataChanged(oldData, newData) { + if (!oldData && !newData) return false; + if (!oldData || !newData) return true; + + const oldKeys = Object.keys(oldData).sort(); + const newKeys = Object.keys(newData).sort(); + + // Different number of items = changed + if (oldKeys.length !== newKeys.length) return true; + + // Check if keys are different + if (JSON.stringify(oldKeys) !== JSON.stringify(newKeys)) return true; + + // Check if values are different (only compare qty, name, category) + for (const key of oldKeys) { + const oldItem = oldData[key]; + const newItem = newData[key]; + + // For active list, only compare qty + if (oldItem.qty !== undefined && newItem.qty !== undefined) { + if (oldItem.qty !== newItem.qty) return true; + } + // For products, compare name, category, and image + else { + if (oldItem.name !== newItem.name || oldItem.category !== newItem.category || oldItem.image !== newItem.image) { + return true; + } + } + } + + return false; + } + + async _loadData() { + if (!this._hass || !this._hass.connection) { + return; + } + + try { + const [products, activeList] = await Promise.all([ + this._hass.connection.sendMessagePromise({ + type: 'shopping_list_manager/get_products' + }), + this._hass.connection.sendMessagePromise({ + type: 'shopping_list_manager/get_active' + }) + ]); + + // Check if data actually changed before re-rendering + const productsChanged = this._hasDataChanged(this._products, products || {}); + const activeChanged = this._hasDataChanged(this._activeList, activeList || {}); + const isFirstLoad = this._isLoading; + + + this._products = products || {}; + this._activeList = activeList || {}; + this._isLoading = false; + + // Render on first load or if data changed + if (isFirstLoad || productsChanged || activeChanged) { + // On first load, do full render. On updates, just update content + if (isFirstLoad) { + this._render(); + } else { + this._updateContent(); + } + } else { + } + } catch (error) { + console.error('Failed to load shopping list data:', error); + this._isLoading = false; + // Only update content on error, don't rebuild entire UI + if (this.shadowRoot.querySelector('.card-content')) { + this._updateContent(); + } else { + this._render(); + } + } + } + + /** + * Poll for updates every 10 seconds (only when page is visible) + */ + _startPolling() { + if (!this._hass || this._pollInterval) { + return; + } + + this._pollInterval = setInterval(() => { + // Only poll if page is visible and user isn't actively typing + if (this._hass && this._hass.connection && !document.hidden) { + this._loadData(); + } + }, 3000); // 3 seconds + + // Also poll when page becomes visible again + this._visibilityHandler = () => { + if (!document.hidden && this._hass && this._hass.connection) { + this._loadData(); + } + }; + document.addEventListener('visibilitychange', this._visibilityHandler); + } + + /** + * Fetch and cache the directory listing from the shopping_list_manager image folder. + * HA's static file server returns an HTML page with links for each file. + */ + async _fetchImageList() { + if (this._imageListCache !== null) return this._imageListCache; + + const dir = '/local/images/shopping_list_manager/'; + try { + const res = await fetch(dir); + if (!res.ok) { + console.warn('[ShoppingList] Image directory fetch returned', res.status, '— falling back to direct-guess mode'); + this._imageListCache = []; + return []; + } + const html = await res.text(); + console.log('[ShoppingList] Raw directory HTML length:', html.length); + + // Extract href values from 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 + ? `
${cat.name}
` + : `
${cat.emoji}${cat.name}
`; + 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 ? + `${product.name} + ` : + `
${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