29 Commits

Author SHA1 Message Date
thekiwismarthome c55e90d74f chore: merge v2.6.2---Enhancements-and-Bug-Fixes into main
- Fix OFT search URL encoding for correct multi-word product queries
- Add EAN barcode code field to OFT search and barcode lookup responses
- Add sort_by=unique_scans_n for more relevant OFT search results
2026-08-11 04:11:14 +12:00
thekiwismarthome 408e360973 Merge pull request #21 from thekiwismarthome/main
need to catchup LOL
2026-08-10 16:38:42 +12:00
thekiwismarthome 773c72d8c3 Merge pull request #20 from thekiwismarthome/feature/custom-regions
Merge pull request #19 from thekiwismarthome/main
2026-08-10 16:33:47 +12:00
thekiwismarthome 98eb022573 fix: OFT search URL encoding and barcode field
- URL-encode search query so multi-word product names return correct results
- Add code (EAN barcode) to fields returned by both name search and
  barcode lookup — frontend can now auto-populate barcode on OFT apply
- Add sort_by=unique_scans_n to name search for more relevant ordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-09 22:36:33 +12:00
thekiwismarthome 674a981066 Merge pull request #19 from thekiwismarthome/main
Merge pull request #18 from thekiwismarthome/feature/custom-regions
2026-08-09 22:19:33 +12:00
thekiwismarthome 8ac734f8e4 Merge pull request #18 from thekiwismarthome/feature/custom-regions
Feature/custom regions
2026-08-09 22:16:18 +12:00
thekiwismarthome 3e4f014b11 feat: add es/fr/de/pt/it config flow translations and expose metric/price settings via WebSocket 2026-08-09 21:45:47 +12:00
thekiwismarthome 88cadea42f fix: persist migrated custom regions format on first load 2026-08-09 14:38:36 +12:00
thekiwismarthome 5e2a7e8d35 feat: add currency_symbol and language fields to custom regions 2026-08-09 10:29:26 +12:00
thekiwismarthome 78f857f8f9 feat: add custom regions support 2026-08-09 09:45:33 +12:00
thekiwismarthome 19bf03a5c6 feat: add ha_todo_entity_id field to ShoppingList for global todo sync config 2026-08-08 23:31:27 +12:00
thekiwismarthome 1f86b6f485 Merge pull request #15 from myTselection/config
fix config screen access #7
2026-06-15 12:19:07 +12:00
thekiwismarthome 2d02a68fe3 Merge pull request #16 from myTselection/dutch-translation
Dutch translation
2026-06-15 12:18:43 +12:00
myTselection c971b89779 us belgian openfoodfacts lookup when belgium catalog is used 2026-06-14 23:41:47 +02:00
myTselection 42746c86b8 v2.2.1 2026-06-14 23:28:25 +02:00
myTselection 6a344914a2 allow categories reload for different language 2026-06-14 23:28:10 +02:00
myTselection 8d9dd5bf6a v2.2.0 2026-06-14 22:51:36 +02:00
myTselection c48c96e133 belgium dutch categories and products 2026-06-14 22:51:16 +02:00
myTselection 1dea960449 v2.1.0 2026-06-14 22:46:59 +02:00
myTselection fd5b17ced8 fix startup warning Detected blocking call to open with args ('/config/custom_components/shopping_list_manager/manifest.json',) 2026-06-14 22:43:37 +02:00
myTselection 0468893919 base nl translation 2026-06-14 22:29:04 +02:00
myTselection 55d2f2d31d fix config screen access #7 2026-06-14 20:45:52 +02:00
thekiwismarthome d50bd39210 feat: expose installed version from manifest via get_integration_settings WS 2026-05-19 22:49:56 +12:00
thekiwismarthome 8652996b65 fix: add translations/en.json to resolve 500 on options flow 2026-05-18 20:29:48 +12:00
thekiwismarthome d5c43fe3b5 Merge pull request #12 from thekiwismarthome/v2.5.0---Improvements-and-Tweaks
V2.5.0   improvements and tweaks
2026-05-11 14:39:37 +12:00
thekiwismarthome 380bba0408 fix: proxy OpenFoodFacts API through HA backend to fix browser CORS errors; implement products/delete handler 2026-03-31 19:57:01 +13:00
thekiwismarthome cf3ab90d74 fix: implement products/delete websocket handler — deleted products were silently ignored 2026-03-25 08:56:25 +13:00
thekiwismarthome 4b7043d075 feat: product match review step when adding ingredients to SLM shopping list 2026-03-25 08:34:50 +13:00
thekiwismarthome e7306275e4 refactor: standardize image storage location and migrate existing files and URLs. 2026-03-03 22:59:44 +13:00
18 changed files with 1747 additions and 47 deletions
@@ -1,8 +1,8 @@
"""Shopping List Manager integration for Home Assistant."""
import json
import logging
import os
from pathlib import Path
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
@@ -17,6 +17,14 @@ _LOGGER = logging.getLogger(__name__)
DATA_STORAGE = f"{DOMAIN}_storage"
def _load_manifest_version(component_path: str) -> str:
"""Load the integration version from manifest.json."""
with open(os.path.join(component_path, "manifest.json"), encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
return manifest.get("version", "unknown")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Shopping List Manager component from yaml (not used)."""
# This integration doesn't support YAML configuration
@@ -43,11 +51,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Initialize image handler
image_handler = ImageHandler(hass, config_path)
# Read installed version from manifest
version = await hass.async_add_executor_job(_load_manifest_version, component_path)
# Store instances in hass.data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][DATA_STORAGE] = storage
hass.data[DOMAIN]["image_handler"] = image_handler
hass.data[DOMAIN]["country"] = country
hass.data[DOMAIN]["version"] = version
# Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -200,11 +212,21 @@ async def _async_register_websocket_handlers(
hass,
handlers.websocket_update_product,
)
websocket_api.async_register_command(
hass,
handlers.websocket_delete_product,
)
websocket_api.async_register_command(
hass,
handlers.websocket_get_product_substitutes,
)
# OpenFoodFacts proxy
websocket_api.async_register_command(
hass,
handlers.websocket_off_fetch,
)
# Categories handlers
websocket_api.async_register_command(
hass,
@@ -220,6 +242,14 @@ async def _async_register_websocket_handlers(
hass,
handlers.websocket_set_country,
)
websocket_api.async_register_command(
hass,
handlers.websocket_create_custom_region,
)
websocket_api.async_register_command(
hass,
handlers.websocket_delete_custom_region,
)
# Backup / Restore handlers
websocket_api.async_register_command(
@@ -50,7 +50,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry
self._config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage the options."""
@@ -59,9 +59,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
return self.async_create_entry(title="", data=user_input)
# Get current settings
current_country = self.config_entry.options.get(
current_country = self._config_entry.options.get(
"country",
self.config_entry.data.get("country", "NZ")
self._config_entry.data.get("country", "NZ")
)
return self.async_show_form(
@@ -73,18 +73,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
"US": "United States",
"GB": "United Kingdom",
"CA": "Canada",
"BE": "Belgium (Dutch)",
}),
vol.Optional(
"enable_price_tracking",
default=self.config_entry.options.get("enable_price_tracking", True)
default=self._config_entry.options.get("enable_price_tracking", True)
): bool,
vol.Optional(
"enable_image_search",
default=self.config_entry.options.get("enable_image_search", True)
default=self._config_entry.options.get("enable_image_search", True)
): bool,
vol.Optional(
"metric_units_only",
default=self.config_entry.options.get("metric_units_only", True)
default=self._config_entry.options.get("metric_units_only", True)
): bool,
}),
description_placeholders={
@@ -10,6 +10,7 @@ STORAGE_KEY_ITEMS = f"{DOMAIN}.items"
STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products"
STORAGE_KEY_CATEGORIES = f"{DOMAIN}.categories"
STORAGE_KEY_LOYALTY_CARDS = f"{DOMAIN}.loyalty_cards"
STORAGE_KEY_CUSTOM_REGIONS = f"{DOMAIN}.custom_regions"
# WebSocket Commands - Lists
WS_TYPE_LISTS_GET_ALL = f"{DOMAIN}/lists/get_all"
@@ -76,7 +77,10 @@ IMAGE_FORMAT = "webp"
IMAGE_SIZE = 200 # 200x200px
IMAGE_QUALITY = 85
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 = "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"
@@ -0,0 +1,111 @@
{
"version": "1.0.0",
"region": "BE",
"language": "nl-BE",
"categories": [
{
"id": "produce",
"name": "Groenten & Fruit",
"icon": "mdi:fruit-cherries",
"color": "#4CAF50",
"sort_order": 1,
"system": true
},
{
"id": "dairy",
"name": "Zuivel & Eieren",
"icon": "mdi:cheese",
"color": "#FFC107",
"sort_order": 2,
"system": true
},
{
"id": "meat",
"name": "Vlees, Vis & Gevogelte",
"icon": "mdi:food-steak",
"color": "#F44336",
"sort_order": 3,
"system": true
},
{
"id": "bakery",
"name": "Bakkerij",
"icon": "mdi:bread-slice",
"color": "#FF9800",
"sort_order": 4,
"system": true
},
{
"id": "frozen",
"name": "Diepvries",
"icon": "mdi:snowflake",
"color": "#2196F3",
"sort_order": 5,
"system": true
},
{
"id": "pantry",
"name": "Voorraadkast",
"icon": "mdi:package-variant",
"color": "#795548",
"sort_order": 6,
"system": true
},
{
"id": "beverages",
"name": "Dranken",
"icon": "mdi:cup",
"color": "#00BCD4",
"sort_order": 7,
"system": true
},
{
"id": "snacks",
"name": "Snacks & Koekjes",
"icon": "mdi:food-apple",
"color": "#E91E63",
"sort_order": 8,
"system": true
},
{
"id": "household",
"name": "Huishouden",
"icon": "mdi:spray-bottle",
"color": "#9C27B0",
"sort_order": 9,
"system": true
},
{
"id": "health",
"name": "Verzorging",
"icon": "mdi:heart-pulse",
"color": "#009688",
"sort_order": 10,
"system": true
},
{
"id": "pet",
"name": "Dieren",
"icon": "mdi:paw",
"color": "#FF5722",
"sort_order": 11,
"system": true
},
{
"id": "baby",
"name": "Baby",
"icon": "mdi:baby-face",
"color": "#F48FB1",
"sort_order": 12,
"system": true
},
{
"id": "other",
"name": "Overig",
"icon": "mdi:dots-horizontal",
"color": "#9E9E9E",
"sort_order": 99,
"system": true
}
]
}
@@ -0,0 +1,941 @@
{
"version": "1.0.0",
"region": "BE",
"language": "nl-BE",
"currency": "EUR",
"last_updated": "2026-06-14",
"description": "Belgium Dutch grocery catalog with common Belgian supermarket products and local brands",
"products": [
{
"id": "prod_be_halfvolle_melk",
"name": "Halfvolle melk",
"category_id": "dairy",
"aliases": ["melk", "halfvolle melk", "halfvol"],
"default_unit": "L",
"default_quantity": 1,
"price": 1.15,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "halfvolle_melk",
"tags": ["basis"],
"allergens": ["milk"],
"substitution_group": "milk_group",
"priority_level": 5
},
{
"id": "prod_be_volle_melk",
"name": "Volle melk",
"category_id": "dairy",
"aliases": ["melk", "volle melk", "vollemelk"],
"default_unit": "L",
"default_quantity": 1,
"price": 1.25,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "volle_melk",
"tags": ["basis"],
"allergens": ["milk"],
"substitution_group": "milk_group",
"priority_level": 5
},
{
"id": "prod_be_havermelk",
"name": "Havermelk",
"category_id": "dairy",
"aliases": ["haverdrink", "havermelk", "plantaardige melk"],
"default_unit": "L",
"default_quantity": 1,
"price": 2.29,
"brands": ["Alpro", "Boni", "Oatly"],
"image_hint": "havermelk",
"tags": ["plantaardig"],
"allergens": ["gluten"],
"substitution_group": "milk_group",
"priority_level": 4
},
{
"id": "prod_be_boter",
"name": "Boter",
"category_id": "dairy",
"aliases": ["boter", "hoeveboter", "roomboter"],
"default_unit": "g",
"default_quantity": 250,
"price": 2.85,
"brands": ["Boni", "Balade", "Carlsbourg"],
"image_hint": "boter",
"tags": ["basis"],
"allergens": ["milk"],
"substitution_group": "butter_group",
"priority_level": 4
},
{
"id": "prod_be_eieren",
"name": "Eieren",
"category_id": "dairy",
"aliases": ["eieren", "eitjes", "vrije uitloop eieren"],
"default_unit": "stuks",
"default_quantity": 10,
"price": 3.25,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "eieren",
"tags": ["basis"],
"allergens": ["egg"],
"substitution_group": "eggs_group",
"priority_level": 5
},
{
"id": "prod_be_jonge_kaas",
"name": "Jonge kaas",
"category_id": "dairy",
"aliases": ["kaas", "jonge kaas", "sneetjes kaas"],
"default_unit": "g",
"default_quantity": 300,
"price": 3.79,
"brands": ["Boni", "Maredsous", "Passendale"],
"image_hint": "jonge_kaas",
"tags": ["broodbeleg"],
"allergens": ["milk"],
"substitution_group": "cheese_group",
"priority_level": 4
},
{
"id": "prod_be_yoghurt_natuur",
"name": "Yoghurt natuur",
"category_id": "dairy",
"aliases": ["yoghurt", "natuuryoghurt", "yoghurt natuur"],
"default_unit": "g",
"default_quantity": 500,
"price": 1.89,
"brands": ["Boni", "Danone", "Delhaize"],
"image_hint": "yoghurt_natuur",
"tags": [],
"allergens": ["milk"],
"substitution_group": "yoghurt_group",
"priority_level": 4
},
{
"id": "prod_be_griekse_yoghurt",
"name": "Griekse yoghurt",
"category_id": "dairy",
"aliases": ["griekse yoghurt", "yoghurt grieks"],
"default_unit": "g",
"default_quantity": 500,
"price": 2.65,
"brands": ["Boni", "Oikos", "Delhaize"],
"image_hint": "griekse_yoghurt",
"tags": [],
"allergens": ["milk"],
"substitution_group": "yoghurt_group",
"priority_level": 3
},
{
"id": "prod_be_appels",
"name": "Appels",
"category_id": "produce",
"aliases": ["appels", "jonagold", "elstar"],
"default_unit": "kg",
"default_quantity": 1,
"price": 2.49,
"brands": ["BelOrta", "Boni"],
"image_hint": "appels",
"tags": ["fruit"],
"allergens": [],
"substitution_group": "apple_group",
"priority_level": 5
},
{
"id": "prod_be_bananen",
"name": "Bananen",
"category_id": "produce",
"aliases": ["bananen", "banaan"],
"default_unit": "kg",
"default_quantity": 1,
"price": 1.89,
"brands": ["Chiquita", "Boni", "Delhaize"],
"image_hint": "bananen",
"tags": ["fruit"],
"allergens": [],
"substitution_group": "banana_group",
"priority_level": 5
},
{
"id": "prod_be_peren",
"name": "Peren",
"category_id": "produce",
"aliases": ["peren", "conference peer", "conference peren"],
"default_unit": "kg",
"default_quantity": 1,
"price": 2.59,
"brands": ["BelOrta", "Boni"],
"image_hint": "peren",
"tags": ["fruit"],
"allergens": [],
"substitution_group": "pear_group",
"priority_level": 4
},
{
"id": "prod_be_aardbeien",
"name": "Aardbeien",
"category_id": "produce",
"aliases": ["aardbeien", "belgische aardbeien"],
"default_unit": "g",
"default_quantity": 500,
"price": 4.49,
"brands": ["BelOrta", "Hoogstraten"],
"image_hint": "aardbeien",
"tags": ["fruit", "seizoen"],
"allergens": [],
"substitution_group": "berry_group",
"priority_level": 4
},
{
"id": "prod_be_aardappelen",
"name": "Aardappelen",
"category_id": "produce",
"aliases": ["aardappelen", "patatten", "vastkokende aardappelen"],
"default_unit": "kg",
"default_quantity": 2.5,
"price": 3.25,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "aardappelen",
"tags": ["groenten", "basis"],
"allergens": [],
"substitution_group": "potato_group",
"priority_level": 5
},
{
"id": "prod_be_tomaten",
"name": "Tomaten",
"category_id": "produce",
"aliases": ["tomaten", "trostomaten", "tomaat"],
"default_unit": "g",
"default_quantity": 500,
"price": 2.49,
"brands": ["BelOrta", "Boni", "Delhaize"],
"image_hint": "tomaten",
"tags": ["groenten"],
"allergens": [],
"substitution_group": "tomato_group",
"priority_level": 5
},
{
"id": "prod_be_witloof",
"name": "Witloof",
"category_id": "produce",
"aliases": ["witloof", "witlof", "grondwitloof"],
"default_unit": "g",
"default_quantity": 500,
"price": 2.99,
"brands": ["BelOrta", "Boni"],
"image_hint": "witloof",
"tags": ["groenten", "belgisch"],
"allergens": [],
"substitution_group": "chicory_group",
"priority_level": 4
},
{
"id": "prod_be_preien",
"name": "Prei",
"category_id": "produce",
"aliases": ["prei", "preien"],
"default_unit": "stuks",
"default_quantity": 3,
"price": 1.99,
"brands": ["Boni", "Delhaize"],
"image_hint": "prei",
"tags": ["groenten"],
"allergens": [],
"substitution_group": "leek_group",
"priority_level": 3
},
{
"id": "prod_be_wortelen",
"name": "Wortelen",
"category_id": "produce",
"aliases": ["wortelen", "peen", "wortels"],
"default_unit": "kg",
"default_quantity": 1,
"price": 1.49,
"brands": ["Boni", "Carrefour"],
"image_hint": "wortelen",
"tags": ["groenten"],
"allergens": [],
"substitution_group": "carrot_group",
"priority_level": 4
},
{
"id": "prod_be_uien",
"name": "Uien",
"category_id": "produce",
"aliases": ["uien", "ajuin", "gele uien"],
"default_unit": "kg",
"default_quantity": 1,
"price": 1.39,
"brands": ["Boni", "Delhaize"],
"image_hint": "uien",
"tags": ["groenten", "basis"],
"allergens": [],
"substitution_group": "onion_group",
"priority_level": 5
},
{
"id": "prod_be_sla",
"name": "Sla",
"category_id": "produce",
"aliases": ["sla", "kropsla", "salade"],
"default_unit": "stuks",
"default_quantity": 1,
"price": 1.59,
"brands": ["Boni", "Delhaize"],
"image_hint": "sla",
"tags": ["groenten"],
"allergens": [],
"substitution_group": "lettuce_group",
"priority_level": 3
},
{
"id": "prod_be_brood_wit",
"name": "Wit brood",
"category_id": "bakery",
"aliases": ["wit brood", "brood", "gesneden wit brood"],
"default_unit": "stuks",
"default_quantity": 1,
"price": 2.39,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "wit_brood",
"tags": ["basis"],
"allergens": ["gluten"],
"substitution_group": "bread_group",
"priority_level": 5
},
{
"id": "prod_be_brood_grijs",
"name": "Grijs brood",
"category_id": "bakery",
"aliases": ["grijs brood", "bruin brood", "tarwebrood"],
"default_unit": "stuks",
"default_quantity": 1,
"price": 2.49,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "grijs_brood",
"tags": ["basis"],
"allergens": ["gluten"],
"substitution_group": "bread_group",
"priority_level": 5
},
{
"id": "prod_be_pistolets",
"name": "Pistolets",
"category_id": "bakery",
"aliases": ["pistolets", "broodjes", "witte pistolets"],
"default_unit": "stuks",
"default_quantity": 6,
"price": 2.19,
"brands": ["Delhaize", "Carrefour", "Boni"],
"image_hint": "pistolets",
"tags": ["belgisch"],
"allergens": ["gluten"],
"substitution_group": "rolls_group",
"priority_level": 4
},
{
"id": "prod_be_sandwiches",
"name": "Sandwiches",
"category_id": "bakery",
"aliases": ["sandwiches", "melkbroodjes", "zachte broodjes"],
"default_unit": "stuks",
"default_quantity": 6,
"price": 2.69,
"brands": ["Boni", "Delhaize"],
"image_hint": "sandwiches",
"tags": ["belgisch"],
"allergens": ["gluten", "milk", "egg"],
"substitution_group": "rolls_group",
"priority_level": 3
},
{
"id": "prod_be_luikse_wafels",
"name": "Luikse wafels",
"category_id": "bakery",
"aliases": ["luikse wafels", "suikerwafels", "wafels"],
"default_unit": "stuks",
"default_quantity": 6,
"price": 3.49,
"brands": ["Lotus", "Boni", "Milcamps"],
"image_hint": "luikse_wafels",
"tags": ["belgisch"],
"allergens": ["gluten", "milk", "egg"],
"substitution_group": "waffle_group",
"priority_level": 3
},
{
"id": "prod_be_kipfilet",
"name": "Kipfilet",
"category_id": "meat",
"aliases": ["kipfilet", "kip", "kippenfilet"],
"default_unit": "g",
"default_quantity": 600,
"price": 7.49,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "kipfilet",
"tags": [],
"allergens": [],
"substitution_group": "chicken_group",
"priority_level": 5
},
{
"id": "prod_be_gehakt",
"name": "Gemengd gehakt",
"category_id": "meat",
"aliases": ["gehakt", "gemengd gehakt", "varkens runds gehakt"],
"default_unit": "g",
"default_quantity": 500,
"price": 4.99,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "gehakt",
"tags": [],
"allergens": [],
"substitution_group": "mince_group",
"priority_level": 4
},
{
"id": "prod_be_spekblokjes",
"name": "Spekblokjes",
"category_id": "meat",
"aliases": ["spekblokjes", "spekreepjes", "spek"],
"default_unit": "g",
"default_quantity": 200,
"price": 2.99,
"brands": ["Herta", "Boni", "Delhaize"],
"image_hint": "spekblokjes",
"tags": [],
"allergens": [],
"substitution_group": "bacon_group",
"priority_level": 3
},
{
"id": "prod_be_hesp",
"name": "Hesp",
"category_id": "meat",
"aliases": ["hesp", "ham", "gekookte hesp"],
"default_unit": "g",
"default_quantity": 150,
"price": 2.89,
"brands": ["Boni", "Aoste", "Delhaize"],
"image_hint": "hesp",
"tags": ["broodbeleg"],
"allergens": [],
"substitution_group": "ham_group",
"priority_level": 4
},
{
"id": "prod_be_zalmfilet",
"name": "Zalmfilet",
"category_id": "meat",
"aliases": ["zalm", "zalmfilet", "verse zalm"],
"default_unit": "g",
"default_quantity": 250,
"price": 6.99,
"brands": ["Delhaize", "Carrefour", "Boni"],
"image_hint": "zalmfilet",
"tags": ["vis"],
"allergens": ["fish"],
"substitution_group": "salmon_group",
"priority_level": 3
},
{
"id": "prod_be_frietjes_dieprvries",
"name": "Diepvriesfrieten",
"category_id": "frozen",
"aliases": ["diepvriesfrieten", "frieten", "frietjes", "frites"],
"default_unit": "kg",
"default_quantity": 1,
"price": 2.39,
"brands": ["Boni", "Lutosa", "McCain"],
"image_hint": "diepvriesfrieten",
"tags": ["belgisch"],
"allergens": [],
"substitution_group": "fries_group",
"priority_level": 5
},
{
"id": "prod_be_groentenmix_dieprvries",
"name": "Diepvriesgroenten",
"category_id": "frozen",
"aliases": ["diepvriesgroenten", "groentenmix", "diepvries groentemix"],
"default_unit": "g",
"default_quantity": 600,
"price": 2.49,
"brands": ["Boni", "Delhaize", "Carrefour"],
"image_hint": "diepvriesgroenten",
"tags": ["groenten"],
"allergens": [],
"substitution_group": "frozen_vegetables_group",
"priority_level": 4
},
{
"id": "prod_be_vanille_ijs",
"name": "Vanille-ijs",
"category_id": "frozen",
"aliases": ["vanille ijs", "vanille-ijs", "roomijs"],
"default_unit": "L",
"default_quantity": 1,
"price": 3.49,
"brands": ["Boni", "Ijsboerke", "Delhaize"],
"image_hint": "vanille_ijs",
"tags": [],
"allergens": ["milk", "egg"],
"substitution_group": "ice_cream_group",
"priority_level": 2
},
{
"id": "prod_be_pasta",
"name": "Pasta",
"category_id": "pantry",
"aliases": ["pasta", "spaghetti", "penne"],
"default_unit": "g",
"default_quantity": 500,
"price": 1.39,
"brands": ["Boni", "Barilla", "Delhaize"],
"image_hint": "pasta",
"tags": ["basis"],
"allergens": ["gluten"],
"substitution_group": "pasta_group",
"priority_level": 5
},
{
"id": "prod_be_rijst",
"name": "Rijst",
"category_id": "pantry",
"aliases": ["rijst", "basmatirijst", "lange korrel rijst"],
"default_unit": "kg",
"default_quantity": 1,
"price": 2.19,
"brands": ["Boni", "Uncle Ben's", "Delhaize"],
"image_hint": "rijst",
"tags": ["basis"],
"allergens": [],
"substitution_group": "rice_group",
"priority_level": 5
},
{
"id": "prod_be_bloem",
"name": "Bloem",
"category_id": "pantry",
"aliases": ["bloem", "tarwebloem", "zelfrijzende bloem"],
"default_unit": "kg",
"default_quantity": 1,
"price": 0.99,
"brands": ["Boni", "Anco", "Delhaize"],
"image_hint": "bloem",
"tags": ["bakken", "basis"],
"allergens": ["gluten"],
"substitution_group": "flour_group",
"priority_level": 4
},
{
"id": "prod_be_suiker",
"name": "Suiker",
"category_id": "pantry",
"aliases": ["suiker", "kristalsuiker", "witte suiker"],
"default_unit": "kg",
"default_quantity": 1,
"price": 1.29,
"brands": ["Tienen-Tirlemont", "Boni", "Delhaize"],
"image_hint": "suiker",
"tags": ["bakken", "basis"],
"allergens": [],
"substitution_group": "sugar_group",
"priority_level": 4
},
{
"id": "prod_be_koffie",
"name": "Koffie",
"category_id": "pantry",
"aliases": ["koffie", "gemalen koffie", "koffiebonen"],
"default_unit": "g",
"default_quantity": 500,
"price": 5.49,
"brands": ["Douwe Egberts", "Rombouts", "Boni"],
"image_hint": "koffie",
"tags": ["ontbijt"],
"allergens": [],
"substitution_group": "coffee_group",
"priority_level": 5
},
{
"id": "prod_be_choco",
"name": "Choco",
"category_id": "pantry",
"aliases": ["choco", "chocopasta", "hazelnootpasta"],
"default_unit": "g",
"default_quantity": 400,
"price": 2.99,
"brands": ["Nutella", "Boni", "Kwatta"],
"image_hint": "choco",
"tags": ["broodbeleg"],
"allergens": ["milk", "nuts"],
"substitution_group": "spread_group",
"priority_level": 4
},
{
"id": "prod_be_confituur",
"name": "Confituur",
"category_id": "pantry",
"aliases": ["confituur", "jam", "aardbeienconfituur"],
"default_unit": "g",
"default_quantity": 450,
"price": 2.39,
"brands": ["Materne", "Boni", "Delhaize"],
"image_hint": "confituur",
"tags": ["broodbeleg"],
"allergens": [],
"substitution_group": "jam_group",
"priority_level": 3
},
{
"id": "prod_be_mayonaise",
"name": "Mayonaise",
"category_id": "pantry",
"aliases": ["mayonaise", "mayo", "fritessaus"],
"default_unit": "ml",
"default_quantity": 500,
"price": 2.89,
"brands": ["Devos Lemmens", "Boni", "Delhaize"],
"image_hint": "mayonaise",
"tags": ["belgisch"],
"allergens": ["egg", "mustard"],
"substitution_group": "mayonnaise_group",
"priority_level": 5
},
{
"id": "prod_be_pickles",
"name": "Pickles",
"category_id": "pantry",
"aliases": ["pickles", "belgische pickles", "piccalilly"],
"default_unit": "g",
"default_quantity": 350,
"price": 2.49,
"brands": ["Devos Lemmens", "Bister", "Boni"],
"image_hint": "pickles",
"tags": ["belgisch"],
"allergens": ["mustard"],
"substitution_group": "sauce_group",
"priority_level": 3
},
{
"id": "prod_be_olijfolie",
"name": "Olijfolie",
"category_id": "pantry",
"aliases": ["olijfolie", "extra vierge olijfolie"],
"default_unit": "ml",
"default_quantity": 750,
"price": 6.49,
"brands": ["Boni", "Bertolli", "Delhaize"],
"image_hint": "olijfolie",
"tags": ["basis"],
"allergens": [],
"substitution_group": "oil_group",
"priority_level": 4
},
{
"id": "prod_be_zout",
"name": "Zout",
"category_id": "pantry",
"aliases": ["zout", "keukenzout", "zeezout"],
"default_unit": "g",
"default_quantity": 500,
"price": 0.79,
"brands": ["Boni", "Jozo", "Delhaize"],
"image_hint": "zout",
"tags": ["basis"],
"allergens": [],
"substitution_group": "salt_group",
"priority_level": 3
},
{
"id": "prod_be_peper",
"name": "Peper",
"category_id": "pantry",
"aliases": ["peper", "zwarte peper", "pepermolen"],
"default_unit": "g",
"default_quantity": 50,
"price": 2.29,
"brands": ["Boni", "Ducros", "Delhaize"],
"image_hint": "peper",
"tags": ["basis"],
"allergens": [],
"substitution_group": "spice_group",
"priority_level": 3
},
{
"id": "prod_be_spa_bruis",
"name": "Bruiswater",
"category_id": "beverages",
"aliases": ["bruiswater", "spuitwater", "water bruis"],
"default_unit": "L",
"default_quantity": 1.5,
"price": 0.89,
"brands": ["Spa", "Chaudfontaine", "Boni"],
"image_hint": "bruiswater",
"tags": [],
"allergens": [],
"substitution_group": "water_group",
"priority_level": 5
},
{
"id": "prod_be_plat_water",
"name": "Plat water",
"category_id": "beverages",
"aliases": ["plat water", "mineraalwater", "water"],
"default_unit": "L",
"default_quantity": 1.5,
"price": 0.75,
"brands": ["Spa", "Chaudfontaine", "Boni"],
"image_hint": "plat_water",
"tags": [],
"allergens": [],
"substitution_group": "water_group",
"priority_level": 5
},
{
"id": "prod_be_sinaasappelsap",
"name": "Sinaasappelsap",
"category_id": "beverages",
"aliases": ["sinaasappelsap", "appelsiensap", "fruitsap"],
"default_unit": "L",
"default_quantity": 1,
"price": 2.19,
"brands": ["Minute Maid", "Boni", "Delhaize"],
"image_hint": "sinaasappelsap",
"tags": [],
"allergens": [],
"substitution_group": "juice_group",
"priority_level": 3
},
{
"id": "prod_be_pils",
"name": "Pils",
"category_id": "beverages",
"aliases": ["pils", "bier", "pintjes"],
"default_unit": "stuks",
"default_quantity": 6,
"price": 5.99,
"brands": ["Jupiler", "Stella Artois", "Maes"],
"image_hint": "pils",
"tags": ["belgisch", "alcohol"],
"allergens": ["gluten"],
"substitution_group": "beer_group",
"priority_level": 3
},
{
"id": "prod_be_coladrank",
"name": "Cola",
"category_id": "beverages",
"aliases": ["cola", "coca cola", "frisdrank"],
"default_unit": "L",
"default_quantity": 1.5,
"price": 2.19,
"brands": ["Coca-Cola", "Pepsi", "Boni"],
"image_hint": "cola",
"tags": [],
"allergens": [],
"substitution_group": "soft_drink_group",
"priority_level": 3
},
{
"id": "prod_be_speculoos",
"name": "Speculoos",
"category_id": "snacks",
"aliases": ["speculoos", "speculaas", "lotus koekjes"],
"default_unit": "g",
"default_quantity": 250,
"price": 2.29,
"brands": ["Lotus", "Boni", "Delhaize"],
"image_hint": "speculoos",
"tags": ["belgisch"],
"allergens": ["gluten"],
"substitution_group": "biscuit_group",
"priority_level": 4
},
{
"id": "prod_be_chocolade",
"name": "Chocolade",
"category_id": "snacks",
"aliases": ["chocolade", "tablet chocolade", "melkchocolade"],
"default_unit": "g",
"default_quantity": 200,
"price": 2.49,
"brands": ["Cote d'Or", "Jacques", "Boni"],
"image_hint": "chocolade",
"tags": ["belgisch"],
"allergens": ["milk", "soy", "nuts"],
"substitution_group": "chocolate_group",
"priority_level": 4
},
{
"id": "prod_be_chips_paprika",
"name": "Paprikachips",
"category_id": "snacks",
"aliases": ["paprikachips", "chips paprika", "chips"],
"default_unit": "g",
"default_quantity": 200,
"price": 1.79,
"brands": ["Lay's", "Boni", "Croky"],
"image_hint": "paprikachips",
"tags": [],
"allergens": [],
"substitution_group": "chips_group",
"priority_level": 3
},
{
"id": "prod_be_wasmiddel",
"name": "Wasmiddel",
"category_id": "household",
"aliases": ["wasmiddel", "vloeibaar wasmiddel", "waspoeder"],
"default_unit": "L",
"default_quantity": 1.5,
"price": 8.49,
"brands": ["Dash", "Ariel", "Boni"],
"image_hint": "wasmiddel",
"tags": [],
"allergens": [],
"substitution_group": "laundry_group",
"priority_level": 3
},
{
"id": "prod_be_afwasmiddel",
"name": "Afwasmiddel",
"category_id": "household",
"aliases": ["afwasmiddel", "dreft", "vaatwasmiddel handwas"],
"default_unit": "ml",
"default_quantity": 500,
"price": 1.99,
"brands": ["Dreft", "Boni", "Delhaize"],
"image_hint": "afwasmiddel",
"tags": [],
"allergens": [],
"substitution_group": "dish_soap_group",
"priority_level": 4
},
{
"id": "prod_be_wc_papier",
"name": "Toiletpapier",
"category_id": "household",
"aliases": ["toiletpapier", "wc papier", "wc-papier"],
"default_unit": "rollen",
"default_quantity": 12,
"price": 4.99,
"brands": ["Page", "Boni", "Delhaize"],
"image_hint": "toiletpapier",
"tags": ["basis"],
"allergens": [],
"substitution_group": "toilet_paper_group",
"priority_level": 5
},
{
"id": "prod_be_keukenpapier",
"name": "Keukenpapier",
"category_id": "household",
"aliases": ["keukenpapier", "keukenrol", "huishoudpapier"],
"default_unit": "rollen",
"default_quantity": 2,
"price": 2.29,
"brands": ["Boni", "Page", "Delhaize"],
"image_hint": "keukenpapier",
"tags": [],
"allergens": [],
"substitution_group": "paper_towel_group",
"priority_level": 3
},
{
"id": "prod_be_tandpasta",
"name": "Tandpasta",
"category_id": "health",
"aliases": ["tandpasta", "fluoride tandpasta"],
"default_unit": "ml",
"default_quantity": 75,
"price": 2.49,
"brands": ["Colgate", "Signal", "Oral-B"],
"image_hint": "tandpasta",
"tags": [],
"allergens": [],
"substitution_group": "toothpaste_group",
"priority_level": 3
},
{
"id": "prod_be_shampoo",
"name": "Shampoo",
"category_id": "health",
"aliases": ["shampoo", "haarshampoo"],
"default_unit": "ml",
"default_quantity": 300,
"price": 3.99,
"brands": ["Nivea", "Dove", "Garnier"],
"image_hint": "shampoo",
"tags": [],
"allergens": [],
"substitution_group": "shampoo_group",
"priority_level": 2
},
{
"id": "prod_be_luiers",
"name": "Luiers",
"category_id": "baby",
"aliases": ["luiers", "pamper", "pampers"],
"default_unit": "stuks",
"default_quantity": 36,
"price": 9.99,
"brands": ["Pampers", "Boni", "Delhaize"],
"image_hint": "luiers",
"tags": [],
"allergens": [],
"substitution_group": "diaper_group",
"priority_level": 4
},
{
"id": "prod_be_babydoekjes",
"name": "Babydoekjes",
"category_id": "baby",
"aliases": ["babydoekjes", "vochtige doekjes", "billendoekjes"],
"default_unit": "stuks",
"default_quantity": 64,
"price": 1.99,
"brands": ["Pampers", "Boni", "Zwitsal"],
"image_hint": "babydoekjes",
"tags": [],
"allergens": [],
"substitution_group": "baby_wipes_group",
"priority_level": 3
},
{
"id": "prod_be_kattenvoer",
"name": "Kattenvoer",
"category_id": "pet",
"aliases": ["kattenvoer", "kattenbrokken", "kat voeding"],
"default_unit": "kg",
"default_quantity": 1.5,
"price": 5.99,
"brands": ["Whiskas", "Felix", "Boni"],
"image_hint": "kattenvoer",
"tags": [],
"allergens": [],
"substitution_group": "cat_food_group",
"priority_level": 3
},
{
"id": "prod_be_hondenvoer",
"name": "Hondenvoer",
"category_id": "pet",
"aliases": ["hondenvoer", "hondenbrokken", "hond voeding"],
"default_unit": "kg",
"default_quantity": 3,
"price": 8.49,
"brands": ["Pedigree", "Cesar", "Boni"],
"image_hint": "hondenvoer",
"tags": [],
"allergens": [],
"substitution_group": "dog_food_group",
"priority_level": 3
}
],
"country": "Belgium"
}
@@ -1,7 +1,7 @@
{
"domain": "shopping_list_manager",
"name": "Shopping List Manager",
"version": "2.0.0",
"version": "2.2.2",
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
"requirements": [
@@ -132,6 +132,7 @@ class ShoppingList:
# Ownership: None = visible to all users; set = private to owner + allowed_users
owner_id: Optional[str] = None
allowed_users: List[str] = field(default_factory=list)
ha_todo_entity_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -2,7 +2,9 @@
import json
import logging
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Any
from .utils.search import ProductSearch
from homeassistant.core import HomeAssistant
@@ -15,6 +17,11 @@ from .const import (
STORAGE_KEY_PRODUCTS,
STORAGE_KEY_CATEGORIES,
STORAGE_KEY_LOYALTY_CARDS,
STORAGE_KEY_CUSTOM_REGIONS,
IMAGES_LOCAL_DIR,
LEGACY_IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX,
LEGACY_IMAGE_URL_PREFIX,
)
from .data.catalog_loader import load_product_catalog
from .models import ShoppingList, Item, Product, Category, LoyaltyCard, generate_id
@@ -42,13 +49,17 @@ class ShoppingListStorage:
self._store_products = Store(hass, STORAGE_VERSION, STORAGE_KEY_PRODUCTS)
self._store_categories = Store(hass, STORAGE_VERSION, STORAGE_KEY_CATEGORIES)
self._store_loyalty_cards = Store(hass, STORAGE_VERSION, STORAGE_KEY_LOYALTY_CARDS)
self._store_custom_regions = Store(hass, STORAGE_VERSION, STORAGE_KEY_CUSTOM_REGIONS)
self._lists: Dict[str, ShoppingList] = {}
self._items: Dict[str, List[Item]] = {}
self._products: Dict[str, Product] = {}
self._categories: List[Category] = []
self._loyalty_cards: Dict[str, LoyaltyCard] = {}
self._custom_regions: Dict[str, Any] = {} # code -> {name, currency_symbol, language}
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:
"""Load data from storage."""
@@ -90,21 +101,12 @@ class ShoppingListStorage:
}
_LOGGER.debug("Loaded %d products", len(self._products))
# Load categories
categories_data = await self._store_categories.async_load()
if categories_data:
self._categories = [Category(**cat_data) for cat_data in categories_data]
_LOGGER.debug("Loaded %d categories", len(self._categories))
else:
# Initialize with default categories from JSON file
default_categories = await load_categories(self._component_path, self._country) # Use self._country
self._categories = [Category(**cat) for cat in default_categories]
await self._save_categories()
_LOGGER.info(
"Initialized %d default categories for country: %s",
len(self._categories),
self._country # Use self._country
)
# 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 country-specific system categories so labels follow the selected country.
await self._load_categories_for_country(self._country)
# Load product catalog if products are empty
if not self._products:
@@ -156,6 +158,21 @@ class ShoppingListStorage:
}
_LOGGER.debug("Loaded %d loyalty cards", len(self._loyalty_cards))
# Load custom regions (migrate old string-only format)
custom_regions_data = await self._store_custom_regions.async_load()
if custom_regions_data:
raw = custom_regions_data.get("regions", {})
needs_migration = any(isinstance(v, str) for v in raw.values())
self._custom_regions = {
k: (v if isinstance(v, dict) else {"name": v, "currency_symbol": None, "language": None})
for k, v in raw.items()
}
if needs_migration:
await self._save_custom_regions()
_LOGGER.info("Migrated %d custom regions to new dict format", len(self._custom_regions))
else:
_LOGGER.debug("Loaded %d custom regions", len(self._custom_regions))
# Initialize search engine after products are loaded
if self._products:
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
@@ -420,6 +437,56 @@ class ShoppingListStorage:
data = {product_id: product.to_dict() for product_id, product in self._products.items()}
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]:
"""Get all products."""
return list(self._products.values())
@@ -519,6 +586,8 @@ class ShoppingListStorage:
del self._products[pid]
self._country = country_code
await self._load_categories_for_country(country_code)
catalog_products = await load_product_catalog(self._component_path, country_code)
count = 0
for prod_data in catalog_products:
@@ -556,6 +625,18 @@ class ShoppingListStorage:
_LOGGER.info("Reloaded catalog for %s: %d products imported", country_code, 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]:
"""Update a product."""
if product_id not in self._products:
@@ -664,6 +745,17 @@ class ShoppingListStorage:
_LOGGER.warning("Failed to write config backup: %s", err)
# Categories methods
async def _load_categories_for_country(self, country_code: str) -> None:
"""Load and persist system categories for the selected country."""
categories = await load_categories(self._component_path, country_code)
self._categories = [Category(**cat_data) for cat_data in categories]
await self._save_categories()
_LOGGER.info(
"Loaded %d categories for country: %s",
len(self._categories),
country_code,
)
async def _save_categories(self) -> None:
"""Save categories to storage."""
data = [cat.to_dict() for cat in self._categories]
@@ -750,3 +842,39 @@ class ShoppingListStorage:
await self._save_loyalty_cards()
_LOGGER.debug("Updated members for loyalty card: %s", card_id)
return card
# ==========================================================================
# Custom Regions
# ==========================================================================
def get_custom_regions(self) -> Dict[str, Any]:
"""Return all custom regions as {code: {name, currency_symbol, language}}."""
return dict(self._custom_regions)
async def create_custom_region(
self, code: str, name: str, currency_symbol: Optional[str] = None, language: Optional[str] = None
) -> bool:
"""Create a new custom region. Returns False if the code already exists."""
if code in self._custom_regions:
return False
self._custom_regions[code] = {
"name": name,
"currency_symbol": currency_symbol,
"language": language,
}
await self._save_custom_regions()
_LOGGER.debug("Created custom region: %s (%s)", code, name)
return True
async def delete_custom_region(self, code: str) -> bool:
"""Delete a custom region. Returns False if not found."""
if code not in self._custom_regions:
return False
del self._custom_regions[code]
await self._save_custom_regions()
_LOGGER.debug("Deleted custom region: %s", code)
return True
async def _save_custom_regions(self) -> None:
"""Persist custom regions to storage."""
await self._store_custom_regions.async_save({"regions": self._custom_regions})
@@ -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)."
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Richte die Shopping List Manager-Integration ein. Land und andere Einstellungen können nach der Einrichtung über die Schaltfläche Konfigurieren angepasst werden."
}
},
"abort": {
"single_instance_allowed": "Es ist nur eine Instanz von Shopping List Manager erlaubt."
}
},
"options": {
"step": {
"init": {
"title": "Shopping List Manager Optionen",
"description": "Das Ändern des Landes lädt den Produktkatalog beim nächsten Neustart neu.",
"data": {
"country": "Land",
"enable_price_tracking": "Preisverfolgung aktivieren",
"enable_image_search": "Bildsuche aktivieren",
"metric_units_only": "Nur metrische Einheiten"
},
"data_description": {
"country": "Wird verwendet, um den Produktkatalog und die Preise zu lokalisieren.",
"enable_price_tracking": "Produktpreise verfolgen und anzeigen.",
"enable_image_search": "Automatisch nach Produktbildern suchen.",
"metric_units_only": "Nur metrische Einheiten anzeigen (g, kg, ml, L)."
}
}
}
}
}
@@ -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)."
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Configura la integración de Shopping List Manager. El país y otros ajustes se pueden configurar después de la instalación mediante el botón Configurar."
}
},
"abort": {
"single_instance_allowed": "Solo se permite una instancia de Shopping List Manager."
}
},
"options": {
"step": {
"init": {
"title": "Opciones de Shopping List Manager",
"description": "Cambiar el país recargará el catálogo de productos en el próximo reinicio.",
"data": {
"country": "País",
"enable_price_tracking": "Activar seguimiento de precios",
"enable_image_search": "Activar búsqueda de imágenes",
"metric_units_only": "Solo unidades métricas"
},
"data_description": {
"country": "Se utiliza para localizar el catálogo de productos y los precios.",
"enable_price_tracking": "Realizar un seguimiento y mostrar los precios de los productos.",
"enable_image_search": "Buscar imágenes de productos automáticamente.",
"metric_units_only": "Mostrar solo unidades métricas (g, kg, ml, L)."
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Configurez l'intégration Shopping List Manager. Le pays et les autres paramètres peuvent être configurés après l'installation via le bouton Configurer."
}
},
"abort": {
"single_instance_allowed": "Une seule instance de Shopping List Manager est autorisée."
}
},
"options": {
"step": {
"init": {
"title": "Options de Shopping List Manager",
"description": "Modifier le pays rechargera le catalogue de produits au prochain redémarrage.",
"data": {
"country": "Pays",
"enable_price_tracking": "Activer le suivi des prix",
"enable_image_search": "Activer la recherche d'images",
"metric_units_only": "Unités métriques uniquement"
},
"data_description": {
"country": "Utilisé pour localiser le catalogue de produits et les prix.",
"enable_price_tracking": "Suivre et afficher les prix des produits.",
"enable_image_search": "Rechercher automatiquement des images de produits.",
"metric_units_only": "Afficher uniquement les unités métriques (g, kg, ml, L)."
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Configura l'integrazione di Shopping List Manager. Il paese e le altre impostazioni possono essere configurati dopo l'installazione tramite il pulsante Configura."
}
},
"abort": {
"single_instance_allowed": "È consentita solo un'istanza di Shopping List Manager."
}
},
"options": {
"step": {
"init": {
"title": "Opzioni di Shopping List Manager",
"description": "La modifica del paese ricaricherà il catalogo dei prodotti al prossimo riavvio.",
"data": {
"country": "Paese",
"enable_price_tracking": "Attiva il monitoraggio dei prezzi",
"enable_image_search": "Attiva la ricerca di immagini",
"metric_units_only": "Solo unità metriche"
},
"data_description": {
"country": "Utilizzato per localizzare il catalogo dei prodotti e i prezzi.",
"enable_price_tracking": "Tracciare e visualizzare i prezzi dei prodotti.",
"enable_image_search": "Cercare automaticamente le immagini dei prodotti.",
"metric_units_only": "Visualizzare solo unità metriche (g, kg, ml, L)."
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Stel de Shopping List Manager-integratie in. Land en andere instellingen kunnen na de setup worden geconfigureerd via de knop Configureren."
}
},
"abort": {
"single_instance_allowed": "Slechts één exemplaar van Shopping List Manager is toegestaan."
}
},
"options": {
"step": {
"init": {
"title": "Shopping List Manager Opties",
"description": "Het wijzigen van land zal de productcatalogus bij de volgende herstart herladen.",
"data": {
"country": "Land",
"enable_price_tracking": "Prijstracking inschakelen",
"enable_image_search": "Afbeeldingen zoeken inschakelen",
"metric_units_only": "Alleen metrische eenheden"
},
"data_description": {
"country": "Wordt gebruikt om de productcatalogus en prijzen te lokaliseren.",
"enable_price_tracking": "Productprijzen bijhouden en weergeven.",
"enable_image_search": "Automatisch naar productafbeeldingen zoeken.",
"metric_units_only": "Alleen metrische eenheden weergeven (g, kg, ml, L)."
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"title": "Shopping List Manager",
"description": "Configure a integração do Shopping List Manager. O país e outras configurações podem ser ajustados após a instalação através do botão Configurar."
}
},
"abort": {
"single_instance_allowed": "Apenas uma instância do Shopping List Manager é permitida."
}
},
"options": {
"step": {
"init": {
"title": "Opções do Shopping List Manager",
"description": "Alterar o país recarregará o catálogo de produtos no próximo reinício.",
"data": {
"country": "País",
"enable_price_tracking": "Ativar rastreamento de preços",
"enable_image_search": "Ativar pesquisa de imagens",
"metric_units_only": "Apenas unidades métricas"
},
"data_description": {
"country": "Usado para localizar o catálogo de produtos e os preços.",
"enable_price_tracking": "Rastrear e exibir os preços dos produtos.",
"enable_image_search": "Pesquisar imagens de produtos automaticamente.",
"metric_units_only": "Exibir apenas unidades métricas (g, kg, ml, L)."
}
}
}
}
}
@@ -1,9 +1,15 @@
"""Image handling utilities for Shopping List Manager."""
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
from ..const import (
IMAGES_LOCAL_DIR,
LEGACY_IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX,
)
_LOGGER = logging.getLogger(__name__)
@@ -18,12 +24,29 @@ class ImageHandler:
config_path: Path to HA config directory
"""
self.hass = hass
# Images stored in /config/www/shopping_list_manager/images/
self._local_images_dir = Path(config_path) / "www" / "shopping_list_manager" / "images"
# Images stored in /config/www/images/shopping_list_manager/
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._migrate_legacy_files()
_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:
"""Get image URL for a product.
@@ -73,11 +96,11 @@ class ImageHandler:
# Check exact match
image_file = self._local_images_dir / f"{normalized_name}{ext}"
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
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
@@ -4,6 +4,7 @@ import logging
import re
from pathlib import Path
from typing import Any, Dict
from urllib.parse import quote_plus
import voluptuous as vol
from aiohttp import ClientTimeout
@@ -18,6 +19,7 @@ from ..const import (
IMAGE_SIZE,
IMAGE_QUALITY,
IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX,
WS_TYPE_LISTS_GET_ALL,
WS_TYPE_LISTS_CREATE,
WS_TYPE_LISTS_UPDATE,
@@ -38,6 +40,8 @@ from ..const import (
WS_TYPE_PRODUCTS_SUGGESTIONS,
WS_TYPE_PRODUCTS_ADD,
WS_TYPE_PRODUCTS_UPDATE,
WS_TYPE_PRODUCTS_DELETE,
WS_TYPE_OFF_FETCH,
WS_TYPE_CATEGORIES_GET_ALL,
WS_TYPE_LOYALTY_GET_ALL,
WS_TYPE_LOYALTY_ADD,
@@ -56,11 +60,35 @@ from .. import get_storage
_LOGGER = logging.getLogger(__name__)
OPENFOODFACTS_DEFAULT_BASE_URL = "https://world.openfoodfacts.org"
OPENFOODFACTS_BASE_URL_BY_COUNTRY = {
"BE": "https://be.openfoodfacts.org",
}
OPENFOODFACTS_ACCEPT_LANGUAGE_BY_COUNTRY = {
"BE": "nl-BE,nl;q=0.9,en;q=0.7",
}
# =============================================================================
# ACCESS-CHECK HELPERS
# =============================================================================
def _get_openfoodfacts_request_config(hass: HomeAssistant) -> tuple[str, Dict[str, str]]:
"""Return the OpenFoodFacts base URL and headers for the active catalog."""
country = hass.data.get(DOMAIN, {}).get("country", "NZ")
base_url = OPENFOODFACTS_BASE_URL_BY_COUNTRY.get(
country,
OPENFOODFACTS_DEFAULT_BASE_URL,
)
headers = {
"User-Agent": "HomeAssistant/ShoppingListManager (contact@homeassistant.io)",
}
if accept_language := OPENFOODFACTS_ACCEPT_LANGUAGE_BY_COUNTRY.get(country):
headers["Accept-Language"] = accept_language
return base_url, headers
def _user_can_access_list(lst, user) -> bool:
"""Return True if the user may read or write to this list.
@@ -314,6 +342,7 @@ async def websocket_create_list(
vol.Optional("name"): str,
vol.Optional("icon"): str,
vol.Optional("category_order"): [str],
vol.Optional("ha_todo_entity_id"): vol.Any(str, None),
}
)
@websocket_api.async_response
@@ -337,6 +366,8 @@ async def websocket_update_list(
update_data["icon"] = msg["icon"]
if "category_order" in msg:
update_data["category_order"] = msg["category_order"]
if "ha_todo_entity_id" in msg:
update_data["ha_todo_entity_id"] = msg["ha_todo_entity_id"]
updated_list = await storage.update_list(list_id, **update_data)
@@ -471,7 +502,7 @@ def websocket_get_items(
vol.Required("type"): WS_TYPE_ITEMS_ADD,
vol.Required("list_id"): str,
vol.Required("name"): str,
vol.Required("category_id"): str,
vol.Optional("category_id", default="other"): str,
vol.Optional("product_id"): str,
vol.Optional("quantity", default=1): vol.Coerce(float),
vol.Optional("unit", default="units"): str,
@@ -846,7 +877,7 @@ async def websocket_download_product_image(
connection.send_result(
msg["id"],
{"local_url": f"/local/shopping_list_manager/images/{filename}"},
{"local_url": f"{LOCAL_IMAGE_URL_PREFIX}{filename}"},
)
@@ -1056,6 +1087,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
# =============================================================================
@@ -1083,6 +1135,62 @@ 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."""
session = async_get_clientsession(hass)
base_url, headers = _get_openfoodfacts_request_config(hass)
try:
if msg.get("barcode"):
barcode = msg["barcode"]
fields = "code,product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
url = f"{base_url}/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 = "code,product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
url = (
f"{base_url}/api/v2/search"
f"?search_terms={quote_plus(query)}&fields={fields}"
f"&page_size={page_size}&sort_by=unique_scans_n"
)
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
# =============================================================================
@@ -1100,27 +1208,39 @@ def websocket_get_integration_settings(
) -> None:
"""Return current country and available country options."""
country = hass.data[DOMAIN].get("country", "NZ")
connection.send_result(
msg["id"],
{
"country": country,
"available_countries": {
version = hass.data[DOMAIN].get("version", "unknown")
storage = get_storage(hass)
built_in = {
"NZ": "New Zealand",
"AU": "Australia",
"US": "United States",
"GB": "United Kingdom",
"CA": "Canada",
},
"BE": "Belgium (Dutch)",
}
custom = storage.get_custom_regions()
custom_names = {code: region["name"] for code, region in custom.items()}
entries = hass.config_entries.async_entries(DOMAIN)
options = entries[0].options if entries else {}
connection.send_result(
msg["id"],
{
"country": country,
"version": version,
"available_countries": {**built_in, **custom_names},
"custom_regions": custom,
"metric_units_only": options.get("metric_units_only", True),
"enable_price_tracking": options.get("enable_price_tracking", True),
}
)
_VALID_COUNTRIES = ["NZ", "AU", "US", "GB", "CA"]
_BUILT_IN_COUNTRIES = ["NZ", "AU", "US", "GB", "CA", "BE"]
@websocket_api.websocket_command(
{
vol.Required("type"): "shopping_list_manager/set_country",
vol.Required("country"): vol.In(_VALID_COUNTRIES),
vol.Required("country"): str,
}
)
@websocket_api.async_response
@@ -1133,6 +1253,11 @@ async def websocket_set_country(
country = msg["country"].upper()
storage = get_storage(hass)
custom_regions = storage.get_custom_regions()
if country not in _BUILT_IN_COUNTRIES and country not in custom_regions:
connection.send_error(msg["id"], "invalid_country", f"Unknown region: {country}")
return
count = await storage.reload_catalog(country)
# Persist to HA config entry so country survives restart
@@ -1149,6 +1274,78 @@ async def websocket_set_country(
)
@websocket_api.websocket_command(
{
vol.Required("type"): "shopping_list_manager/regions/create",
vol.Required("code"): vol.All(str, vol.Length(min=2, max=8), vol.Upper),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=64)),
vol.Optional("currency_symbol"): vol.Any(vol.All(str, vol.Length(min=1, max=5)), None),
vol.Optional("language"): vol.Any(vol.All(str, vol.Length(min=1, max=64)), None),
}
)
@websocket_api.async_response
async def websocket_create_custom_region(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: Dict[str, Any],
) -> None:
"""Create a custom region."""
code = msg["code"].upper()
name = msg["name"].strip()
currency_symbol = msg.get("currency_symbol")
language = msg.get("language")
storage = get_storage(hass)
if code in _BUILT_IN_COUNTRIES:
connection.send_error(msg["id"], "conflict", f"{code} is a built-in region")
return
created = await storage.create_custom_region(code, name, currency_symbol, language)
if not created:
connection.send_error(msg["id"], "conflict", f"Region {code} already exists")
return
connection.send_result(
msg["id"],
{"success": True, "code": code, "custom_regions": storage.get_custom_regions()}
)
@websocket_api.websocket_command(
{
vol.Required("type"): "shopping_list_manager/regions/delete",
vol.Required("code"): str,
}
)
@websocket_api.async_response
async def websocket_delete_custom_region(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: Dict[str, Any],
) -> None:
"""Delete a custom region."""
code = msg["code"].upper()
storage = get_storage(hass)
deleted = await storage.delete_custom_region(code)
if not deleted:
connection.send_error(msg["id"], "not_found", f"Custom region {code} not found")
return
# If the active country was this region, fall back to NZ
if hass.data[DOMAIN].get("country") == code:
hass.data[DOMAIN]["country"] = "NZ"
entries = hass.config_entries.async_entries(DOMAIN)
if entries:
entry = entries[0]
hass.config_entries.async_update_entry(entry, options={**entry.options, "country": "NZ"})
connection.send_result(
msg["id"],
{"success": True, "code": code, "custom_regions": storage.get_custom_regions()}
)
# =============================================================================
# BACKUP / RESTORE HANDLERS
# =============================================================================