mirror of
https://github.com/thekiwismarthome/shopping-list-manager.git
synced 2026-06-30 21:46:30 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 380bba0408 | |||
| cf3ab90d74 | |||
| 4b7043d075 | |||
| e7306275e4 |
@@ -200,11 +200,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."""
|
||||||
@@ -90,6 +98,10 @@ class ShoppingListStorage:
|
|||||||
}
|
}
|
||||||
_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()
|
||||||
if categories_data:
|
if categories_data:
|
||||||
@@ -420,6 +432,56 @@ class ShoppingListStorage:
|
|||||||
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."""
|
||||||
return list(self._products.values())
|
return list(self._products.values())
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,12 +24,29 @@ 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,
|
||||||
@@ -846,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}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1056,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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1083,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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user