8 Commits

Author SHA1 Message Date
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 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
11 changed files with 363 additions and 12 deletions
@@ -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(
@@ -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"
@@ -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."""
@@ -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, 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))
@@ -155,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()}
@@ -824,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": "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": "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)."
}
}
}
}
}
@@ -341,6 +341,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
@@ -364,6 +365,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)
@@ -1204,29 +1207,38 @@ 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")
connection.send_result(
msg["id"],
{
"country": country,
"version": version,
"available_countries": {
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", "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
@@ -1239,6 +1251,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
@@ -1255,6 +1272,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
# =============================================================================