Compare commits

...

5 Commits

4 changed files with 148 additions and 4 deletions
@@ -43,11 +43,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 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,
@@ -618,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)."
}
}
}
}
}
@@ -39,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,
@@ -1057,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
# ============================================================================= # =============================================================================
@@ -1084,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
# ============================================================================= # =============================================================================
@@ -1101,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",