diff --git a/custom_components/shopping_list_manager/__init__.py b/custom_components/shopping_list_manager/__init__.py index a3069c1..21dff19 100644 --- a/custom_components/shopping_list_manager/__init__.py +++ b/custom_components/shopping_list_manager/__init__.py @@ -242,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( diff --git a/custom_components/shopping_list_manager/const.py b/custom_components/shopping_list_manager/const.py index 1d2b898..d4424f6 100644 --- a/custom_components/shopping_list_manager/const.py +++ b/custom_components/shopping_list_manager/const.py @@ -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" diff --git a/custom_components/shopping_list_manager/storage.py b/custom_components/shopping_list_manager/storage.py index 1d271f1..4b20ca0 100644 --- a/custom_components/shopping_list_manager/storage.py +++ b/custom_components/shopping_list_manager/storage.py @@ -17,6 +17,7 @@ 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, @@ -48,12 +49,14 @@ 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, str] = {} # code -> display name 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)) @@ -155,6 +158,12 @@ class ShoppingListStorage: } _LOGGER.debug("Loaded %d loyalty cards", len(self._loyalty_cards)) + # Load custom regions + custom_regions_data = await self._store_custom_regions.async_load() + if custom_regions_data: + self._custom_regions = custom_regions_data.get("regions", {}) + _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()} @@ -824,3 +833,33 @@ 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, str]: + """Return all custom regions as {code: display_name}.""" + return dict(self._custom_regions) + + async def create_custom_region(self, code: str, name: str) -> 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 + 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}) diff --git a/custom_components/shopping_list_manager/websocket/handlers.py b/custom_components/shopping_list_manager/websocket/handlers.py index 5423a51..fdf6eda 100644 --- a/custom_components/shopping_list_manager/websocket/handlers.py +++ b/custom_components/shopping_list_manager/websocket/handlers.py @@ -1207,29 +1207,33 @@ def websocket_get_integration_settings( """Return current country and available country options.""" country = hass.data[DOMAIN].get("country", "NZ") 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() connection.send_result( msg["id"], { "country": country, "version": version, - "available_countries": { - "NZ": "New Zealand", - "AU": "Australia", - "US": "United States", - "GB": "United Kingdom", - "CA": "Canada", - "BE": "Belgium (Dutch)", - }, + "available_countries": {**built_in, **custom}, + "custom_regions": custom, } ) -_VALID_COUNTRIES = ["NZ", "AU", "US", "GB", "CA", "BE"] +_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 @@ -1242,6 +1246,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 @@ -1258,6 +1267,74 @@ 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)), + } +) +@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() + 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) + if not created: + connection.send_error(msg["id"], "conflict", f"Region {code} already exists") + return + + connection.send_result( + msg["id"], + {"success": True, "code": code, "name": name, "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 # =============================================================================