Compare commits

...

9 Commits

6 changed files with 255 additions and 15 deletions
@@ -27,27 +27,33 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Shopping List Manager from a config entry.""" """Set up Shopping List Manager from a config entry."""
_LOGGER.info("Setting up Shopping List Manager") _LOGGER.info("Setting up Shopping List Manager")
# Get component path for loading data files # Get component path for loading data files
component_path = os.path.dirname(__file__) component_path = os.path.dirname(__file__)
config_path = hass.config.path() config_path = hass.config.path()
# Get country from options (or fall back to data, or default to NZ) # Get country from options (or fall back to data, or default to NZ)
country = entry.options.get("country") or entry.data.get("country", "NZ") country = entry.options.get("country") or entry.data.get("country", "NZ")
_LOGGER.info("Using country: %s", country) _LOGGER.info("Using country: %s", country)
# Initialize storage with country # Initialize storage with country
storage = ShoppingListStorage(hass, component_path, country) storage = ShoppingListStorage(hass, component_path, country)
await storage.async_load() await storage.async_load()
# Initialize image handler # Initialize image handler
image_handler = ImageHandler(hass, config_path) image_handler = ImageHandler(hass, config_path)
# Read installed version from manifest
import json as _json
with open(os.path.join(component_path, "manifest.json")) as _f:
_manifest = _json.load(_f)
# Store instances in hass.data # Store instances in hass.data
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][DATA_STORAGE] = storage hass.data[DOMAIN][DATA_STORAGE] = storage
hass.data[DOMAIN]["image_handler"] = image_handler hass.data[DOMAIN]["image_handler"] = image_handler
hass.data[DOMAIN]["country"] = country hass.data[DOMAIN]["country"] = country
hass.data[DOMAIN]["version"] = _manifest.get("version", "unknown")
# Register update listener for options changes # Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(update_listener)) entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -200,11 +206,21 @@ async def _async_register_websocket_handlers(
hass, hass,
handlers.websocket_update_product, handlers.websocket_update_product,
) )
websocket_api.async_register_command(
hass,
handlers.websocket_delete_product,
)
websocket_api.async_register_command( websocket_api.async_register_command(
hass, hass,
handlers.websocket_get_product_substitutes, handlers.websocket_get_product_substitutes,
) )
# OpenFoodFacts proxy
websocket_api.async_register_command(
hass,
handlers.websocket_off_fetch,
)
# Categories handlers # Categories handlers
websocket_api.async_register_command( websocket_api.async_register_command(
hass, hass,
@@ -76,7 +76,10 @@ IMAGE_FORMAT = "webp"
IMAGE_SIZE = 200 # 200x200px IMAGE_SIZE = 200 # 200x200px
IMAGE_QUALITY = 85 IMAGE_QUALITY = 85
IMAGE_MAX_SIZE_KB = 15 IMAGE_MAX_SIZE_KB = 15
IMAGES_LOCAL_DIR = "www/shopping_list_manager/images" IMAGES_LOCAL_DIR = "www/images/shopping_list_manager"
LEGACY_IMAGES_LOCAL_DIR = "www/shopping_list_manager/images"
LOCAL_IMAGE_URL_PREFIX = "/local/images/shopping_list_manager/"
LEGACY_IMAGE_URL_PREFIX = "/local/shopping_list_manager/images/"
# Placeholder image (inline SVG) # Placeholder image (inline SVG)
PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='Arial' font-size='16' fill='%23999'%3ENo Image%3C/text%3E%3C/svg%3E" PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='Arial' font-size='16' fill='%23999'%3ENo Image%3C/text%3E%3C/svg%3E"
@@ -2,7 +2,9 @@
import json import json
import logging import logging
import os import os
import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from .utils.search import ProductSearch from .utils.search import ProductSearch
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -15,6 +17,10 @@ from .const import (
STORAGE_KEY_PRODUCTS, STORAGE_KEY_PRODUCTS,
STORAGE_KEY_CATEGORIES, STORAGE_KEY_CATEGORIES,
STORAGE_KEY_LOYALTY_CARDS, STORAGE_KEY_LOYALTY_CARDS,
IMAGES_LOCAL_DIR,
LEGACY_IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX,
LEGACY_IMAGE_URL_PREFIX,
) )
from .data.catalog_loader import load_product_catalog from .data.catalog_loader import load_product_catalog
from .models import ShoppingList, Item, Product, Category, LoyaltyCard, generate_id from .models import ShoppingList, Item, Product, Category, LoyaltyCard, generate_id
@@ -49,6 +55,8 @@ class ShoppingListStorage:
self._categories: List[Category] = [] self._categories: List[Category] = []
self._loyalty_cards: Dict[str, LoyaltyCard] = {} self._loyalty_cards: Dict[str, LoyaltyCard] = {}
self._search_engine: Optional[ProductSearch] = None self._search_engine: Optional[ProductSearch] = None
self._images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR))
self._legacy_images_dir = Path(hass.config.path(LEGACY_IMAGES_LOCAL_DIR))
async def async_load(self) -> None: async def async_load(self) -> None:
"""Load data from storage.""" """Load data from storage."""
@@ -89,6 +97,10 @@ class ShoppingListStorage:
for product_id, product_data in products_data.items() for product_id, product_data in products_data.items()
} }
_LOGGER.debug("Loaded %d products", len(self._products)) _LOGGER.debug("Loaded %d products", len(self._products))
# Ensure image directory exists and migrate legacy image paths/URLs.
self._images_dir.mkdir(parents=True, exist_ok=True)
await self._migrate_legacy_images_and_urls()
# Load categories # Load categories
categories_data = await self._store_categories.async_load() categories_data = await self._store_categories.async_load()
@@ -419,6 +431,56 @@ class ShoppingListStorage:
"""Save products to storage.""" """Save products to storage."""
data = {product_id: product.to_dict() for product_id, product in self._products.items()} data = {product_id: product.to_dict() for product_id, product in self._products.items()}
await self._store_products.async_save(data) await self._store_products.async_save(data)
async def _migrate_legacy_images_and_urls(self) -> None:
"""Move old image files and rewrite stored URLs to the new path."""
moved_files = 0
updated_product_urls = 0
updated_item_urls = 0
if self._legacy_images_dir.exists() and self._legacy_images_dir != self._images_dir:
for src in self._legacy_images_dir.glob("*"):
if not src.is_file():
continue
dest = self._images_dir / src.name
if dest.exists():
continue
try:
shutil.move(str(src), str(dest))
moved_files += 1
except Exception as err:
_LOGGER.debug("Could not move legacy image %s: %s", src, err)
for product in self._products.values():
image_url = product.image_url or ""
if image_url.startswith(LEGACY_IMAGE_URL_PREFIX):
product.image_url = image_url.replace(
LEGACY_IMAGE_URL_PREFIX, LOCAL_IMAGE_URL_PREFIX, 1
)
updated_product_urls += 1
for items in self._items.values():
for item in items:
image_url = item.image_url or ""
if image_url.startswith(LEGACY_IMAGE_URL_PREFIX):
item.image_url = image_url.replace(
LEGACY_IMAGE_URL_PREFIX, LOCAL_IMAGE_URL_PREFIX, 1
)
updated_item_urls += 1
if updated_product_urls:
await self._save_products()
if updated_item_urls:
await self._save_items()
if moved_files or updated_product_urls or updated_item_urls:
_LOGGER.info(
"Migrated shopping list images to %s (moved_files=%d, updated_product_urls=%d, updated_item_urls=%d)",
IMAGES_LOCAL_DIR,
moved_files,
updated_product_urls,
updated_item_urls,
)
def get_products(self) -> List[Product]: def get_products(self) -> List[Product]:
"""Get all products.""" """Get all products."""
@@ -556,6 +618,18 @@ class ShoppingListStorage:
_LOGGER.info("Reloaded catalog for %s: %d products imported", country_code, count) _LOGGER.info("Reloaded catalog for %s: %d products imported", country_code, count)
return count return count
async def delete_product(self, product_id: str) -> bool:
"""Delete a product from the catalog."""
if product_id not in self._products:
return False
del self._products[product_id]
await self._save_products()
# Rebuild search engine so the product is no longer searchable
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
self._search_engine = ProductSearch(products_dict)
_LOGGER.debug("Deleted product: %s", product_id)
return True
async def update_product(self, product_id: str, **kwargs) -> Optional[Product]: async def update_product(self, product_id: str, **kwargs) -> Optional[Product]:
"""Update a product.""" """Update a product."""
if product_id not in self._products: if product_id not in self._products:
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Set up the Shopping List Manager integration. Country and other settings can be configured after setup via the Configure button."
}
},
"abort": {
"single_instance_allowed": "Only a single instance of Shopping List Manager is allowed."
}
},
"options": {
"step": {
"init": {
"title": "Shopping List Manager Options",
"description": "Changing country will reload the product catalog on next restart.",
"data": {
"country": "Country",
"enable_price_tracking": "Enable price tracking",
"enable_image_search": "Enable image search",
"metric_units_only": "Metric units only"
},
"data_description": {
"country": "Used to localise product catalog and pricing.",
"enable_price_tracking": "Track and display product prices.",
"enable_image_search": "Search for product images automatically.",
"metric_units_only": "Show only metric units (g, kg, ml, L)."
}
}
}
}
}
@@ -1,9 +1,15 @@
"""Image handling utilities for Shopping List Manager.""" """Image handling utilities for Shopping List Manager."""
import logging import logging
import os import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from ..const import (
IMAGES_LOCAL_DIR,
LEGACY_IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -18,11 +24,28 @@ class ImageHandler:
config_path: Path to HA config directory config_path: Path to HA config directory
""" """
self.hass = hass self.hass = hass
# Images stored in /config/www/shopping_list_manager/images/ # Images stored in /config/www/images/shopping_list_manager/
self._local_images_dir = Path(config_path) / "www" / "shopping_list_manager" / "images" self._local_images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR))
self._legacy_images_dir = Path(hass.config.path(LEGACY_IMAGES_LOCAL_DIR))
self._local_images_dir.mkdir(parents=True, exist_ok=True) self._local_images_dir.mkdir(parents=True, exist_ok=True)
self._migrate_legacy_files()
_LOGGER.info("Image directory: %s", self._local_images_dir) _LOGGER.info("Image directory: %s", self._local_images_dir)
def _migrate_legacy_files(self) -> None:
"""Move legacy image files to the new standardized directory."""
if not self._legacy_images_dir.exists() or self._legacy_images_dir == self._local_images_dir:
return
for src in self._legacy_images_dir.glob("*"):
if not src.is_file():
continue
dest = self._local_images_dir / src.name
if dest.exists():
continue
try:
shutil.move(str(src), str(dest))
except Exception as err:
_LOGGER.debug("Could not move legacy image %s: %s", src, err)
def get_image_url(self, product_name: str, external_url: Optional[str] = None) -> str: def get_image_url(self, product_name: str, external_url: Optional[str] = None) -> str:
"""Get image URL for a product. """Get image URL for a product.
@@ -73,11 +96,11 @@ class ImageHandler:
# Check exact match # Check exact match
image_file = self._local_images_dir / f"{normalized_name}{ext}" image_file = self._local_images_dir / f"{normalized_name}{ext}"
if image_file.exists(): if image_file.exists():
return f"/local/shopping_list_manager/images/{normalized_name}{ext}" return f"{LOCAL_IMAGE_URL_PREFIX}{normalized_name}{ext}"
# Check for files starting with the product name # Check for files starting with the product name
for file in self._local_images_dir.glob(f"{normalized_name}*{ext}"): for file in self._local_images_dir.glob(f"{normalized_name}*{ext}"):
return f"/local/shopping_list_manager/images/{file.name}" return f"{LOCAL_IMAGE_URL_PREFIX}{file.name}"
return None return None
@@ -18,6 +18,7 @@ from ..const import (
IMAGE_SIZE, IMAGE_SIZE,
IMAGE_QUALITY, IMAGE_QUALITY,
IMAGES_LOCAL_DIR, IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX,
WS_TYPE_LISTS_GET_ALL, WS_TYPE_LISTS_GET_ALL,
WS_TYPE_LISTS_CREATE, WS_TYPE_LISTS_CREATE,
WS_TYPE_LISTS_UPDATE, WS_TYPE_LISTS_UPDATE,
@@ -38,6 +39,8 @@ from ..const import (
WS_TYPE_PRODUCTS_SUGGESTIONS, WS_TYPE_PRODUCTS_SUGGESTIONS,
WS_TYPE_PRODUCTS_ADD, WS_TYPE_PRODUCTS_ADD,
WS_TYPE_PRODUCTS_UPDATE, WS_TYPE_PRODUCTS_UPDATE,
WS_TYPE_PRODUCTS_DELETE,
WS_TYPE_OFF_FETCH,
WS_TYPE_CATEGORIES_GET_ALL, WS_TYPE_CATEGORIES_GET_ALL,
WS_TYPE_LOYALTY_GET_ALL, WS_TYPE_LOYALTY_GET_ALL,
WS_TYPE_LOYALTY_ADD, WS_TYPE_LOYALTY_ADD,
@@ -471,7 +474,7 @@ def websocket_get_items(
vol.Required("type"): WS_TYPE_ITEMS_ADD, vol.Required("type"): WS_TYPE_ITEMS_ADD,
vol.Required("list_id"): str, vol.Required("list_id"): str,
vol.Required("name"): str, vol.Required("name"): str,
vol.Required("category_id"): str, vol.Optional("category_id", default="other"): str,
vol.Optional("product_id"): str, vol.Optional("product_id"): str,
vol.Optional("quantity", default=1): vol.Coerce(float), vol.Optional("quantity", default=1): vol.Coerce(float),
vol.Optional("unit", default="units"): str, vol.Optional("unit", default="units"): str,
@@ -816,7 +819,8 @@ async def websocket_download_product_image(
try: try:
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
async with session.get(raw_url, timeout=ClientTimeout(total=10)) as resp: headers = {"User-Agent": "Mozilla/5.0 (compatible; HomeAssistant/ShoppingListManager)"}
async with session.get(raw_url, timeout=ClientTimeout(total=15), headers=headers) as resp:
if resp.status != 200: if resp.status != 200:
connection.send_error(msg["id"], "download_failed", f"HTTP {resp.status}") connection.send_error(msg["id"], "download_failed", f"HTTP {resp.status}")
return return
@@ -827,8 +831,14 @@ async def websocket_download_product_image(
try: try:
img = Image.open(io.BytesIO(raw)) img = Image.open(io.BytesIO(raw))
if img.mode not in ("RGB", "RGBA"): # Convert to RGB for reliable lossy WebP encoding
img = img.convert("RGBA") # (RGBA, palette, grayscale modes can fail or produce oversized files)
if img.mode == "RGBA":
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
img.thumbnail((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS) img.thumbnail((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS)
out = io.BytesIO() out = io.BytesIO()
img.save(out, format="WEBP", quality=IMAGE_QUALITY) img.save(out, format="WEBP", quality=IMAGE_QUALITY)
@@ -839,7 +849,7 @@ async def websocket_download_product_image(
connection.send_result( connection.send_result(
msg["id"], msg["id"],
{"local_url": f"/local/shopping_list_manager/images/{filename}"}, {"local_url": f"{LOCAL_IMAGE_URL_PREFIX}{filename}"},
) )
@@ -1049,6 +1059,27 @@ async def websocket_update_product(
) )
@websocket_api.websocket_command(
{
vol.Required("type"): WS_TYPE_PRODUCTS_DELETE,
vol.Required("product_id"): str,
}
)
@websocket_api.async_response
async def websocket_delete_product(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: Dict[str, Any],
) -> None:
"""Handle delete product command."""
storage = get_storage(hass)
deleted = await storage.delete_product(msg["product_id"])
if not deleted:
connection.send_error(msg["id"], "not_found", "Product not found")
return
connection.send_result(msg["id"], {"deleted": True})
# ============================================================================= # =============================================================================
# CATEGORY HANDLERS # CATEGORY HANDLERS
# ============================================================================= # =============================================================================
@@ -1076,6 +1107,64 @@ def websocket_get_categories(
) )
# =============================================================================
# OPENFOODFACTS PROXY HANDLERS
# =============================================================================
@websocket_api.websocket_command(
{
vol.Required("type"): WS_TYPE_OFF_FETCH,
vol.Optional("query"): str,
vol.Optional("barcode"): str,
vol.Optional("page_size", default=5): int,
}
)
@websocket_api.async_response
async def websocket_off_fetch(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: Dict[str, Any],
) -> None:
"""Proxy OpenFoodFacts requests through HA to avoid browser CORS restrictions."""
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from aiohttp import ClientTimeout
session = async_get_clientsession(hass)
headers = {"User-Agent": "HomeAssistant/ShoppingListManager (contact@homeassistant.io)"}
try:
if msg.get("barcode"):
barcode = msg["barcode"]
fields = "product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json?fields={fields}"
async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp:
if not resp.ok:
connection.send_result(msg["id"], {"status": 0})
return
data = await resp.json(content_type=None)
connection.send_result(msg["id"], {
"status": data.get("status", 0),
"product": data.get("product"),
})
else:
query = msg.get("query", "")
page_size = msg.get("page_size", 5)
fields = "product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
url = (
f"https://world.openfoodfacts.org/api/v2/search"
f"?search_terms={query}&fields={fields}&page_size={page_size}"
)
async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp:
if not resp.ok:
connection.send_result(msg["id"], {"products": []})
return
data = await resp.json(content_type=None)
connection.send_result(msg["id"], {"products": data.get("products", [])})
except Exception as err:
_LOGGER.warning("OpenFoodFacts proxy request failed: %s", err)
connection.send_error(msg["id"], "fetch_failed", str(err))
# ============================================================================= # =============================================================================
# INTEGRATION SETTINGS HANDLERS # INTEGRATION SETTINGS HANDLERS
# ============================================================================= # =============================================================================
@@ -1093,10 +1182,12 @@ def websocket_get_integration_settings(
) -> None: ) -> None:
"""Return current country and available country options.""" """Return current country and available country options."""
country = hass.data[DOMAIN].get("country", "NZ") country = hass.data[DOMAIN].get("country", "NZ")
version = hass.data[DOMAIN].get("version", "unknown")
connection.send_result( connection.send_result(
msg["id"], msg["id"],
{ {
"country": country, "country": country,
"version": version,
"available_countries": { "available_countries": {
"NZ": "New Zealand", "NZ": "New Zealand",
"AU": "Australia", "AU": "Australia",