23 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
16 changed files with 1523 additions and 48 deletions
@@ -1,8 +1,8 @@
"""Shopping List Manager integration for Home Assistant.""" """Shopping List Manager integration for Home Assistant."""
import json
import logging import logging
import os import os
from pathlib import Path
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@@ -17,6 +17,14 @@ _LOGGER = logging.getLogger(__name__)
DATA_STORAGE = f"{DOMAIN}_storage" 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Shopping List Manager component from yaml (not used).""" """Set up the Shopping List Manager component from yaml (not used)."""
# This integration doesn't support YAML configuration # This integration doesn't support YAML configuration
@@ -43,11 +51,15 @@ 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
version = await hass.async_add_executor_job(_load_manifest_version, component_path)
# 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"] = version
# 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))
@@ -230,6 +242,14 @@ async def _async_register_websocket_handlers(
hass, hass,
handlers.websocket_set_country, 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 # Backup / Restore handlers
websocket_api.async_register_command( websocket_api.async_register_command(
@@ -50,7 +50,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry): def __init__(self, config_entry):
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry self._config_entry = config_entry
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
"""Manage the options.""" """Manage the options."""
@@ -59,9 +59,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)
# Get current settings # Get current settings
current_country = self.config_entry.options.get( current_country = self._config_entry.options.get(
"country", "country",
self.config_entry.data.get("country", "NZ") self._config_entry.data.get("country", "NZ")
) )
return self.async_show_form( return self.async_show_form(
@@ -73,18 +73,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
"US": "United States", "US": "United States",
"GB": "United Kingdom", "GB": "United Kingdom",
"CA": "Canada", "CA": "Canada",
"BE": "Belgium (Dutch)",
}), }),
vol.Optional( vol.Optional(
"enable_price_tracking", "enable_price_tracking",
default=self.config_entry.options.get("enable_price_tracking", True) default=self._config_entry.options.get("enable_price_tracking", True)
): bool, ): bool,
vol.Optional( vol.Optional(
"enable_image_search", "enable_image_search",
default=self.config_entry.options.get("enable_image_search", True) default=self._config_entry.options.get("enable_image_search", True)
): bool, ): bool,
vol.Optional( vol.Optional(
"metric_units_only", "metric_units_only",
default=self.config_entry.options.get("metric_units_only", True) default=self._config_entry.options.get("metric_units_only", True)
): bool, ): bool,
}), }),
description_placeholders={ description_placeholders={
@@ -10,6 +10,7 @@ STORAGE_KEY_ITEMS = f"{DOMAIN}.items"
STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products" STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products"
STORAGE_KEY_CATEGORIES = f"{DOMAIN}.categories" STORAGE_KEY_CATEGORIES = f"{DOMAIN}.categories"
STORAGE_KEY_LOYALTY_CARDS = f"{DOMAIN}.loyalty_cards" STORAGE_KEY_LOYALTY_CARDS = f"{DOMAIN}.loyalty_cards"
STORAGE_KEY_CUSTOM_REGIONS = f"{DOMAIN}.custom_regions"
# WebSocket Commands - Lists # WebSocket Commands - Lists
WS_TYPE_LISTS_GET_ALL = f"{DOMAIN}/lists/get_all" WS_TYPE_LISTS_GET_ALL = f"{DOMAIN}/lists/get_all"
@@ -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", "domain": "shopping_list_manager",
"name": "Shopping List Manager", "name": "Shopping List Manager",
"version": "2.0.0", "version": "2.2.2",
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager", "documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues", "issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
"requirements": [ "requirements": [
@@ -132,6 +132,7 @@ class ShoppingList:
# Ownership: None = visible to all users; set = private to owner + allowed_users # Ownership: None = visible to all users; set = private to owner + allowed_users
owner_id: Optional[str] = None owner_id: Optional[str] = None
allowed_users: List[str] = field(default_factory=list) allowed_users: List[str] = field(default_factory=list)
ha_todo_entity_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary.""" """Convert to dictionary."""
@@ -17,6 +17,7 @@ from .const import (
STORAGE_KEY_PRODUCTS, STORAGE_KEY_PRODUCTS,
STORAGE_KEY_CATEGORIES, STORAGE_KEY_CATEGORIES,
STORAGE_KEY_LOYALTY_CARDS, STORAGE_KEY_LOYALTY_CARDS,
STORAGE_KEY_CUSTOM_REGIONS,
IMAGES_LOCAL_DIR, IMAGES_LOCAL_DIR,
LEGACY_IMAGES_LOCAL_DIR, LEGACY_IMAGES_LOCAL_DIR,
LOCAL_IMAGE_URL_PREFIX, LOCAL_IMAGE_URL_PREFIX,
@@ -48,12 +49,14 @@ class ShoppingListStorage:
self._store_products = Store(hass, STORAGE_VERSION, STORAGE_KEY_PRODUCTS) self._store_products = Store(hass, STORAGE_VERSION, STORAGE_KEY_PRODUCTS)
self._store_categories = Store(hass, STORAGE_VERSION, STORAGE_KEY_CATEGORIES) self._store_categories = Store(hass, STORAGE_VERSION, STORAGE_KEY_CATEGORIES)
self._store_loyalty_cards = Store(hass, STORAGE_VERSION, STORAGE_KEY_LOYALTY_CARDS) 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._lists: Dict[str, ShoppingList] = {}
self._items: Dict[str, List[Item]] = {} self._items: Dict[str, List[Item]] = {}
self._products: Dict[str, Product] = {} self._products: Dict[str, Product] = {}
self._categories: List[Category] = [] self._categories: List[Category] = []
self._loyalty_cards: Dict[str, LoyaltyCard] = {} self._loyalty_cards: Dict[str, LoyaltyCard] = {}
self._custom_regions: Dict[str, Any] = {} # code -> {name, currency_symbol, language}
self._search_engine: Optional[ProductSearch] = None self._search_engine: Optional[ProductSearch] = None
self._images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR)) self._images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR))
self._legacy_images_dir = Path(hass.config.path(LEGACY_IMAGES_LOCAL_DIR)) self._legacy_images_dir = Path(hass.config.path(LEGACY_IMAGES_LOCAL_DIR))
@@ -102,21 +105,8 @@ class ShoppingListStorage:
self._images_dir.mkdir(parents=True, exist_ok=True) self._images_dir.mkdir(parents=True, exist_ok=True)
await self._migrate_legacy_images_and_urls() await self._migrate_legacy_images_and_urls()
# Load categories # Load country-specific system categories so labels follow the selected country.
categories_data = await self._store_categories.async_load() await self._load_categories_for_country(self._country)
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
)
# Load product catalog if products are empty # Load product catalog if products are empty
if not self._products: if not self._products:
@@ -168,6 +158,21 @@ class ShoppingListStorage:
} }
_LOGGER.debug("Loaded %d loyalty cards", len(self._loyalty_cards)) _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 # Initialize search engine after products are loaded
if self._products: if self._products:
products_dict = {pid: p.to_dict() for pid, p in self._products.items()} products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
@@ -581,6 +586,8 @@ class ShoppingListStorage:
del self._products[pid] del self._products[pid]
self._country = country_code self._country = country_code
await self._load_categories_for_country(country_code)
catalog_products = await load_product_catalog(self._component_path, country_code) catalog_products = await load_product_catalog(self._component_path, country_code)
count = 0 count = 0
for prod_data in catalog_products: for prod_data in catalog_products:
@@ -738,6 +745,17 @@ class ShoppingListStorage:
_LOGGER.warning("Failed to write config backup: %s", err) _LOGGER.warning("Failed to write config backup: %s", err)
# Categories methods # 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: async def _save_categories(self) -> None:
"""Save categories to storage.""" """Save categories to storage."""
data = [cat.to_dict() for cat in self._categories] data = [cat.to_dict() for cat in self._categories]
@@ -824,3 +842,39 @@ class ShoppingListStorage:
await self._save_loyalty_cards() await self._save_loyalty_cards()
_LOGGER.debug("Updated members for loyalty card: %s", card_id) _LOGGER.debug("Updated members for loyalty card: %s", card_id)
return card 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": "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)."
}
}
}
}
}
@@ -4,6 +4,7 @@ import logging
import re import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from urllib.parse import quote_plus
import voluptuous as vol import voluptuous as vol
from aiohttp import ClientTimeout from aiohttp import ClientTimeout
@@ -59,11 +60,35 @@ from .. import get_storage
_LOGGER = logging.getLogger(__name__) _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 # 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: def _user_can_access_list(lst, user) -> bool:
"""Return True if the user may read or write to this list. """Return True if the user may read or write to this list.
@@ -317,6 +342,7 @@ async def websocket_create_list(
vol.Optional("name"): str, vol.Optional("name"): str,
vol.Optional("icon"): str, vol.Optional("icon"): str,
vol.Optional("category_order"): [str], vol.Optional("category_order"): [str],
vol.Optional("ha_todo_entity_id"): vol.Any(str, None),
} }
) )
@websocket_api.async_response @websocket_api.async_response
@@ -340,6 +366,8 @@ async def websocket_update_list(
update_data["icon"] = msg["icon"] update_data["icon"] = msg["icon"]
if "category_order" in msg: if "category_order" in msg:
update_data["category_order"] = msg["category_order"] 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) updated_list = await storage.update_list(list_id, **update_data)
@@ -1126,17 +1154,14 @@ async def websocket_off_fetch(
msg: Dict[str, Any], msg: Dict[str, Any],
) -> None: ) -> None:
"""Proxy OpenFoodFacts requests through HA to avoid browser CORS restrictions.""" """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) session = async_get_clientsession(hass)
headers = {"User-Agent": "HomeAssistant/ShoppingListManager (contact@homeassistant.io)"} base_url, headers = _get_openfoodfacts_request_config(hass)
try: try:
if msg.get("barcode"): if msg.get("barcode"):
barcode = msg["barcode"] barcode = msg["barcode"]
fields = "product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price" fields = "code,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}" url = f"{base_url}/api/v2/product/{barcode}.json?fields={fields}"
async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp: async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp:
if not resp.ok: if not resp.ok:
connection.send_result(msg["id"], {"status": 0}) connection.send_result(msg["id"], {"status": 0})
@@ -1149,10 +1174,11 @@ async def websocket_off_fetch(
else: else:
query = msg.get("query", "") query = msg.get("query", "")
page_size = msg.get("page_size", 5) page_size = msg.get("page_size", 5)
fields = "product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price" fields = "code,product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
url = ( url = (
f"https://world.openfoodfacts.org/api/v2/search" f"{base_url}/api/v2/search"
f"?search_terms={query}&fields={fields}&page_size={page_size}" 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: async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp:
if not resp.ok: if not resp.ok:
@@ -1182,27 +1208,39 @@ 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")
connection.send_result( version = hass.data[DOMAIN].get("version", "unknown")
msg["id"], storage = get_storage(hass)
{ built_in = {
"country": country,
"available_countries": {
"NZ": "New Zealand", "NZ": "New Zealand",
"AU": "Australia", "AU": "Australia",
"US": "United States", "US": "United States",
"GB": "United Kingdom", "GB": "United Kingdom",
"CA": "Canada", "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( @websocket_api.websocket_command(
{ {
vol.Required("type"): "shopping_list_manager/set_country", vol.Required("type"): "shopping_list_manager/set_country",
vol.Required("country"): vol.In(_VALID_COUNTRIES), vol.Required("country"): str,
} }
) )
@websocket_api.async_response @websocket_api.async_response
@@ -1215,6 +1253,11 @@ async def websocket_set_country(
country = msg["country"].upper() country = msg["country"].upper()
storage = get_storage(hass) 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) count = await storage.reload_catalog(country)
# Persist to HA config entry so country survives restart # Persist to HA config entry so country survives restart
@@ -1231,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 # BACKUP / RESTORE HANDLERS
# ============================================================================= # =============================================================================