182 files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
{"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1786865261.4982338}
|
{"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1786968619.5823264}
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
||||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374101",
|
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374102",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d79e36a45ffb434dae8af23a2404abe4",
|
"id": "3af9295bc61240279b88bc700c1ef48a",
|
||||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,25 @@ PLATFORMS = COORDINATOR_AWARE_PLATFORMS + [
|
|||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Suffixes used when building gateway-attached entity unique_ids in sensor.py
|
||||||
|
# (e.g. f"{device.identifier.lower()}-{suffix}"). Used to migrate those
|
||||||
|
# unique_ids when the gateway's identifier changes from DSN-based to MAC-based.
|
||||||
|
GATEWAY_ENTITY_SUFFIXES = [
|
||||||
|
"defi_hilo",
|
||||||
|
"recompenses_hilo",
|
||||||
|
"notifications_hilo",
|
||||||
|
"outdoor_weather_hilo",
|
||||||
|
"hilo_gateway",
|
||||||
|
"hilo_rate_current",
|
||||||
|
"hilo_rate_low",
|
||||||
|
"hilo_rate_medium",
|
||||||
|
"hilo_rate_high",
|
||||||
|
"hilo_rate_access",
|
||||||
|
"hilo_rate_low_threshold",
|
||||||
|
"hilo_rate_reward_rate",
|
||||||
|
"hilo_cost_total",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
@@ -124,9 +143,39 @@ def _async_register_custom_device(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry( # noqa: C901
|
@callback
|
||||||
hass: HomeAssistant, entry: ConfigEntry
|
def _async_migrate_gateway_device_identifier(
|
||||||
) -> bool:
|
hass: HomeAssistant, entry: ConfigEntry, old_dsn: str | None, new_mac: str
|
||||||
|
) -> None:
|
||||||
|
"""Migrate the gateway device registry entry from DSN-based to MAC-based identity.
|
||||||
|
|
||||||
|
Preserves the device's registry entry (and thus its device_id, so entities,
|
||||||
|
dashboards, and automations referencing it stay attached) by renaming its
|
||||||
|
identifier in place instead of letting a new device get created.
|
||||||
|
"""
|
||||||
|
if not old_dsn or old_dsn == new_mac:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
old_device = device_registry.async_get_device(identifiers={(DOMAIN, old_dsn)})
|
||||||
|
if old_device is None:
|
||||||
|
return # fresh install, or already migrated
|
||||||
|
|
||||||
|
new_device = device_registry.async_get_device(identifiers={(DOMAIN, new_mac)})
|
||||||
|
if new_device is not None and new_device.id != old_device.id:
|
||||||
|
LOG.warning(
|
||||||
|
"Gateway device already registered under new identifier %s, skipping device migration",
|
||||||
|
new_mac,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
device_registry.async_update_device(
|
||||||
|
old_device.id, new_identifiers={(DOMAIN, new_mac)}
|
||||||
|
)
|
||||||
|
LOG.info("Migrated gateway device identifier %s -> %s", old_dsn, new_mac)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Hilo as config entry."""
|
"""Set up Hilo as config entry."""
|
||||||
HiloFlowHandler.async_register_implementation(
|
HiloFlowHandler.async_register_implementation(
|
||||||
hass, AuthCodeWithPKCEImplementation(hass)
|
hass, AuthCodeWithPKCEImplementation(hass)
|
||||||
@@ -160,9 +209,7 @@ async def async_setup_entry( # noqa: C901
|
|||||||
|
|
||||||
_async_standardize_config_entry(hass, entry)
|
_async_standardize_config_entry(hass, entry)
|
||||||
scan_interval = current_options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
scan_interval = current_options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||||
scan_interval = (
|
scan_interval = max(scan_interval, MIN_SCAN_INTERVAL)
|
||||||
scan_interval if scan_interval >= MIN_SCAN_INTERVAL else MIN_SCAN_INTERVAL
|
|
||||||
)
|
|
||||||
|
|
||||||
hilo = Hilo(hass, entry, api)
|
hilo = Hilo(hass, entry, api)
|
||||||
try:
|
try:
|
||||||
@@ -176,9 +223,7 @@ async def async_setup_entry( # noqa: C901
|
|||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = hilo
|
hass.data[DOMAIN][entry.entry_id] = hilo
|
||||||
|
|
||||||
hass.async_create_task(
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Note (ic-dev21): This is a bit of a hack to rename some entities that were created with non-standard names in early versions
|
# Note (ic-dev21): This is a bit of a hack to rename some entities that were created with non-standard names in early versions
|
||||||
# HA has changed the way they name entities linked to a device by default and this breaks the naming scheme of the gateway entities.
|
# HA has changed the way they name entities linked to a device by default and this breaks the naming scheme of the gateway entities.
|
||||||
@@ -731,6 +776,29 @@ class Hilo:
|
|||||||
)
|
)
|
||||||
return self._events[event_id]
|
return self._events[event_id]
|
||||||
|
|
||||||
|
async def _fetch_legacy_gateway_dsn(self, new_mac: str) -> str | None:
|
||||||
|
"""This function looks up the Hilo gateway device in the device registry and
|
||||||
|
returns its old DSN-based identifier if it exists. If it doesn't,
|
||||||
|
it returns Noneto use the MAC address instead."""
|
||||||
|
device_registry = dr.async_get(self._hass)
|
||||||
|
for device in device_registry.devices.values():
|
||||||
|
if device.manufacturer != "Hilo" or device.model != "EQ000017":
|
||||||
|
continue
|
||||||
|
for domain, identifier in device.identifiers:
|
||||||
|
if domain == DOMAIN and identifier != new_mac:
|
||||||
|
return identifier
|
||||||
|
return None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_migrate_gateway_entities(self, old_dsn: str | None, new_mac: str) -> None:
|
||||||
|
"""Migrate gateway-attached entity unique_ids from DSN-based to MAC-based."""
|
||||||
|
if not old_dsn or old_dsn == new_mac:
|
||||||
|
return
|
||||||
|
for suffix in GATEWAY_ENTITY_SUFFIXES:
|
||||||
|
old_unique_id = f"{old_dsn.lower()}-{suffix}"
|
||||||
|
new_unique_id = f"{new_mac.lower()}-{suffix}"
|
||||||
|
self.async_migrate_unique_id(old_unique_id, new_unique_id, Platform.SENSOR)
|
||||||
|
|
||||||
async def async_init(self, scan_interval: int) -> None:
|
async def async_init(self, scan_interval: int) -> None:
|
||||||
"""Initialize the Hilo "manager" class.
|
"""Initialize the Hilo "manager" class.
|
||||||
|
|
||||||
@@ -772,10 +840,18 @@ class Hilo:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 6: Register custom devices in HA
|
# Step 6: Migrate gateway identity (DSN -> MAC) if needed, then register
|
||||||
_async_register_custom_device(
|
# custom devices in HA.
|
||||||
self._hass, self.entry, self.devices.find_device(1)
|
gateway = self.devices.find_device(1)
|
||||||
)
|
if gateway:
|
||||||
|
old_dsn = await self._fetch_legacy_gateway_dsn(gateway.identifier)
|
||||||
|
if old_dsn:
|
||||||
|
_async_migrate_gateway_device_identifier(
|
||||||
|
self._hass, self.entry, old_dsn, gateway.identifier
|
||||||
|
)
|
||||||
|
self.async_migrate_gateway_entities(old_dsn, gateway.identifier)
|
||||||
|
_async_register_custom_device(self._hass, self.entry, gateway)
|
||||||
|
|
||||||
if self.track_unknown_sources:
|
if self.track_unknown_sources:
|
||||||
if not self.unknown_tracker_device:
|
if not self.unknown_tracker_device:
|
||||||
self.unknown_tracker_device = self.devices.generate_device(
|
self.unknown_tracker_device = self.devices.generate_device(
|
||||||
@@ -1122,7 +1198,7 @@ class Hilo:
|
|||||||
ATTR_UNIT_OF_MEASUREMENT: parent_unit, # note ic-dev21: now uses parent_unit directly
|
ATTR_UNIT_OF_MEASUREMENT: parent_unit, # note ic-dev21: now uses parent_unit directly
|
||||||
ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY,
|
ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY,
|
||||||
}
|
}
|
||||||
if not all(a in attrs.keys() for a in new_attrs.keys()):
|
if not all(a in attrs.keys() for a in new_attrs):
|
||||||
LOG.warning(
|
LOG.warning(
|
||||||
f"Fixing utility sensor: {entity} {current_state} new_attrs: {new_attrs}"
|
f"Fixing utility sensor: {entity} {current_state} new_attrs: {new_attrs}"
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,5 +12,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"issue_tracker": "https://github.com/dvd-dev/hilo/issues",
|
"issue_tracker": "https://github.com/dvd-dev/hilo/issues",
|
||||||
"requirements": ["python-hilo>=2026.3.5"],
|
"requirements": ["python-hilo>=2026.3.5"],
|
||||||
"version": "2026.8.1"
|
"version": "2026.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
CURRENCY_DOLLAR,
|
CURRENCY_DOLLAR,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.const import (
|
|||||||
Platform,
|
Platform,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
UnitOfPower,
|
UnitOfPower,
|
||||||
|
UnitOfRatio,
|
||||||
UnitOfSoundPressure,
|
UnitOfSoundPressure,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
__short_version__ as current_version,
|
__short_version__ as current_version,
|
||||||
@@ -271,7 +271,7 @@ class Co2Sensor(HiloEntity, SensorEntity):
|
|||||||
"""Define a Co2 sensor entity."""
|
"""Define a Co2 sensor entity."""
|
||||||
|
|
||||||
_attr_device_class = SensorDeviceClass.CO2
|
_attr_device_class = SensorDeviceClass.CO2
|
||||||
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION
|
_attr_native_unit_of_measurement = UnitOfRatio.PARTS_PER_MILLION
|
||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
def __init__(self, hilo, device):
|
def __init__(self, hilo, device):
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ from .const import (
|
|||||||
from .coordinator import MaintenanceCoordinator
|
from .coordinator import MaintenanceCoordinator
|
||||||
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
|
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
|
||||||
from .frontend import async_register_card
|
from .frontend import async_register_card
|
||||||
|
from .helpers.aggregate import object_name as aggregate_object_name
|
||||||
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
|
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
|
||||||
from .helpers.dates import INTERVAL_UNITS
|
from .helpers.dates import INTERVAL_UNITS
|
||||||
from .helpers.documents import DocumentStore
|
from .helpers.documents import DocumentStore
|
||||||
|
from .helpers.global_options import get_global_entry
|
||||||
from .helpers.notification_manager import NotificationManager
|
from .helpers.notification_manager import NotificationManager
|
||||||
from .helpers.schedule import normalize_task_storage
|
from .helpers.schedule import normalize_task_storage
|
||||||
from .helpers.task_fields import (
|
from .helpers.task_fields import (
|
||||||
@@ -252,10 +254,7 @@ async def async_maybe_send_weekly_digest(hass: HomeAssistant, *, force: bool = F
|
|||||||
|
|
||||||
if not force and dt_util.now().weekday() != 0: # Monday only
|
if not force and dt_util.now().weekday() != 0: # Monday only
|
||||||
return
|
return
|
||||||
global_entry = next(
|
global_entry = get_global_entry(hass)
|
||||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if global_entry is None:
|
if global_entry is None:
|
||||||
return
|
return
|
||||||
options = global_entry.options or global_entry.data
|
options = global_entry.options or global_entry.data
|
||||||
@@ -290,10 +289,7 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo
|
|||||||
DEFAULT_WARRANTY_REMINDER_DAYS,
|
DEFAULT_WARRANTY_REMINDER_DAYS,
|
||||||
)
|
)
|
||||||
|
|
||||||
global_entry = next(
|
global_entry = get_global_entry(hass)
|
||||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if global_entry is None:
|
if global_entry is None:
|
||||||
return
|
return
|
||||||
options = global_entry.options or global_entry.data
|
options = global_entry.options or global_entry.data
|
||||||
@@ -314,7 +310,7 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
if delta == days or (force and 0 <= delta <= days):
|
if delta == days or (force and 0 <= delta <= days):
|
||||||
names.append(obj.get("name") or entry.title)
|
names.append(aggregate_object_name(entry))
|
||||||
if not names:
|
if not names:
|
||||||
return
|
return
|
||||||
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
||||||
@@ -336,10 +332,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
|||||||
from .const import CONF_REMINDER_LEAD_DAYS
|
from .const import CONF_REMINDER_LEAD_DAYS
|
||||||
from .models.maintenance_task import MaintenanceTask
|
from .models.maintenance_task import MaintenanceTask
|
||||||
|
|
||||||
global_entry = next(
|
global_entry = get_global_entry(hass)
|
||||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if global_entry is None:
|
if global_entry is None:
|
||||||
return
|
return
|
||||||
options = global_entry.options or global_entry.data
|
options = global_entry.options or global_entry.data
|
||||||
@@ -368,7 +361,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
|||||||
continue
|
continue
|
||||||
# Merged data so last_performed reflects the Store, not stale entry data.
|
# Merged data so last_performed reflects the Store, not stale entry data.
|
||||||
merged = coordinator._get_merged_tasks_data()
|
merged = coordinator._get_merged_tasks_data()
|
||||||
obj_name = obj.get("name") or entry.title
|
obj_name = aggregate_object_name(entry)
|
||||||
for task_id, task_data in merged.items():
|
for task_id, task_data in merged.items():
|
||||||
if not task_data.get("enabled", True):
|
if not task_data.get("enabled", True):
|
||||||
continue
|
continue
|
||||||
@@ -678,7 +671,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
|||||||
coordinator = getattr(rd, "coordinator", None) if rd else None
|
coordinator = getattr(rd, "coordinator", None) if rd else None
|
||||||
if coordinator is None or not coordinator.data:
|
if coordinator is None or not coordinator.data:
|
||||||
continue
|
continue
|
||||||
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title)
|
object_name = aggregate_object_name(ce)
|
||||||
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
|
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
|
||||||
status = str(task.get("_status", ""))
|
status = str(task.get("_status", ""))
|
||||||
if status == "archived":
|
if status == "archived":
|
||||||
@@ -1345,7 +1338,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
|||||||
is_fixable=True,
|
is_fixable=True,
|
||||||
severity=ir.IssueSeverity.WARNING,
|
severity=ir.IssueSeverity.WARNING,
|
||||||
translation_key="device_link_self" if self_link else "device_link_lost",
|
translation_key="device_link_self" if self_link else "device_link_lost",
|
||||||
translation_placeholders={"object": obj_data.get("name") or entry.title},
|
translation_placeholders={"object": aggregate_object_name(entry)},
|
||||||
data={"entry_id": entry.entry_id},
|
data={"entry_id": entry.entry_id},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from .const import (
|
|||||||
SIGNAL_TASK_RESET,
|
SIGNAL_TASK_RESET,
|
||||||
MaintenanceStatus,
|
MaintenanceStatus,
|
||||||
slugify_object_name,
|
slugify_object_name,
|
||||||
|
task_unique_id,
|
||||||
)
|
)
|
||||||
from .coordinator import MaintenanceCoordinator
|
from .coordinator import MaintenanceCoordinator
|
||||||
from .entity.entity_base import MaintenanceEntity
|
from .entity.entity_base import MaintenanceEntity
|
||||||
@@ -94,7 +95,7 @@ class MaintenanceBinarySensor(MaintenanceEntity, BinarySensorEntity):
|
|||||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||||
|
|
||||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||||
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_overdue"
|
self._attr_unique_id = task_unique_id(object_slug, task_id, "overdue")
|
||||||
|
|
||||||
entity_slug = task_data.get("entity_slug")
|
entity_slug = task_data.get("entity_slug")
|
||||||
if entity_slug:
|
if entity_slug:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .const import (
|
|||||||
CONF_TASKS,
|
CONF_TASKS,
|
||||||
GLOBAL_UNIQUE_ID,
|
GLOBAL_UNIQUE_ID,
|
||||||
slugify_object_name,
|
slugify_object_name,
|
||||||
|
task_unique_id,
|
||||||
)
|
)
|
||||||
from .coordinator import MaintenanceCoordinator
|
from .coordinator import MaintenanceCoordinator
|
||||||
from .entity.entity_base import MaintenanceEntity
|
from .entity.entity_base import MaintenanceEntity
|
||||||
@@ -89,7 +90,7 @@ class MaintenanceActionButton(MaintenanceEntity, ButtonEntity):
|
|||||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||||
|
|
||||||
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_{action}"
|
self._attr_unique_id = task_unique_id(object_slug, task_id, action)
|
||||||
self._attr_translation_key = f"button_{action}"
|
self._attr_translation_key = f"button_{action}"
|
||||||
# Custom entity_slug → stable, language-independent entity_id/name.
|
# Custom entity_slug → stable, language-independent entity_id/name.
|
||||||
entity_slug = task_data.get("entity_slug")
|
entity_slug = task_data.get("entity_slug")
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADVANCED_SCHEDULE_TIME,
|
|
||||||
CONF_OBJECT,
|
CONF_OBJECT,
|
||||||
CONF_TASKS,
|
CONF_TASKS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -20,7 +19,9 @@ from .const import (
|
|||||||
MaintenanceStatus,
|
MaintenanceStatus,
|
||||||
ScheduleType,
|
ScheduleType,
|
||||||
)
|
)
|
||||||
|
from .helpers.aggregate import merged_tasks
|
||||||
from .helpers.dates import interval_span_days
|
from .helpers.dates import interval_span_days
|
||||||
|
from .helpers.global_options import is_schedule_time_enabled
|
||||||
from .helpers.i18n import normalize_language
|
from .helpers.i18n import normalize_language
|
||||||
from .models.maintenance_task import MaintenanceTask
|
from .models.maintenance_task import MaintenanceTask
|
||||||
|
|
||||||
@@ -521,9 +522,7 @@ class MaintenanceCalendar(CalendarEntity):
|
|||||||
live_tasks = {}
|
live_tasks = {}
|
||||||
|
|
||||||
# Merge static (ConfigEntry) + dynamic (Store) task data
|
# Merge static (ConfigEntry) + dynamic (Store) task data
|
||||||
store = getattr(runtime_data, "store", None) if runtime_data else None
|
tasks_data = merged_tasks(entry)
|
||||||
static_tasks = entry.data.get(CONF_TASKS, {})
|
|
||||||
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
|
|
||||||
|
|
||||||
for task_id, task_dict in tasks_data.items():
|
for task_id, task_dict in tasks_data.items():
|
||||||
task = MaintenanceTask.from_dict(task_dict)
|
task = MaintenanceTask.from_dict(task_dict)
|
||||||
@@ -638,9 +637,5 @@ class MaintenanceCalendar(CalendarEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _is_schedule_time_feature_enabled(self) -> bool:
|
def _is_schedule_time_feature_enabled(self) -> bool:
|
||||||
"""Lookup the global advanced flag — same approach as coordinator."""
|
"""Lookup the global advanced flag — same source as the coordinator."""
|
||||||
for ce in self._hass.config_entries.async_entries(DOMAIN):
|
return is_schedule_time_enabled(self._hass)
|
||||||
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
|
||||||
opts = ce.options or ce.data
|
|
||||||
return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False))
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -16,14 +16,8 @@ from homeassistant.config_entries import (
|
|||||||
from homeassistant.core import HomeAssistant, State, callback
|
from homeassistant.core import HomeAssistant, State, callback
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
from .config_flow_helpers import (
|
|
||||||
CALENDAR_KIND_VALUES,
|
|
||||||
apply_interval_unit,
|
|
||||||
calendar_schema,
|
|
||||||
interval_unit_selector,
|
|
||||||
schedule_from_calendar_input,
|
|
||||||
)
|
|
||||||
from .config_flow_options_global import validate_notify_service
|
from .config_flow_options_global import validate_notify_service
|
||||||
|
from .config_flow_schedule import ScheduleStepsMixin
|
||||||
from .config_flow_trigger import TriggerConfigMixin
|
from .config_flow_trigger import TriggerConfigMixin
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_DEFAULT_WARNING_DAYS,
|
CONF_DEFAULT_WARNING_DAYS,
|
||||||
@@ -39,30 +33,15 @@ from .const import (
|
|||||||
CONF_OBJECT_NOTES,
|
CONF_OBJECT_NOTES,
|
||||||
CONF_OBJECT_SERIAL_NUMBER,
|
CONF_OBJECT_SERIAL_NUMBER,
|
||||||
CONF_OBJECT_WARRANTY_EXPIRY,
|
CONF_OBJECT_WARRANTY_EXPIRY,
|
||||||
CONF_TASK_DUE_DATE,
|
|
||||||
CONF_TASK_ICON,
|
|
||||||
CONF_TASK_INTERVAL_DAYS,
|
|
||||||
CONF_TASK_INTERVAL_UNIT,
|
|
||||||
CONF_TASK_LABELS_TEXT,
|
|
||||||
CONF_TASK_NAME,
|
|
||||||
CONF_TASK_NOTES,
|
|
||||||
CONF_TASK_PRIORITY,
|
|
||||||
CONF_TASK_SCHEDULE_TYPE,
|
|
||||||
CONF_TASK_TYPE,
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
CONF_TASKS,
|
CONF_TASKS,
|
||||||
DEFAULT_INTERVAL_DAYS,
|
|
||||||
DEFAULT_WARNING_DAYS,
|
DEFAULT_WARNING_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
GLOBAL_UNIQUE_ID,
|
GLOBAL_UNIQUE_ID,
|
||||||
MaintenanceTypeEnum,
|
|
||||||
ScheduleType,
|
|
||||||
slugify_object_name,
|
slugify_object_name,
|
||||||
)
|
)
|
||||||
from .helpers.global_options import get_default_warning_days
|
|
||||||
from .helpers.i18n import normalize_language
|
from .helpers.i18n import normalize_language
|
||||||
from .helpers.schedule import KIND_WEEKDAYS, normalize_task_storage
|
from .helpers.schedule import normalize_task_storage
|
||||||
from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE
|
from .helpers.task_fields import WARNING_DAYS_RANGE
|
||||||
from .templates import (
|
from .templates import (
|
||||||
TEMPLATE_CATEGORIES,
|
TEMPLATE_CATEGORIES,
|
||||||
ObjectTemplate,
|
ObjectTemplate,
|
||||||
@@ -80,7 +59,7 @@ def _localized_template_default_name(template: ObjectTemplate, hass: HomeAssista
|
|||||||
return localize_template_text(template.name, normalize_language(hass)) or template.name
|
return localize_template_text(template.name, normalize_language(hass)) or template.name
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMAIN):
|
class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Maintenance Supporter."""
|
"""Handle a config flow for Maintenance Supporter."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
@@ -588,79 +567,16 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
|
|||||||
|
|
||||||
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Add a maintenance task."""
|
"""Add a maintenance task."""
|
||||||
if user_input is not None:
|
return await self._schedule_add_task(
|
||||||
if user_input.get("go_back"):
|
user_input,
|
||||||
return await self.async_step_task_menu()
|
|
||||||
|
|
||||||
self._current_task = {
|
|
||||||
"id": uuid4().hex,
|
|
||||||
CONF_TASK_NAME: user_input[CONF_TASK_NAME],
|
|
||||||
CONF_TASK_TYPE: user_input[CONF_TASK_TYPE],
|
|
||||||
CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE],
|
|
||||||
}
|
|
||||||
if user_input.get(CONF_TASK_ICON):
|
|
||||||
self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON]
|
|
||||||
if user_input.get(CONF_TASK_PRIORITY):
|
|
||||||
self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY]
|
|
||||||
if user_input.get(CONF_TASK_LABELS_TEXT):
|
|
||||||
self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT]
|
|
||||||
|
|
||||||
schedule = user_input[CONF_TASK_SCHEDULE_TYPE]
|
|
||||||
if schedule == ScheduleType.TIME_BASED:
|
|
||||||
return await self.async_step_time_based()
|
|
||||||
if schedule in CALENDAR_KIND_VALUES:
|
|
||||||
return await self.async_step_calendar()
|
|
||||||
if schedule == ScheduleType.SENSOR_BASED:
|
|
||||||
return await self.async_step_sensor_select()
|
|
||||||
if schedule == ScheduleType.ONE_TIME:
|
|
||||||
return await self.async_step_one_time()
|
|
||||||
# Manual
|
|
||||||
return await self.async_step_manual()
|
|
||||||
|
|
||||||
type_options = [t.value for t in MaintenanceTypeEnum]
|
|
||||||
schedule_options = [
|
|
||||||
ScheduleType.TIME_BASED,
|
|
||||||
*CALENDAR_KIND_VALUES,
|
|
||||||
ScheduleType.SENSOR_BASED,
|
|
||||||
ScheduleType.ONE_TIME,
|
|
||||||
ScheduleType.MANUAL,
|
|
||||||
]
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="add_task",
|
step_id="add_task",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self.async_step_task_menu,
|
||||||
{
|
time_based_step=self.async_step_time_based,
|
||||||
vol.Required(CONF_TASK_NAME): selector.TextSelector(
|
calendar_step=self.async_step_calendar,
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
sensor_step=self.async_step_sensor_select,
|
||||||
),
|
one_time_step=self.async_step_one_time,
|
||||||
vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector(
|
manual_step=self.async_step_manual,
|
||||||
selector.SelectSelectorConfig(
|
seed_id=True,
|
||||||
options=type_options,
|
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
||||||
translation_key="maintenance_type",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=schedule_options,
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="schedule_type",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_ICON): selector.IconSelector(),
|
|
||||||
vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=list(TASK_PRIORITIES),
|
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
||||||
translation_key="task_priority",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
description_placeholders={
|
description_placeholders={
|
||||||
"object_name": self._object_data.get(CONF_OBJECT_NAME, ""),
|
"object_name": self._object_data.get(CONF_OBJECT_NAME, ""),
|
||||||
},
|
},
|
||||||
@@ -668,131 +584,30 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
|
|||||||
|
|
||||||
async def async_step_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure time-based schedule."""
|
"""Configure time-based schedule."""
|
||||||
errors: dict[str, str] = {}
|
return await self._schedule_time_based(
|
||||||
|
user_input,
|
||||||
if user_input is not None:
|
|
||||||
if user_input.get("go_back"):
|
|
||||||
return await self.async_step_add_task()
|
|
||||||
|
|
||||||
interval = user_input.get(CONF_TASK_INTERVAL_DAYS, DEFAULT_INTERVAL_DAYS)
|
|
||||||
if interval <= 0:
|
|
||||||
errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval"
|
|
||||||
else:
|
|
||||||
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
|
|
||||||
apply_interval_unit(self._current_task, user_input)
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
last_performed = user_input.get("last_performed")
|
|
||||||
if last_performed:
|
|
||||||
self._current_task["last_performed"] = str(last_performed)
|
|
||||||
|
|
||||||
return self._save_task_and_return()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="time_based",
|
step_id="time_based",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self.async_step_add_task,
|
||||||
{
|
on_complete=self._save_task_and_return,
|
||||||
vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=INTERVAL_DAYS_RANGE[0],
|
|
||||||
max=INTERVAL_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
||||||
vol.Optional("last_performed"): selector.DateSelector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0],
|
|
||||||
max=WARNING_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure a calendar recurrence kind (weekdays / nth_weekday /
|
"""Configure a calendar recurrence kind (weekdays / nth_weekday /
|
||||||
day_of_month) during initial setup."""
|
day_of_month) during initial setup."""
|
||||||
errors: dict[str, str] = {}
|
return await self._schedule_calendar(
|
||||||
kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS)
|
user_input,
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
if user_input.get("go_back"):
|
|
||||||
return await self.async_step_add_task()
|
|
||||||
schedule = schedule_from_calendar_input(kind, user_input)
|
|
||||||
if schedule is None:
|
|
||||||
errors["base"] = "invalid_schedule"
|
|
||||||
else:
|
|
||||||
self._current_task["schedule"] = schedule
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
return self._save_task_and_return()
|
|
||||||
|
|
||||||
schema = calendar_schema(kind).extend(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="calendar",
|
step_id="calendar",
|
||||||
data_schema=schema,
|
on_go_back=self.async_step_add_task,
|
||||||
errors=errors,
|
on_complete=self._save_task_and_return,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure a one-time (non-recurring) task."""
|
"""Configure a one-time (non-recurring) task."""
|
||||||
errors: dict[str, str] = {}
|
return await self._schedule_one_time(
|
||||||
|
user_input,
|
||||||
if user_input is not None:
|
|
||||||
if user_input.get("go_back"):
|
|
||||||
return await self.async_step_add_task()
|
|
||||||
|
|
||||||
due_date = user_input.get(CONF_TASK_DUE_DATE)
|
|
||||||
if not due_date:
|
|
||||||
errors[CONF_TASK_DUE_DATE] = "invalid_due_date"
|
|
||||||
else:
|
|
||||||
self._current_task[CONF_TASK_DUE_DATE] = str(due_date)
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
return self._save_task_and_return()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="one_time",
|
step_id="one_time",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self.async_step_add_task,
|
||||||
{
|
on_complete=self._save_task_and_return,
|
||||||
vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0],
|
|
||||||
max=WARNING_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Sensor trigger steps (thin wrappers delegating to TriggerConfigMixin) ---
|
# --- Sensor trigger steps (thin wrappers delegating to TriggerConfigMixin) ---
|
||||||
@@ -954,40 +769,11 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
|
|||||||
|
|
||||||
async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure manual schedule."""
|
"""Configure manual schedule."""
|
||||||
if user_input is not None:
|
return await self._schedule_manual(
|
||||||
if user_input.get("go_back"):
|
user_input,
|
||||||
return await self.async_step_add_task()
|
|
||||||
|
|
||||||
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
if user_input.get(CONF_TASK_NOTES):
|
|
||||||
self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES]
|
|
||||||
|
|
||||||
return self._save_task_and_return()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="manual",
|
step_id="manual",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self.async_step_add_task,
|
||||||
{
|
on_complete=self._save_task_and_return,
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_NOTES): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(
|
|
||||||
type=selector.TextSelectorType.TEXT,
|
|
||||||
multiline=True,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
@@ -1023,49 +809,19 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
|
|||||||
|
|
||||||
def _save_task_and_return(self) -> ConfigFlowResult:
|
def _save_task_and_return(self) -> ConfigFlowResult:
|
||||||
"""Save the current task and return to task menu."""
|
"""Save the current task and return to task menu."""
|
||||||
from homeassistant.util import dt as dt_util
|
from .config_flow_schedule import build_new_task_record
|
||||||
|
|
||||||
from .helpers.sanitize import cap_task_fields, parse_labels_text
|
|
||||||
|
|
||||||
task_id = self._current_task.get("id", uuid4().hex)
|
task_id = self._current_task.get("id", uuid4().hex)
|
||||||
task_data = {
|
task_data = build_new_task_record(
|
||||||
"id": task_id,
|
self._current_task,
|
||||||
"object_id": self._object_data.get("id", ""),
|
task_id=task_id,
|
||||||
"name": self._current_task.get(CONF_TASK_NAME, ""),
|
object_id=self._object_data.get("id", ""),
|
||||||
"type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM),
|
hass=self.hass,
|
||||||
"enabled": True,
|
# No entry (and thus no Store) exists yet during setup — history and
|
||||||
"schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED),
|
# a backdated last_performed must ride entry.data.
|
||||||
"warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)),
|
seed_history=True,
|
||||||
"history": [],
|
include_last_performed=True,
|
||||||
# Anchor for next_due fallback when last_performed is None (issue #30).
|
)
|
||||||
"created_at": dt_util.now().date().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calendar kinds carry a pre-built nested schedule; create_entry
|
|
||||||
# normalizes it (treated as authoritative over the flat fields).
|
|
||||||
if "schedule" in self._current_task:
|
|
||||||
task_data["schedule"] = self._current_task["schedule"]
|
|
||||||
|
|
||||||
if CONF_TASK_INTERVAL_DAYS in self._current_task:
|
|
||||||
task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS])
|
|
||||||
if CONF_TASK_INTERVAL_UNIT in self._current_task:
|
|
||||||
task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT]
|
|
||||||
if CONF_TASK_DUE_DATE in self._current_task:
|
|
||||||
task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE]
|
|
||||||
if "last_performed" in self._current_task:
|
|
||||||
task_data["last_performed"] = self._current_task["last_performed"]
|
|
||||||
if "trigger_config" in self._current_task:
|
|
||||||
task_data["trigger_config"] = self._current_task["trigger_config"]
|
|
||||||
if CONF_TASK_NOTES in self._current_task:
|
|
||||||
task_data["notes"] = self._current_task[CONF_TASK_NOTES]
|
|
||||||
if CONF_TASK_ICON in self._current_task:
|
|
||||||
task_data["custom_icon"] = self._current_task[CONF_TASK_ICON]
|
|
||||||
if CONF_TASK_PRIORITY in self._current_task:
|
|
||||||
task_data["priority"] = self._current_task[CONF_TASK_PRIORITY]
|
|
||||||
if self._current_task.get(CONF_TASK_LABELS_TEXT):
|
|
||||||
task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT])
|
|
||||||
|
|
||||||
cap_task_fields(task_data)
|
|
||||||
self._tasks[task_id] = task_data
|
self._tasks[task_id] = task_data
|
||||||
self._current_task = {}
|
self._current_task = {}
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ def interval_unit_selector() -> selector.SelectSelector:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def interval_anchor_selector() -> selector.SelectSelector:
|
||||||
|
"""Shared completion/planned anchor dropdown (issue #30).
|
||||||
|
|
||||||
|
Single source for the add-task time-based step (both flows via
|
||||||
|
ScheduleStepsMixin) and the task-edit form."""
|
||||||
|
return selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=[
|
||||||
|
selector.SelectOptionDict(value="completion", label="From completion date"),
|
||||||
|
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
|
||||||
|
],
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> None:
|
def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> None:
|
||||||
"""Persist ``interval_unit`` from a flow step into ``target`` only when it
|
"""Persist ``interval_unit`` from a flow step into ``target`` only when it
|
||||||
differs from the implicit default ``days`` (keeps stored task dicts minimal).
|
differs from the implicit default ``days`` (keeps stored task dicts minimal).
|
||||||
|
|||||||
@@ -1,43 +1,16 @@
|
|||||||
"""Add-task + schedule-kind steps (mixin)."""
|
"""Add-task + schedule-kind steps (mixin).
|
||||||
|
|
||||||
|
Thin wrappers: the step content lives in config_flow_schedule.ScheduleStepsMixin,
|
||||||
|
shared verbatim with the setup wizard (config_flow.py) so the two can't drift.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
from homeassistant.config_entries import ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlowResult
|
||||||
from homeassistant.helpers import selector
|
|
||||||
|
|
||||||
from .config_flow_helpers import (
|
from .config_flow_schedule import ScheduleStepsMixin
|
||||||
CALENDAR_KIND_VALUES,
|
|
||||||
apply_interval_unit,
|
|
||||||
calendar_schema,
|
|
||||||
interval_unit_selector,
|
|
||||||
schedule_from_calendar_input,
|
|
||||||
)
|
|
||||||
from .const import (
|
|
||||||
CONF_TASK_DUE_DATE,
|
|
||||||
CONF_TASK_ICON,
|
|
||||||
CONF_TASK_INTERVAL_ANCHOR,
|
|
||||||
CONF_TASK_INTERVAL_DAYS,
|
|
||||||
CONF_TASK_INTERVAL_UNIT,
|
|
||||||
CONF_TASK_LABELS_TEXT,
|
|
||||||
CONF_TASK_NAME,
|
|
||||||
CONF_TASK_NOTES,
|
|
||||||
CONF_TASK_PRIORITY,
|
|
||||||
CONF_TASK_READING_UNIT,
|
|
||||||
CONF_TASK_SCHEDULE_TYPE,
|
|
||||||
CONF_TASK_TYPE,
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
DEFAULT_INTERVAL_DAYS,
|
|
||||||
MaintenanceTypeEnum,
|
|
||||||
ScheduleType,
|
|
||||||
)
|
|
||||||
from .helpers.global_options import get_default_warning_days
|
|
||||||
from .helpers.schedule import (
|
|
||||||
KIND_WEEKDAYS,
|
|
||||||
)
|
|
||||||
from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
@@ -45,7 +18,7 @@ if TYPE_CHECKING:
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
class AddTaskMixin:
|
class AddTaskMixin(ScheduleStepsMixin):
|
||||||
"""Add a new task and pick its schedule kind."""
|
"""Add a new task and pick its schedule kind."""
|
||||||
|
|
||||||
# -- provided by the assembled MaintenanceOptionsFlow --
|
# -- provided by the assembled MaintenanceOptionsFlow --
|
||||||
@@ -58,257 +31,57 @@ class AddTaskMixin:
|
|||||||
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
|
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
|
||||||
async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: ...
|
async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: ...
|
||||||
|
|
||||||
|
def _wire_add_task_callbacks(self) -> None:
|
||||||
|
"""Route the sensor-trigger sub-flow's completion/cancel to this flow."""
|
||||||
|
self._trigger_on_complete = self._save_new_task
|
||||||
|
self._on_cancel = self._show_init_menu
|
||||||
|
|
||||||
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Add a new task — step 1: name, type, schedule."""
|
"""Add a new task — step 1: name, type, schedule."""
|
||||||
if user_input is not None:
|
return await self._schedule_add_task(
|
||||||
if user_input.get("go_back"):
|
user_input,
|
||||||
return self._show_init_menu()
|
|
||||||
|
|
||||||
self._current_task = {
|
|
||||||
CONF_TASK_NAME: user_input[CONF_TASK_NAME],
|
|
||||||
CONF_TASK_TYPE: user_input.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CLEANING),
|
|
||||||
CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE],
|
|
||||||
}
|
|
||||||
if user_input.get(CONF_TASK_ICON):
|
|
||||||
self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON]
|
|
||||||
if user_input.get(CONF_TASK_PRIORITY):
|
|
||||||
self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY]
|
|
||||||
if user_input.get(CONF_TASK_LABELS_TEXT):
|
|
||||||
self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT]
|
|
||||||
if user_input.get(CONF_TASK_READING_UNIT):
|
|
||||||
self._current_task[CONF_TASK_READING_UNIT] = user_input[CONF_TASK_READING_UNIT].strip()
|
|
||||||
|
|
||||||
self._trigger_on_complete = self._save_new_task
|
|
||||||
self._on_cancel = self._show_init_menu
|
|
||||||
|
|
||||||
schedule = user_input[CONF_TASK_SCHEDULE_TYPE]
|
|
||||||
if schedule == ScheduleType.TIME_BASED:
|
|
||||||
return await self.async_step_opt_time_based()
|
|
||||||
if schedule in CALENDAR_KIND_VALUES:
|
|
||||||
return await self.async_step_opt_calendar()
|
|
||||||
if schedule == ScheduleType.SENSOR_BASED:
|
|
||||||
return await self.async_step_opt_sensor_select()
|
|
||||||
if schedule == ScheduleType.ONE_TIME:
|
|
||||||
return await self.async_step_opt_one_time()
|
|
||||||
# Manual
|
|
||||||
return await self.async_step_opt_manual()
|
|
||||||
|
|
||||||
type_options = [t.value for t in MaintenanceTypeEnum]
|
|
||||||
# Recurrence kinds: time-based, the calendar kinds (Phase 4), then the
|
|
||||||
# trigger/one-time/manual kinds.
|
|
||||||
schedule_options = [
|
|
||||||
ScheduleType.TIME_BASED,
|
|
||||||
*CALENDAR_KIND_VALUES,
|
|
||||||
ScheduleType.SENSOR_BASED,
|
|
||||||
ScheduleType.ONE_TIME,
|
|
||||||
ScheduleType.MANUAL,
|
|
||||||
]
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="add_task",
|
step_id="add_task",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self._show_init_menu,
|
||||||
{
|
time_based_step=self.async_step_opt_time_based,
|
||||||
vol.Required(CONF_TASK_NAME): selector.TextSelector(
|
calendar_step=self.async_step_opt_calendar,
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
sensor_step=self.async_step_opt_sensor_select,
|
||||||
),
|
one_time_step=self.async_step_opt_one_time,
|
||||||
vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector(
|
manual_step=self.async_step_opt_manual,
|
||||||
selector.SelectSelectorConfig(
|
before_dispatch=self._wire_add_task_callbacks,
|
||||||
options=type_options,
|
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
||||||
translation_key="maintenance_type",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=schedule_options,
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="schedule_type",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_ICON): selector.IconSelector(),
|
|
||||||
vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=list(TASK_PRIORITIES),
|
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
||||||
translation_key="task_priority",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³").
|
|
||||||
vol.Optional(CONF_TASK_READING_UNIT): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_opt_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_opt_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure time-based schedule for new task."""
|
"""Configure time-based schedule for new task."""
|
||||||
errors: dict[str, str] = {}
|
return await self._schedule_time_based(
|
||||||
|
user_input,
|
||||||
if user_input is not None:
|
|
||||||
if user_input.get("go_back"):
|
|
||||||
return self._show_init_menu()
|
|
||||||
|
|
||||||
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
|
|
||||||
if not interval or interval <= 0:
|
|
||||||
errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval"
|
|
||||||
else:
|
|
||||||
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
|
|
||||||
apply_interval_unit(self._current_task, user_input)
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
self._current_task[CONF_TASK_INTERVAL_ANCHOR] = user_input.get(CONF_TASK_INTERVAL_ANCHOR, "completion")
|
|
||||||
last_performed = user_input.get("last_performed")
|
|
||||||
if last_performed:
|
|
||||||
self._current_task["last_performed"] = str(last_performed)
|
|
||||||
|
|
||||||
return self._save_new_task()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="opt_time_based",
|
step_id="opt_time_based",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self._show_init_menu,
|
||||||
{
|
on_complete=self._save_new_task,
|
||||||
vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_ANCHOR, default="completion"): selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="completion", label="From completion date"),
|
|
||||||
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("last_performed"): selector.DateSelector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_opt_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_opt_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure a calendar recurrence kind for a new task."""
|
"""Configure a calendar recurrence kind for a new task."""
|
||||||
errors: dict[str, str] = {}
|
return await self._schedule_calendar(
|
||||||
kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS)
|
user_input,
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
if user_input.get("go_back"):
|
|
||||||
return self._show_init_menu()
|
|
||||||
schedule = schedule_from_calendar_input(kind, user_input)
|
|
||||||
if schedule is None:
|
|
||||||
errors["base"] = "invalid_schedule"
|
|
||||||
else:
|
|
||||||
self._current_task["schedule"] = schedule
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
if user_input.get("last_performed"):
|
|
||||||
self._current_task["last_performed"] = str(user_input["last_performed"])
|
|
||||||
return self._save_new_task()
|
|
||||||
|
|
||||||
schema = calendar_schema(kind).extend(
|
|
||||||
{
|
|
||||||
vol.Optional("last_performed"): selector.DateSelector(),
|
|
||||||
vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="opt_calendar",
|
step_id="opt_calendar",
|
||||||
data_schema=schema,
|
on_go_back=self._show_init_menu,
|
||||||
errors=errors,
|
on_complete=self._save_new_task,
|
||||||
description_placeholders={"kind": kind},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_opt_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_opt_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure a one-time (non-recurring) task for new task."""
|
"""Configure a one-time (non-recurring) task for new task."""
|
||||||
errors: dict[str, str] = {}
|
return await self._schedule_one_time(
|
||||||
|
user_input,
|
||||||
if user_input is not None:
|
|
||||||
if user_input.get("go_back"):
|
|
||||||
return self._show_init_menu()
|
|
||||||
|
|
||||||
due_date = user_input.get(CONF_TASK_DUE_DATE)
|
|
||||||
if not due_date:
|
|
||||||
errors[CONF_TASK_DUE_DATE] = "invalid_due_date"
|
|
||||||
else:
|
|
||||||
self._current_task[CONF_TASK_DUE_DATE] = str(due_date)
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
return self._save_new_task()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="opt_one_time",
|
step_id="opt_one_time",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self._show_init_menu,
|
||||||
{
|
on_complete=self._save_new_task,
|
||||||
vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_opt_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_opt_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure manual schedule for new task."""
|
"""Configure manual schedule for new task."""
|
||||||
if user_input is not None:
|
return await self._schedule_manual(
|
||||||
if user_input.get("go_back"):
|
user_input,
|
||||||
return self._show_init_menu()
|
|
||||||
|
|
||||||
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL
|
|
||||||
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
||||||
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
||||||
)
|
|
||||||
if user_input.get(CONF_TASK_NOTES):
|
|
||||||
self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES]
|
|
||||||
|
|
||||||
return self._save_new_task()
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="opt_manual",
|
step_id="opt_manual",
|
||||||
data_schema=vol.Schema(
|
on_go_back=self._show_init_menu,
|
||||||
{
|
on_complete=self._save_new_task,
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_NOTES): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True)
|
|
||||||
),
|
|
||||||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,25 +17,8 @@ from .const import (
|
|||||||
CONF_ADVANCED_ADAPTIVE,
|
CONF_ADVANCED_ADAPTIVE,
|
||||||
CONF_ADVANCED_CHECKLISTS,
|
CONF_ADVANCED_CHECKLISTS,
|
||||||
CONF_OBJECT,
|
CONF_OBJECT,
|
||||||
CONF_TASK_DUE_DATE,
|
|
||||||
CONF_TASK_ICON,
|
|
||||||
CONF_TASK_INTERVAL_ANCHOR,
|
|
||||||
CONF_TASK_INTERVAL_DAYS,
|
|
||||||
CONF_TASK_INTERVAL_UNIT,
|
|
||||||
CONF_TASK_LABELS_TEXT,
|
|
||||||
CONF_TASK_NAME,
|
|
||||||
CONF_TASK_NOTES,
|
|
||||||
CONF_TASK_PRIORITY,
|
|
||||||
CONF_TASK_SCHEDULE_TYPE,
|
|
||||||
CONF_TASK_TYPE,
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
CONF_TASKS,
|
CONF_TASKS,
|
||||||
DOMAIN,
|
|
||||||
GLOBAL_UNIQUE_ID,
|
|
||||||
MaintenanceTypeEnum,
|
|
||||||
ScheduleType,
|
|
||||||
)
|
)
|
||||||
from .helpers.global_options import get_default_warning_days
|
|
||||||
from .helpers.schedule import (
|
from .helpers.schedule import (
|
||||||
normalize_task_storage,
|
normalize_task_storage,
|
||||||
)
|
)
|
||||||
@@ -69,49 +52,15 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow):
|
|||||||
|
|
||||||
def _save_new_task(self) -> ConfigFlowResult:
|
def _save_new_task(self) -> ConfigFlowResult:
|
||||||
"""Save the current task and return to init."""
|
"""Save the current task and return to init."""
|
||||||
from homeassistant.util import dt as dt_util
|
from .config_flow_schedule import build_new_task_record
|
||||||
|
|
||||||
from .helpers.sanitize import cap_task_fields, parse_labels_text
|
|
||||||
|
|
||||||
task_id = uuid4().hex
|
task_id = uuid4().hex
|
||||||
task_data: dict[str, Any] = {
|
task_data = build_new_task_record(
|
||||||
"id": task_id,
|
self._current_task,
|
||||||
"object_id": self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
task_id=task_id,
|
||||||
"name": self._current_task.get(CONF_TASK_NAME, ""),
|
object_id=self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||||
"type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM),
|
hass=self.hass,
|
||||||
"enabled": True,
|
)
|
||||||
"schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED),
|
|
||||||
"warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)),
|
|
||||||
# Anchor for next_due fallback when last_performed is None (issue #30).
|
|
||||||
"created_at": dt_util.now().date().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calendar kinds carry a pre-built nested schedule; normalize (in
|
|
||||||
# _update_config_entry) treats it as authoritative over the flat fields.
|
|
||||||
if "schedule" in self._current_task:
|
|
||||||
task_data["schedule"] = self._current_task["schedule"]
|
|
||||||
|
|
||||||
if CONF_TASK_INTERVAL_DAYS in self._current_task:
|
|
||||||
task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS])
|
|
||||||
if CONF_TASK_INTERVAL_UNIT in self._current_task:
|
|
||||||
task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT]
|
|
||||||
if CONF_TASK_DUE_DATE in self._current_task:
|
|
||||||
task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE]
|
|
||||||
anchor = self._current_task.get(CONF_TASK_INTERVAL_ANCHOR, "completion")
|
|
||||||
if anchor != "completion":
|
|
||||||
task_data["interval_anchor"] = anchor
|
|
||||||
if "trigger_config" in self._current_task:
|
|
||||||
task_data["trigger_config"] = self._current_task["trigger_config"]
|
|
||||||
if CONF_TASK_NOTES in self._current_task:
|
|
||||||
task_data["notes"] = self._current_task[CONF_TASK_NOTES]
|
|
||||||
if CONF_TASK_ICON in self._current_task:
|
|
||||||
task_data["custom_icon"] = self._current_task[CONF_TASK_ICON]
|
|
||||||
if CONF_TASK_PRIORITY in self._current_task:
|
|
||||||
task_data["priority"] = self._current_task[CONF_TASK_PRIORITY]
|
|
||||||
if self._current_task.get(CONF_TASK_LABELS_TEXT):
|
|
||||||
task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT])
|
|
||||||
|
|
||||||
cap_task_fields(task_data)
|
|
||||||
new_data = dict(self.config_entry.data)
|
new_data = dict(self.config_entry.data)
|
||||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||||||
new_tasks[task_id] = task_data
|
new_tasks[task_id] = task_data
|
||||||
@@ -171,10 +120,9 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow):
|
|||||||
|
|
||||||
def _get_global_options(self) -> dict[str, Any]:
|
def _get_global_options(self) -> dict[str, Any]:
|
||||||
"""Get global options from the global config entry."""
|
"""Get global options from the global config entry."""
|
||||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
from .helpers.global_options import get_global_options
|
||||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
||||||
return dict(entry.options or entry.data)
|
return dict(get_global_options(self.hass))
|
||||||
return {}
|
|
||||||
|
|
||||||
def _build_task_action_menu(self) -> list[str]:
|
def _build_task_action_menu(self) -> list[str]:
|
||||||
"""Build the task_action menu options list."""
|
"""Build the task_action menu options list."""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .config_flow_helpers import (
|
|||||||
apply_season_ends,
|
apply_season_ends,
|
||||||
calendar_current,
|
calendar_current,
|
||||||
calendar_schema,
|
calendar_schema,
|
||||||
|
interval_anchor_selector,
|
||||||
interval_unit_selector,
|
interval_unit_selector,
|
||||||
schedule_from_calendar_input,
|
schedule_from_calendar_input,
|
||||||
season_ends_schema,
|
season_ends_schema,
|
||||||
@@ -385,15 +386,7 @@ class TaskCrudMixin:
|
|||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_TASK_INTERVAL_ANCHOR,
|
CONF_TASK_INTERVAL_ANCHOR,
|
||||||
default=sched["interval_anchor"],
|
default=sched["interval_anchor"],
|
||||||
): selector.SelectSelector(
|
): interval_anchor_selector(),
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="completion", label="From completion date"),
|
|
||||||
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
|
|||||||
@@ -104,6 +104,58 @@ def _recovery_default(tc: dict[str, Any] | None) -> bool:
|
|||||||
return bool((tc or {}).get("auto_complete_on_recovery"))
|
return bool((tc or {}).get("auto_complete_on_recovery"))
|
||||||
|
|
||||||
|
|
||||||
|
def _recovery_field(tc: dict[str, Any] | None) -> dict[Any, Any]:
|
||||||
|
"""The #53 recovery checkbox — one schema entry, six call sites."""
|
||||||
|
return {
|
||||||
|
vol.Optional(
|
||||||
|
"auto_complete_on_recovery",
|
||||||
|
default=_recovery_default(tc),
|
||||||
|
): selector.BooleanSelector()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_logic_field(entity_ids: list[Any]) -> dict[Any, Any]:
|
||||||
|
"""The any/all selector, shown only with 2+ entities — identical on every
|
||||||
|
trigger-type step (and per compound condition)."""
|
||||||
|
if len(entity_ids) < 2:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC): selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=[
|
||||||
|
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
||||||
|
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
||||||
|
],
|
||||||
|
mode=selector.SelectSelectorMode.LIST,
|
||||||
|
translation_key="entity_logic",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _interval_warning_fields(hass: HomeAssistant) -> dict[Any, Any]:
|
||||||
|
"""The safety-interval + warning-days tail shared by all four type steps."""
|
||||||
|
return {
|
||||||
|
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=INTERVAL_DAYS_RANGE[0],
|
||||||
|
max=INTERVAL_DAYS_RANGE[1],
|
||||||
|
step=1,
|
||||||
|
mode=selector.NumberSelectorMode.BOX,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_TASK_WARNING_DAYS,
|
||||||
|
default=get_default_warning_days(hass),
|
||||||
|
): selector.NumberSelector(
|
||||||
|
selector.NumberSelectorConfig(
|
||||||
|
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any:
|
def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any:
|
||||||
"""State field bound to the trigger entity (#129 follow-up).
|
"""State field bound to the trigger entity (#129 follow-up).
|
||||||
|
|
||||||
@@ -460,47 +512,10 @@ class TriggerConfigMixin:
|
|||||||
vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector(
|
vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector(
|
||||||
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
**_recovery_field(self._current_task.get("trigger_config")),
|
||||||
"auto_complete_on_recovery",
|
|
||||||
default=_recovery_default(self._current_task.get("trigger_config")),
|
|
||||||
): selector.BooleanSelector(),
|
|
||||||
}
|
}
|
||||||
|
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||||
# Add entity_logic selector when multiple entities are selected
|
schema_fields.update(_interval_warning_fields(self.hass))
|
||||||
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
||||||
if len(entity_ids) > 1:
|
|
||||||
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
||||||
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="entity_logic",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_fields.update(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=INTERVAL_DAYS_RANGE[0],
|
|
||||||
max=INTERVAL_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -583,47 +598,10 @@ class TriggerConfigMixin:
|
|||||||
step="any",
|
step="any",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
**_recovery_field(prev_tc),
|
||||||
"auto_complete_on_recovery",
|
|
||||||
default=_recovery_default(prev_tc),
|
|
||||||
): selector.BooleanSelector(),
|
|
||||||
}
|
}
|
||||||
|
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||||
# Add entity_logic selector when multiple entities are selected
|
schema_fields.update(_interval_warning_fields(self.hass))
|
||||||
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
||||||
if len(entity_ids) > 1:
|
|
||||||
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
||||||
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="entity_logic",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_fields.update(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=INTERVAL_DAYS_RANGE[0],
|
|
||||||
max=INTERVAL_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -688,47 +666,10 @@ class TriggerConfigMixin:
|
|||||||
mode=selector.NumberSelectorMode.BOX,
|
mode=selector.NumberSelectorMode.BOX,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
**_recovery_field(self._current_task.get("trigger_config")),
|
||||||
"auto_complete_on_recovery",
|
|
||||||
default=_recovery_default(self._current_task.get("trigger_config")),
|
|
||||||
): selector.BooleanSelector(),
|
|
||||||
}
|
}
|
||||||
|
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||||
# Add entity_logic selector when multiple entities are selected
|
schema_fields.update(_interval_warning_fields(self.hass))
|
||||||
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
||||||
if len(entity_ids) > 1:
|
|
||||||
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
||||||
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="entity_logic",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_fields.update(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=INTERVAL_DAYS_RANGE[0],
|
|
||||||
max=INTERVAL_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -796,47 +737,10 @@ class TriggerConfigMixin:
|
|||||||
vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector(
|
vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector(
|
||||||
self._trigger_entity_id, multiple=True
|
self._trigger_entity_id, multiple=True
|
||||||
),
|
),
|
||||||
vol.Optional(
|
**_recovery_field(current_tc),
|
||||||
"auto_complete_on_recovery",
|
|
||||||
default=_recovery_default(current_tc),
|
|
||||||
): selector.BooleanSelector(),
|
|
||||||
}
|
}
|
||||||
|
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||||
# Add entity_logic selector when multiple entities are selected
|
schema_fields.update(_interval_warning_fields(self.hass))
|
||||||
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
||||||
if len(entity_ids) > 1:
|
|
||||||
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
||||||
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="entity_logic",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_fields.update(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=INTERVAL_DAYS_RANGE[0],
|
|
||||||
max=INTERVAL_DAYS_RANGE[1],
|
|
||||||
step=1,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_TASK_WARNING_DAYS,
|
|
||||||
default=get_default_warning_days(self.hass),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -893,10 +797,7 @@ class TriggerConfigMixin:
|
|||||||
translation_key="compound_logic",
|
translation_key="compound_logic",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
**_recovery_field(self._current_task.get("trigger_config")),
|
||||||
"auto_complete_on_recovery",
|
|
||||||
default=_recovery_default(self._current_task.get("trigger_config")),
|
|
||||||
): selector.BooleanSelector(),
|
|
||||||
}
|
}
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
@@ -1104,18 +1005,7 @@ class TriggerConfigMixin:
|
|||||||
): _state_selector(cond.get("entity_id"), multiple=True),
|
): _state_selector(cond.get("entity_id"), multiple=True),
|
||||||
}
|
}
|
||||||
|
|
||||||
entity_ids = cond.get("entity_ids", [])
|
schema_fields.update(_entity_logic_field(cond.get("entity_ids", [])))
|
||||||
if len(entity_ids) > 1:
|
|
||||||
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
||||||
selector.SelectSelectorConfig(
|
|
||||||
options=[
|
|
||||||
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
||||||
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
||||||
],
|
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
|
||||||
translation_key="entity_logic",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ def slugify_object_name(name: str) -> str:
|
|||||||
return slug
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def task_unique_id(object_slug: str, task_id: str, suffix: str = "") -> str:
|
||||||
|
"""The unique_id of a per-task entity:
|
||||||
|
``maintenance_supporter_{slug}_{task_id}[_suffix]``.
|
||||||
|
|
||||||
|
ONE formatter for every builder — the sensor/binary_sensor/button
|
||||||
|
platforms, the WS entity-id resolver, and the logbook each hand-formatted
|
||||||
|
this string, so changing the scheme in one place silently broke the
|
||||||
|
others' registry lookups. (The parse-side consumers — entity_rename and
|
||||||
|
the service-target resolver — key off the same prefix/suffix contract.)
|
||||||
|
"""
|
||||||
|
base = f"maintenance_supporter_{object_slug}_{task_id}"
|
||||||
|
return f"{base}_{suffix}" if suffix else base
|
||||||
|
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
|||||||
from .const import (
|
from .const import (
|
||||||
BUDGET_CACHE_KEY,
|
BUDGET_CACHE_KEY,
|
||||||
BUDGET_CURRENCIES,
|
BUDGET_CURRENCIES,
|
||||||
CONF_ADVANCED_SCHEDULE_TIME,
|
|
||||||
CONF_BUDGET_ALERT_THRESHOLD,
|
CONF_BUDGET_ALERT_THRESHOLD,
|
||||||
CONF_BUDGET_ALERTS_ENABLED,
|
CONF_BUDGET_ALERTS_ENABLED,
|
||||||
CONF_BUDGET_CURRENCY,
|
CONF_BUDGET_CURRENCY,
|
||||||
@@ -38,7 +36,6 @@ from .const import (
|
|||||||
EVENT_TASK_COMPLETED,
|
EVENT_TASK_COMPLETED,
|
||||||
EVENT_TASK_RESET,
|
EVENT_TASK_RESET,
|
||||||
EVENT_TASK_SKIPPED,
|
EVENT_TASK_SKIPPED,
|
||||||
GLOBAL_UNIQUE_ID,
|
|
||||||
MANUAL_COMPLETION_DEDUP_SECONDS,
|
MANUAL_COMPLETION_DEDUP_SECONDS,
|
||||||
MISSING_ENTITY_THRESHOLD_REFRESHES,
|
MISSING_ENTITY_THRESHOLD_REFRESHES,
|
||||||
NOTIFICATION_MANAGER_KEY,
|
NOTIFICATION_MANAGER_KEY,
|
||||||
@@ -51,6 +48,8 @@ from .const import (
|
|||||||
TriggerEntityState,
|
TriggerEntityState,
|
||||||
)
|
)
|
||||||
from .helpers.budget import compute_spend
|
from .helpers.budget import compute_spend
|
||||||
|
from .helpers.entry_tasks import write_task
|
||||||
|
from .helpers.global_options import get_global_options, is_schedule_time_enabled
|
||||||
from .helpers.schedule import normalize_task_storage, read_legacy_fields
|
from .helpers.schedule import normalize_task_storage, read_legacy_fields
|
||||||
from .models.maintenance_object import MaintenanceObject
|
from .models.maintenance_object import MaintenanceObject
|
||||||
from .models.maintenance_task import MaintenanceTask
|
from .models.maintenance_task import MaintenanceTask
|
||||||
@@ -59,6 +58,24 @@ from .storage import MaintenanceStore
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _inert_task_result(task: MaintenanceTask, status: str, **extra: Any) -> dict[str, Any]:
|
||||||
|
"""Coordinator payload for a task that gets NO live evaluation (archived /
|
||||||
|
paused): due fields nulled, trigger off, only cost/history-derived fields
|
||||||
|
surfaced. The archived and paused short-circuits were hand-copied twins."""
|
||||||
|
task_result = task.to_dict()
|
||||||
|
task_result["_status"] = status
|
||||||
|
task_result["_days_until_due"] = None
|
||||||
|
task_result["_next_due"] = None
|
||||||
|
task_result["_is_done"] = task.is_done
|
||||||
|
task_result["_trigger_active"] = False
|
||||||
|
task_result["_times_performed"] = task.times_performed
|
||||||
|
task_result["_total_cost"] = task.total_cost
|
||||||
|
task_result["_average_duration"] = task.average_duration
|
||||||
|
task_result["_last_entry"] = task.last_entry
|
||||||
|
task_result.update(extra)
|
||||||
|
return task_result
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""Coordinator for a single maintenance object and its tasks."""
|
"""Coordinator for a single maintenance object and its tasks."""
|
||||||
|
|
||||||
@@ -96,18 +113,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._trigger_entity_states: dict[str, str] = {} # task_id -> TriggerEntityState
|
self._trigger_entity_states: dict[str, str] = {} # task_id -> TriggerEntityState
|
||||||
|
|
||||||
def _is_schedule_time_feature_enabled(self) -> bool:
|
def _is_schedule_time_feature_enabled(self) -> bool:
|
||||||
"""Return True iff the global advanced flag for time-of-day scheduling is on.
|
"""Return True iff the global advanced flag for time-of-day scheduling is on."""
|
||||||
|
return is_schedule_time_enabled(self.hass)
|
||||||
Reads the global config entry's options on every call. Cheap (just a
|
|
||||||
dict lookup over the small list of integration entries) and means a
|
|
||||||
toggle in Settings takes effect on the next coordinator refresh
|
|
||||||
without needing a restart.
|
|
||||||
"""
|
|
||||||
for ce in self.hass.config_entries.async_entries(DOMAIN):
|
|
||||||
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
|
||||||
opts = ce.options or ce.data
|
|
||||||
return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _in_startup_grace_period(self) -> bool:
|
def _in_startup_grace_period(self) -> bool:
|
||||||
"""Return True if still within the startup grace period."""
|
"""Return True if still within the startup grace period."""
|
||||||
@@ -195,17 +202,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# are surfaced (budget keeps counting; the detail view still renders
|
# are surfaced (budget keeps counting; the detail view still renders
|
||||||
# the record).
|
# the record).
|
||||||
if task.archived_at is not None:
|
if task.archived_at is not None:
|
||||||
task_result = task.to_dict()
|
result[CONF_TASKS][task_id] = _inert_task_result(task, MaintenanceStatus.ARCHIVED)
|
||||||
task_result["_status"] = MaintenanceStatus.ARCHIVED
|
|
||||||
task_result["_days_until_due"] = None
|
|
||||||
task_result["_next_due"] = None
|
|
||||||
task_result["_is_done"] = task.is_done
|
|
||||||
task_result["_trigger_active"] = False
|
|
||||||
task_result["_times_performed"] = task.times_performed
|
|
||||||
task_result["_total_cost"] = task.total_cost
|
|
||||||
task_result["_average_duration"] = task.average_duration
|
|
||||||
task_result["_last_entry"] = task.last_entry
|
|
||||||
result[CONF_TASKS][task_id] = task_result
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# v2.20 (N3): tasks of a paused object are frozen — status PAUSED,
|
# v2.20 (N3): tasks of a paused object are frozen — status PAUSED,
|
||||||
@@ -214,18 +211,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# first-class citizen in every view. `_paused` mirrors the status
|
# first-class citizen in every view. `_paused` mirrors the status
|
||||||
# for the dict-twin recomputation in helpers.status.
|
# for the dict-twin recomputation in helpers.status.
|
||||||
if object_paused:
|
if object_paused:
|
||||||
task_result = task.to_dict()
|
result[CONF_TASKS][task_id] = _inert_task_result(task, MaintenanceStatus.PAUSED, _paused=True)
|
||||||
task_result["_status"] = MaintenanceStatus.PAUSED
|
|
||||||
task_result["_paused"] = True
|
|
||||||
task_result["_days_until_due"] = None
|
|
||||||
task_result["_next_due"] = None
|
|
||||||
task_result["_is_done"] = task.is_done
|
|
||||||
task_result["_trigger_active"] = False
|
|
||||||
task_result["_times_performed"] = task.times_performed
|
|
||||||
task_result["_total_cost"] = task.total_cost
|
|
||||||
task_result["_average_duration"] = task.average_duration
|
|
||||||
task_result["_last_entry"] = task.last_entry
|
|
||||||
result[CONF_TASKS][task_id] = task_result
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Restore live trigger state from previous coordinator data
|
# Restore live trigger state from previous coordinator data
|
||||||
@@ -719,7 +705,6 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# BEFORE the bundle threshold, like the vacation filter above. A stale
|
# BEFORE the bundle threshold, like the vacation filter above. A stale
|
||||||
# view id (view deleted) means no scope, never "silence everything".
|
# view id (view deleted) means no scope, never "silence everything".
|
||||||
from .const import CONF_NOTIFY_SCOPE_VIEW_ID
|
from .const import CONF_NOTIFY_SCOPE_VIEW_ID
|
||||||
from .helpers.global_options import get_global_options
|
|
||||||
|
|
||||||
scope_view_id = get_global_options(self.hass).get(CONF_NOTIFY_SCOPE_VIEW_ID) or ""
|
scope_view_id = get_global_options(self.hass).get(CONF_NOTIFY_SCOPE_VIEW_ID) or ""
|
||||||
if scope_view_id:
|
if scope_view_id:
|
||||||
@@ -739,15 +724,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
|
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
|
||||||
CONF_NOTIFICATION_BUNDLING_ENABLED,
|
CONF_NOTIFICATION_BUNDLING_ENABLED,
|
||||||
GLOBAL_UNIQUE_ID,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
global_options: Mapping[str, Any] = {}
|
global_options = get_global_options(self.hass)
|
||||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
|
||||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
||||||
global_options = entry.options or entry.data
|
|
||||||
break
|
|
||||||
|
|
||||||
bundling_enabled = global_options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False)
|
bundling_enabled = global_options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False)
|
||||||
bundle_threshold = int(global_options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2))
|
bundle_threshold = int(global_options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2))
|
||||||
|
|
||||||
@@ -810,12 +789,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if not isinstance(nm, NotificationManager) or not nm.enabled:
|
if not isinstance(nm, NotificationManager) or not nm.enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
global_options: Mapping[str, Any] = {}
|
global_options = get_global_options(self.hass)
|
||||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
|
||||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
||||||
global_options = entry.options or entry.data
|
|
||||||
break
|
|
||||||
|
|
||||||
if not global_options.get(CONF_BUDGET_ALERTS_ENABLED, False):
|
if not global_options.get(CONF_BUDGET_ALERTS_ENABLED, False):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1036,16 +1010,11 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
)
|
)
|
||||||
enriched_used: list[dict[str, Any]] | None = None
|
enriched_used: list[dict[str, Any]] | None = None
|
||||||
if used_parts is not None or record_links:
|
if used_parts is not None or record_links:
|
||||||
own_catalog = self.entry.data.get("parts") or {}
|
|
||||||
|
|
||||||
def _part_name(link: dict[str, Any]) -> str:
|
def _part_name(link: dict[str, Any]) -> str:
|
||||||
owner_id = link.get("entry_id")
|
from .parts_runtime import part_link_name
|
||||||
catalog: dict[str, Any] = own_catalog
|
|
||||||
if owner_id and owner_id != self.entry.entry_id:
|
return part_link_name(self.hass, self.entry, link)
|
||||||
owner = self.hass.config_entries.async_get_entry(owner_id)
|
|
||||||
catalog = (owner.data.get("parts") or {}) if owner else {}
|
|
||||||
name = (catalog.get(link["part_id"]) or {}).get("name")
|
|
||||||
return str(name) if name else str(link["part_id"])
|
|
||||||
|
|
||||||
enriched_used = [
|
enriched_used = [
|
||||||
{
|
{
|
||||||
@@ -1110,13 +1079,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# writes then loses only the rotation — the completion is recorded,
|
# writes then loses only the rotation — the completion is recorded,
|
||||||
# and a retried completion can't double-advance the pointer.
|
# and a retried completion can't double-advance the pointer.
|
||||||
if task.responsible_user_id != pre_rotation_responsible:
|
if task.responsible_user_id != pre_rotation_responsible:
|
||||||
new_data = dict(self.entry.data)
|
td = dict(self.entry.data.get(CONF_TASKS, {}).get(task_id, {}))
|
||||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
|
||||||
td = dict(new_tasks.get(task_id, {}))
|
|
||||||
td["responsible_user_id"] = task.responsible_user_id
|
td["responsible_user_id"] = task.responsible_user_id
|
||||||
new_tasks[task_id] = td
|
write_task(self.hass, self.entry, task_id, td)
|
||||||
new_data[CONF_TASKS] = new_tasks
|
|
||||||
self.hass.config_entries.async_update_entry(self.entry, data=new_data)
|
|
||||||
|
|
||||||
# Invalidate budget cache when a cost is recorded
|
# Invalidate budget cache when a cost is recorded
|
||||||
if cost is not None:
|
if cost is not None:
|
||||||
|
|||||||
@@ -66,12 +66,10 @@ async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: Mainten
|
|||||||
diag["overview"] = _get_integration_overview(hass)
|
diag["overview"] = _get_integration_overview(hass)
|
||||||
else:
|
else:
|
||||||
# Object entry diagnostics — merge Store dynamic data for stats
|
# Object entry diagnostics — merge Store dynamic data for stats
|
||||||
runtime_data = getattr(entry, "runtime_data", None)
|
from .helpers.aggregate import merged_tasks
|
||||||
store = getattr(runtime_data, "store", None) if runtime_data else None
|
|
||||||
static_tasks = entry.data.get(CONF_TASKS, {})
|
|
||||||
merged_tasks = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
|
|
||||||
merged_data = dict(entry.data)
|
merged_data = dict(entry.data)
|
||||||
merged_data[CONF_TASKS] = merged_tasks
|
merged_data[CONF_TASKS] = merged_tasks(entry)
|
||||||
|
|
||||||
diag["statistics"] = _calculate_statistics(merged_data)
|
diag["statistics"] = _calculate_statistics(merged_data)
|
||||||
diag["trigger_status"] = _check_trigger_status(hass, entry.data)
|
diag["trigger_status"] = _check_trigger_status(hass, entry.data)
|
||||||
|
|||||||
@@ -79,10 +79,16 @@ class RuntimeTrigger(BaseTrigger):
|
|||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Set up runtime trigger with state restoration."""
|
"""Set up runtime trigger with state restoration."""
|
||||||
state = self.hass.states.get(self.entity_id)
|
state = self.hass.states.get(self.entity_id)
|
||||||
if state is None:
|
if state is None or state.state in ("unavailable", "unknown"):
|
||||||
|
# No USABLE state yet (#131 family): "unavailable" must not read
|
||||||
|
# as OFF — a running device whose sensor merely connects late
|
||||||
|
# would lose its restored on_since anchor and undercount. Keep
|
||||||
|
# the restored tracking state untouched and let the first real
|
||||||
|
# state event decide.
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Runtime trigger entity %s not yet available — listener registered, waiting for entity to appear",
|
"Runtime trigger entity %s not ready at setup (state=%s) — listener registered, waiting for a real state",
|
||||||
self.entity_id,
|
self.entity_id,
|
||||||
|
state.state if state else "missing",
|
||||||
)
|
)
|
||||||
self._unsub_listener = async_track_state_change_event(
|
self._unsub_listener = async_track_state_change_event(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class StateChangeTrigger(BaseTrigger):
|
|||||||
Triggers when count reaches target_changes.
|
Triggers when count reaches target_changes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Setup saw no usable state -> reconcile on the first real one (#131).
|
||||||
|
# Class default so hand-built test instances inherit it.
|
||||||
|
_needs_latch_reconcile: bool = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -52,6 +56,7 @@ class StateChangeTrigger(BaseTrigger):
|
|||||||
self._change_count: int = trigger_config.get("trigger_change_count", 0)
|
self._change_count: int = trigger_config.get("trigger_change_count", 0)
|
||||||
self._current_value = float(self._change_count)
|
self._current_value = float(self._change_count)
|
||||||
self._last_state: str | None = None
|
self._last_state: str | None = None
|
||||||
|
self._needs_latch_reconcile = False
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Set up state change trigger.
|
"""Set up state change trigger.
|
||||||
@@ -61,40 +66,25 @@ class StateChangeTrigger(BaseTrigger):
|
|||||||
appears (old_state=None), so the trigger will self-heal automatically.
|
appears (old_state=None), so the trigger will self-heal automatically.
|
||||||
"""
|
"""
|
||||||
state = self.hass.states.get(self.entity_id)
|
state = self.hass.states.get(self.entity_id)
|
||||||
if state is None:
|
if state is None or state.state in ("unavailable", "unknown"):
|
||||||
|
# No USABLE state yet — the #131 family: trigger setup races both
|
||||||
|
# the entity's registration AND its device readiness (a Zigbee /
|
||||||
|
# Z-Wave problem sensor restores as unavailable long before it
|
||||||
|
# reports). "unavailable" must never read as "recovered" — it
|
||||||
|
# used to quietly clear a single-shot latch right here. Register
|
||||||
|
# the listener and defer the latch reconciliation to the first
|
||||||
|
# real state.
|
||||||
|
self._needs_latch_reconcile = True
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Trigger entity %s not yet available — listener registered, waiting for entity to appear",
|
"Trigger entity %s not ready at setup (state=%s) — listener registered, latch check deferred",
|
||||||
self.entity_id,
|
self.entity_id,
|
||||||
|
state.state if state else "missing",
|
||||||
)
|
)
|
||||||
# Register listener anyway so we catch the entity appearing
|
|
||||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._last_state = state.state
|
self._last_state = state.state
|
||||||
|
self._reconcile_persisted_latch(state.state)
|
||||||
# Restore triggered state from persisted change count
|
|
||||||
if self._change_count >= self._target_changes:
|
|
||||||
# Latch reconciliation: a single-shot state alarm (target_changes
|
|
||||||
# == 1) that already left its alert state while we were down is no
|
|
||||||
# longer active. Clear it quietly — the recovery transition was
|
|
||||||
# never observed, so we must NOT auto-complete for it here (that
|
|
||||||
# path only runs on a live off event, guarded against double-count).
|
|
||||||
if (
|
|
||||||
self._to_state is not None
|
|
||||||
and self._target_changes == 1
|
|
||||||
and _norm_state(state.state) != self._to_state
|
|
||||||
):
|
|
||||||
self._change_count = 0
|
|
||||||
self._current_value = 0.0
|
|
||||||
if self.hass.is_running:
|
|
||||||
self.hass.async_create_task(self._persist_change_count())
|
|
||||||
else:
|
|
||||||
self._triggered = True
|
|
||||||
self.entity.async_update_trigger_state(
|
|
||||||
is_triggered=True,
|
|
||||||
current_value=float(self._change_count),
|
|
||||||
trigger_entity_id=self.entity_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register state change listener (override base: we handle events differently)
|
# Register state change listener (override base: we handle events differently)
|
||||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
||||||
@@ -108,6 +98,42 @@ class StateChangeTrigger(BaseTrigger):
|
|||||||
self._to_state,
|
self._to_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _reconcile_persisted_latch(self, live_state: str) -> None:
|
||||||
|
"""Align the persisted change-count latch with the LIVE entity state.
|
||||||
|
|
||||||
|
Runs at setup when the entity already exists, and again when the
|
||||||
|
entity first APPEARS (issue #131): trigger setup races HA's state
|
||||||
|
restoration, so a source that restores later kept a stale latch —
|
||||||
|
a problem sensor still on read OK, and a single-shot alarm that had
|
||||||
|
recovered while we were down stayed triggered.
|
||||||
|
|
||||||
|
A single-shot state alarm (target_changes == 1) whose entity is no
|
||||||
|
longer in its alert state is cleared QUIETLY — the recovery
|
||||||
|
transition was never observed, so we must NOT auto-complete for it
|
||||||
|
here (that path only runs on a live off event, guarded against
|
||||||
|
double-count). Otherwise the latch is restored and repainted.
|
||||||
|
"""
|
||||||
|
if self._change_count < self._target_changes:
|
||||||
|
return
|
||||||
|
if self._to_state is not None and self._target_changes == 1 and _norm_state(live_state) != self._to_state:
|
||||||
|
self._change_count = 0
|
||||||
|
self._current_value = 0.0
|
||||||
|
self._triggered = False
|
||||||
|
if self.hass.is_running:
|
||||||
|
self.hass.async_create_task(self._persist_change_count())
|
||||||
|
self.entity.async_update_trigger_state(
|
||||||
|
is_triggered=False,
|
||||||
|
current_value=0.0,
|
||||||
|
trigger_entity_id=self.entity_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._triggered = True
|
||||||
|
self.entity.async_update_trigger_state(
|
||||||
|
is_triggered=True,
|
||||||
|
current_value=float(self._change_count),
|
||||||
|
trigger_entity_id=self.entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None:
|
def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None:
|
||||||
"""Handle state transition and count matching changes."""
|
"""Handle state transition and count matching changes."""
|
||||||
@@ -128,9 +154,14 @@ class StateChangeTrigger(BaseTrigger):
|
|||||||
new_val,
|
new_val,
|
||||||
)
|
)
|
||||||
self._logged_unavailable = False
|
self._logged_unavailable = False
|
||||||
# Capture initial state but don't count as a transition
|
# Capture initial state but don't count as a transition — and
|
||||||
|
# reconcile the persisted latch against it (issue #131): when the
|
||||||
|
# entity restores AFTER our setup, this appearance is the first
|
||||||
|
# moment the latch can be checked against reality.
|
||||||
if new_val not in ("unavailable", "unknown"):
|
if new_val not in ("unavailable", "unknown"):
|
||||||
|
self._needs_latch_reconcile = False
|
||||||
self._last_state = new_val
|
self._last_state = new_val
|
||||||
|
self._reconcile_persisted_latch(new_val)
|
||||||
return
|
return
|
||||||
|
|
||||||
old_val = old_state.state
|
old_val = old_state.state
|
||||||
@@ -155,6 +186,17 @@ class StateChangeTrigger(BaseTrigger):
|
|||||||
)
|
)
|
||||||
self._logged_unavailable = False
|
self._logged_unavailable = False
|
||||||
|
|
||||||
|
# First REAL state after a setup that saw none/unavailable (#131
|
||||||
|
# family): reconcile the persisted latch against it instead of
|
||||||
|
# counting the restore as a transition. Mid-run unavailability
|
||||||
|
# glitches never set the flag, so their observed recovery still goes
|
||||||
|
# through the normal transition/auto-complete path below.
|
||||||
|
if self._needs_latch_reconcile:
|
||||||
|
self._needs_latch_reconcile = False
|
||||||
|
self._last_state = new_val
|
||||||
|
self._reconcile_persisted_latch(new_val)
|
||||||
|
return
|
||||||
|
|
||||||
# Use _last_state as fallback when old_val is unavailable/unknown
|
# Use _last_state as fallback when old_val is unavailable/unknown
|
||||||
effective_old = old_val
|
effective_old = old_val
|
||||||
if old_val in ("unavailable", "unknown") and self._last_state is not None:
|
if old_val in ("unavailable", "unknown") and self._last_state is not None:
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from .const import (
|
|||||||
CONF_TASKS,
|
CONF_TASKS,
|
||||||
DEFAULT_WARNING_DAYS,
|
DEFAULT_WARNING_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
GLOBAL_UNIQUE_ID,
|
|
||||||
)
|
)
|
||||||
|
from .helpers.aggregate import get_object_entries, merged_tasks
|
||||||
from .helpers.schedule import Schedule, read_legacy_fields
|
from .helpers.schedule import Schedule, read_legacy_fields
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -73,8 +73,7 @@ def _build_export_object(
|
|||||||
# Merge static + Store dynamic data for each task
|
# Merge static + Store dynamic data for each task
|
||||||
rd = getattr(entry, "runtime_data", None)
|
rd = getattr(entry, "runtime_data", None)
|
||||||
store = getattr(rd, "store", None) if rd else None
|
store = getattr(rd, "store", None) if rd else None
|
||||||
static_tasks = entry.data.get(CONF_TASKS, {})
|
tasks_data = merged_tasks(entry)
|
||||||
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
|
|
||||||
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
|
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
@@ -116,6 +115,9 @@ def _build_export_object(
|
|||||||
"entity_slug": tdata.get("entity_slug"),
|
"entity_slug": tdata.get("entity_slug"),
|
||||||
"adaptive_config": tdata.get("adaptive_config"),
|
"adaptive_config": tdata.get("adaptive_config"),
|
||||||
"checklist": tdata.get("checklist") or [],
|
"checklist": tdata.get("checklist") or [],
|
||||||
|
# In-cycle ticks (#73) — merged_tasks overlays them from the Store;
|
||||||
|
# exporting them keeps half-done checklists across backup/restore.
|
||||||
|
"checklist_progress": tdata.get("checklist_progress"),
|
||||||
"schedule_time": tdata.get("schedule_time"),
|
"schedule_time": tdata.get("schedule_time"),
|
||||||
# v2.17+ / #83 task fields — persisted and user-facing, so a JSON
|
# v2.17+ / #83 task fields — persisted and user-facing, so a JSON
|
||||||
# backup must restore them (same field-completeness contract as #67
|
# backup must restore them (same field-completeness contract as #67
|
||||||
@@ -197,15 +199,11 @@ def _build_export_object(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]:
|
# The maintenance OBJECT entries (never the global hub). Shared by every
|
||||||
"""The maintenance OBJECT entries (never the global hub), optionally
|
# exporter so JSON/YAML/CSV apply the same selective-export filter — the
|
||||||
narrowed to a selection. Shared by every exporter so JSON/YAML/CSV apply
|
# implementation lives in helpers.aggregate, this module keeps the name its
|
||||||
the same selective-export filter. ``entry_ids=None`` means all objects."""
|
# importers (csv_handler, doc_archive, WS adopt handlers) bind to.
|
||||||
return [
|
object_entries = get_object_entries
|
||||||
entry
|
|
||||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
|
||||||
if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_export_data(
|
def build_export_data(
|
||||||
@@ -284,3 +282,49 @@ def export_maintenance_data(
|
|||||||
"""
|
"""
|
||||||
data = build_export_data(hass, include_history=include_history)
|
data = build_export_data(hass, include_history=include_history)
|
||||||
return serialize_export(data, fmt)
|
return serialize_export(data, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings the export deliberately leaves behind: HA user ids are
|
||||||
|
# instance-bound (and the panel-access allowlist is security-relevant), and
|
||||||
|
# the adopted-task stash is transient re-adopt state.
|
||||||
|
_NON_PORTABLE_SETTINGS = ("admin_panel_user_ids", "adopted_task_notes")
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_export(hass: HomeAssistant) -> dict[str, Any]:
|
||||||
|
"""The SECOND export: the global entry's settings.
|
||||||
|
|
||||||
|
``build_export_data`` deliberately carries only objects — groups, saved
|
||||||
|
views, vacation config, notification/budget settings and the feature
|
||||||
|
toggles live on the global entry and export through here instead
|
||||||
|
(2026-08 round-trip audit decision). Instance-bound keys are excluded
|
||||||
|
(see ``_NON_PORTABLE_SETTINGS``).
|
||||||
|
"""
|
||||||
|
from .const import (
|
||||||
|
CONF_GROUPS,
|
||||||
|
CONF_SAVED_FILTER_VIEWS,
|
||||||
|
CONF_VACATION_BUFFER_DAYS,
|
||||||
|
CONF_VACATION_ENABLED,
|
||||||
|
CONF_VACATION_END,
|
||||||
|
CONF_VACATION_EXEMPT_TASK_IDS,
|
||||||
|
CONF_VACATION_START,
|
||||||
|
)
|
||||||
|
from .helpers.global_options import get_global_options
|
||||||
|
from .helpers.settings_registry import ALLOWED_SETTING_KEYS
|
||||||
|
|
||||||
|
opts = get_global_options(hass)
|
||||||
|
settings: dict[str, Any] = {
|
||||||
|
k: opts[k] for k in ALLOWED_SETTING_KEYS if k in opts and k not in _NON_PORTABLE_SETTINGS
|
||||||
|
}
|
||||||
|
# Structured sections with their own WS surfaces (not in the registry).
|
||||||
|
for key in (
|
||||||
|
CONF_GROUPS,
|
||||||
|
CONF_SAVED_FILTER_VIEWS,
|
||||||
|
CONF_VACATION_ENABLED,
|
||||||
|
CONF_VACATION_START,
|
||||||
|
CONF_VACATION_END,
|
||||||
|
CONF_VACATION_BUFFER_DAYS,
|
||||||
|
CONF_VACATION_EXEMPT_TASK_IDS,
|
||||||
|
):
|
||||||
|
if key in opts:
|
||||||
|
settings[key] = opts[key]
|
||||||
|
return {"version": 1, "global_settings": settings}
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { UserService } from "../user-service";
|
import { UserService } from "../user-service";
|
||||||
import type { HAUser, HomeAssistant } from "../types";
|
import type { HAUser, HomeAssistant } from "../types";
|
||||||
@@ -55,7 +55,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
|||||||
private _userService: UserService | null = null;
|
private _userService: UserService | null = null;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>): void {
|
updated(changed: Map<string, unknown>): void {
|
||||||
|
|||||||
+6
-15
@@ -6,7 +6,8 @@
|
|||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
|
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
|||||||
private _localeReady = false;
|
private _localeReady = false;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
@@ -232,24 +233,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly _SORT_KEY = "ms_bf_roster_sort";
|
|
||||||
|
|
||||||
private static _storedSort(): "name" | "urgency" {
|
private static _storedSort(): "name" | "urgency" {
|
||||||
try {
|
return lsGet(LS_KEYS.batteryRosterSort) === "name" ? "name" : "urgency";
|
||||||
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
|
|
||||||
return v === "name" ? "name" : "urgency";
|
|
||||||
} catch {
|
|
||||||
return "urgency";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setSort(mode: "name" | "urgency"): void {
|
private _setSort(mode: "name" | "urgency"): void {
|
||||||
this._rosterSort = mode;
|
this._rosterSort = mode;
|
||||||
try {
|
// Storage may be unavailable — the toggle still works for this visit.
|
||||||
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
|
lsSet(LS_KEYS.batteryRosterSort, mode);
|
||||||
} catch {
|
|
||||||
// storage unavailable — the toggle still works for this visit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Urgency (the default, issue #123): low rows first — emptiest first —
|
/** Urgency (the default, issue #123): low rows first — emptiest first —
|
||||||
|
|||||||
+4
-5
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
|
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
|
||||||
|
import { registerCustomCard } from "../helpers/register-card";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||||
import type { BudgetStatus, HomeAssistant } from "../types";
|
import type { BudgetStatus, HomeAssistant } from "../types";
|
||||||
@@ -40,7 +41,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _isAdmin(): boolean {
|
private get _isAdmin(): boolean {
|
||||||
@@ -277,9 +278,7 @@ if (!customElements.get("maintenance-budget-section-card")) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as { customCards?: unknown[] }).customCards =
|
registerCustomCard({
|
||||||
(window as { customCards?: unknown[] }).customCards || [];
|
|
||||||
((window as { customCards?: unknown[] }).customCards!).push({
|
|
||||||
type: "maintenance-budget-section-card",
|
type: "maintenance-budget-section-card",
|
||||||
name: "Maintenance Supporter — Budget",
|
name: "Maintenance Supporter — Budget",
|
||||||
description: "Inline monthly + yearly budget editor",
|
description: "Inline monthly + yearly budget editor",
|
||||||
|
|||||||
+7
-31
@@ -10,10 +10,12 @@
|
|||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { isSafeHttpUrl } from "../helpers/url";
|
import { isSafeHttpUrl } from "../helpers/url";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { downloadUrl } from "../helpers/download";
|
import { downloadUrl } from "../helpers/download";
|
||||||
|
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
|
||||||
import { formatBytes } from "../helpers/format-bytes";
|
import { formatBytes } from "../helpers/format-bytes";
|
||||||
|
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
interface MaintenanceDocument {
|
interface MaintenanceDocument {
|
||||||
@@ -28,16 +30,6 @@ interface MaintenanceDocument {
|
|||||||
added_at?: string;
|
added_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
|
||||||
manual: "mdi:book-open-variant",
|
|
||||||
warranty: "mdi:shield-check",
|
|
||||||
invoice: "mdi:receipt-text-outline",
|
|
||||||
spare_parts: "mdi:cog-outline",
|
|
||||||
photo: "mdi:image-outline",
|
|
||||||
other: "mdi:file-document-outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MaintenanceDocumentsSection extends LitElement {
|
export class MaintenanceDocumentsSection extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@property({ attribute: false }) public entryId!: string;
|
@property({ attribute: false }) public entryId!: string;
|
||||||
@@ -67,16 +59,11 @@ export class MaintenanceDocumentsSection extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _sign(doc: MaintenanceDocument): Promise<string> {
|
private async _sign(doc: MaintenanceDocument): Promise<string> {
|
||||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
return signDocumentPath(this.hass, doc.id);
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 300,
|
|
||||||
});
|
|
||||||
return signed.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>): void {
|
updated(changed: Map<string, unknown>): void {
|
||||||
@@ -210,12 +197,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
|||||||
|
|
||||||
private async _download(doc: MaintenanceDocument): Promise<void> {
|
private async _download(doc: MaintenanceDocument): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 30,
|
|
||||||
});
|
|
||||||
downloadUrl(signed.path, doc.filename || doc.title || "document");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = describeWsError(e, this._lang);
|
this._error = describeWsError(e, this._lang);
|
||||||
}
|
}
|
||||||
@@ -228,15 +210,9 @@ export class MaintenanceDocumentsSection extends LitElement {
|
|||||||
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
|
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
|
|
||||||
// then point it at the freshly signed URL once it resolves.
|
|
||||||
const win = window.open("about:blank", "_blank");
|
|
||||||
try {
|
try {
|
||||||
const url = await this._sign(doc);
|
await openSignedDocument(this.hass, doc.id);
|
||||||
// Absolute URL so it always resolves against the blank popup (about:blank).
|
|
||||||
if (win) win.location.href = new URL(url, window.location.origin).href;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (win) win.close();
|
|
||||||
this._error = describeWsError(e, this._lang);
|
this._error = describeWsError(e, this._lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { t } from "../styles";
|
import { t, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import "./ms-textfield";
|
import "./ms-textfield";
|
||||||
import type {
|
import type {
|
||||||
@@ -26,7 +26,7 @@ export class MaintenanceGroupDialog extends LitElement {
|
|||||||
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
|
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public openCreate(): void {
|
public openCreate(): void {
|
||||||
|
|||||||
+4
-5
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
|
import { registerCustomCard } from "../helpers/register-card";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
@@ -47,7 +48,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _isAdmin(): boolean {
|
private get _isAdmin(): boolean {
|
||||||
@@ -330,9 +331,7 @@ if (!customElements.get("maintenance-groups-section-card")) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as { customCards?: unknown[] }).customCards =
|
registerCustomCard({
|
||||||
(window as { customCards?: unknown[] }).customCards || [];
|
|
||||||
((window as { customCards?: unknown[] }).customCards!).push({
|
|
||||||
type: "maintenance-groups-section-card",
|
type: "maintenance-groups-section-card",
|
||||||
name: "Maintenance Supporter — Groups",
|
name: "Maintenance Supporter — Groups",
|
||||||
description: "Inline group CRUD",
|
description: "Inline group CRUD",
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t } from "../styles";
|
import { t, langOf } from "../styles";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
|||||||
private _originalSnapshot: HistoryEntryDraft | null = null;
|
private _originalSnapshot: HistoryEntryDraft | null = null;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #130: selectable parts + the edited selection (part key -> quantity;
|
// #130: selectable parts + the edited selection (part key -> quantity;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
import { signDocumentPath } from "../helpers/document-url";
|
||||||
|
|
||||||
export class MaintenanceHistoryPhoto extends LitElement {
|
export class MaintenanceHistoryPhoto extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@@ -29,12 +30,7 @@ export class MaintenanceHistoryPhoto extends LitElement {
|
|||||||
|
|
||||||
private async _sign(): Promise<void> {
|
private async _sign(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
this._url = await signDocumentPath(this.hass, this.docId);
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${this.docId}`,
|
|
||||||
expires: 300,
|
|
||||||
});
|
|
||||||
this._url = signed.path;
|
|
||||||
} catch {
|
} catch {
|
||||||
this._failed = true;
|
this._failed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
|
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
|
||||||
import { t } from "../styles";
|
import { t, langOf } from "../styles";
|
||||||
|
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import "./ms-textfield";
|
import "./ms-textfield";
|
||||||
@@ -33,7 +33,7 @@ export class MaintenanceObjectDialog extends LitElement {
|
|||||||
@state() private _entryId: string | null = null; // null = create, string = update
|
@state() private _entryId: string | null = null; // null = create, string = update
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public openCreate(): void {
|
public openCreate(): void {
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@
|
|||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { isSafeHttpUrl } from "../helpers/url";
|
import { isSafeHttpUrl } from "../helpers/url";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, STATUS_COLORS } from "../styles";
|
import { t, STATUS_COLORS, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
|
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
|||||||
@state() private _error = "";
|
@state() private _error = "";
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openFor(entryId: string): Promise<void> {
|
public async openFor(entryId: string): Promise<void> {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { isSafeHttpUrl } from "../helpers/url";
|
import { isSafeHttpUrl } from "../helpers/url";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import type { HomeAssistant, MaintenancePart } from "../types";
|
import type { HomeAssistant, MaintenancePart } from "../types";
|
||||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||||
@@ -70,7 +70,7 @@ export class MaintenancePartsSection extends LitElement {
|
|||||||
@state() private _docsFor: string | null = null;
|
@state() private _docsFor: string | null = null;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.locale?.language || this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public connectedCallback(): void {
|
public connectedCallback(): void {
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@
|
|||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
|
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
|||||||
private _localeReady = false;
|
private _localeReady = false;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>): void {
|
updated(changed: Map<string, unknown>): void {
|
||||||
@@ -171,7 +171,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 340px;
|
min-width: min(360px, calc(100vw - 24px));
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { t } from "../styles";
|
import { t, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export class SeasonalOverridesDialog extends LitElement {
|
|||||||
@state() private _values: string[] = new Array(12).fill("");
|
@state() private _values: string[] = new Array(12).fill("");
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
|
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
|
|||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||||
import { t } from "../styles";
|
import { t, langOf } from "../styles";
|
||||||
|
import { signApiPath } from "../helpers/document-url";
|
||||||
|
import { downloadUrl } from "../helpers/download";
|
||||||
import { UserService } from "../user-service";
|
import { UserService } from "../user-service";
|
||||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||||
import { downloadTextFile } from "../helpers/download";
|
import { downloadTextFile } from "../helpers/download";
|
||||||
@@ -160,7 +162,7 @@ export class MaintenanceSettingsView extends LitElement {
|
|||||||
private _userService: UserService | null = null;
|
private _userService: UserService | null = null;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changedProps: Map<string, unknown>): void {
|
updated(changedProps: Map<string, unknown>): void {
|
||||||
@@ -1430,6 +1432,7 @@ export class MaintenanceSettingsView extends LitElement {
|
|||||||
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
|
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
|
||||||
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
|
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
|
||||||
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
|
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
|
||||||
|
<button @click=${this._exportSettings}>${t("settings_export_settings", L)}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-actions docs-archive-block">
|
<div class="settings-actions docs-archive-block">
|
||||||
<h4>${t("settings_docs_archive", L)}</h4>
|
<h4>${t("settings_docs_archive", L)}</h4>
|
||||||
@@ -1510,6 +1513,22 @@ export class MaintenanceSettingsView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The SECOND export: the global entry's settings (groups, saved views,
|
||||||
|
* vacation, notification/budget settings, feature toggles) — the objects
|
||||||
|
* export deliberately excludes them. Re-import via the regular import. */
|
||||||
|
private async _exportSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.hass.connection.sendMessagePromise({
|
||||||
|
type: "maintenance_supporter/settings/export",
|
||||||
|
}) as { data: string };
|
||||||
|
const ts = new Date().toISOString().slice(0, 10);
|
||||||
|
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
|
||||||
|
this._showToast(t("settings_export_success", this._lang));
|
||||||
|
} catch {
|
||||||
|
this._showToast(t("action_error", this._lang));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _exportYaml(): Promise<void> {
|
private async _exportYaml(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ids = this._selectedEntryIds;
|
const ids = this._selectedEntryIds;
|
||||||
@@ -1573,17 +1592,8 @@ export class MaintenanceSettingsView extends LitElement {
|
|||||||
try {
|
try {
|
||||||
const raw = this._selectedEntryIds;
|
const raw = this._selectedEntryIds;
|
||||||
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
|
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
|
||||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
const signed = await signApiPath(this.hass, `/api/maintenance_supporter/documents/archive${q}`);
|
||||||
type: "auth/sign_path",
|
downloadUrl(signed, "maintenance-documents.zip");
|
||||||
path: `/api/maintenance_supporter/documents/archive${q}`,
|
|
||||||
expires: 300,
|
|
||||||
});
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = signed.path;
|
|
||||||
a.download = "maintenance-documents.zip";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
} catch {
|
} catch {
|
||||||
this._showToast(t("action_error", this._lang));
|
this._showToast(t("action_error", this._lang));
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-11
@@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
|
import { openSignedDocument } from "../helpers/document-url";
|
||||||
import { formatBytes } from "../helpers/format-bytes";
|
import { formatBytes } from "../helpers/format-bytes";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
|||||||
private _searchTimer = 0;
|
private _searchTimer = 0;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>): void {
|
updated(changed: Map<string, unknown>): void {
|
||||||
@@ -137,17 +138,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
|||||||
window.open(doc.url, "_blank", "noopener");
|
window.open(doc.url, "_blank", "noopener");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const win = window.open("about:blank", "_blank");
|
|
||||||
try {
|
try {
|
||||||
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
await openSignedDocument(this.hass, doc.id);
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 300,
|
|
||||||
});
|
|
||||||
// Absolute URL so it navigates the blank popup reliably (about:blank base).
|
|
||||||
if (win) win.location.href = new URL(s.path, window.location.origin).href;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (win) win.close();
|
|
||||||
this._error = describeWsError(e, this._lang);
|
this._error = describeWsError(e, this._lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@
|
|||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
|||||||
private _localeReady = false;
|
private _localeReady = false;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>): void {
|
updated(changed: Map<string, unknown>): void {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||||||
import { formatDate, t, weekdayName } from "../styles";
|
import { formatDate, t, weekdayName, langOf } from "../styles";
|
||||||
import { UserService } from "../user-service";
|
import { UserService } from "../user-service";
|
||||||
import { partLinkKey } from "../helpers/shared-parts";
|
import { partLinkKey } from "../helpers/shared-parts";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,8 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
|||||||
// The type selector additionally offers "compound" (a group of conditions
|
// The type selector additionally offers "compound" (a group of conditions
|
||||||
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
|
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
|
||||||
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
||||||
|
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
|
||||||
|
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
|
||||||
|
|
||||||
/** One condition of a compound trigger — a flat trigger the user edits inline.
|
/** One condition of a compound trigger — a flat trigger the user edits inline.
|
||||||
* String-typed like the top-level fields (form inputs); coerced on save. */
|
* String-typed like the top-level fields (form inputs); coerced on save. */
|
||||||
@@ -279,9 +281,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
|||||||
// Adaptive tuning (parity with the options flow's adaptive step) — Store-
|
// Adaptive tuning (parity with the options flow's adaptive step) — Store-
|
||||||
// managed like the environmental binding, saved through task/set_adaptive.
|
// managed like the environmental binding, saved through task/set_adaptive.
|
||||||
@state() private _adaptiveEnabled = false;
|
@state() private _adaptiveEnabled = false;
|
||||||
@state() private _adaptiveAlpha = "0.3";
|
@state() private _adaptiveAlpha: string = ADAPTIVE_DEFAULTS.alpha;
|
||||||
@state() private _adaptiveMin = "7";
|
@state() private _adaptiveMin: string = ADAPTIVE_DEFAULTS.min;
|
||||||
@state() private _adaptiveMax = "365";
|
@state() private _adaptiveMax: string = ADAPTIVE_DEFAULTS.max;
|
||||||
@state() private _adaptiveSeasonal = true;
|
@state() private _adaptiveSeasonal = true;
|
||||||
@state() private _adaptivePrediction = true;
|
@state() private _adaptivePrediction = true;
|
||||||
private _adaptiveInitial = "";
|
private _adaptiveInitial = "";
|
||||||
@@ -295,7 +297,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
|||||||
private _userService: UserService | null = null;
|
private _userService: UserService | null = null;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
|
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
|
||||||
@@ -404,9 +406,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
|||||||
this._environmentalInitial = this._environmentalEntity;
|
this._environmentalInitial = this._environmentalEntity;
|
||||||
this._environmentalAttributeInitial = this._environmentalAttribute;
|
this._environmentalAttributeInitial = this._environmentalAttribute;
|
||||||
this._adaptiveEnabled = !!ac.enabled;
|
this._adaptiveEnabled = !!ac.enabled;
|
||||||
this._adaptiveAlpha = (ac.ewa_alpha ?? 0.3).toString();
|
this._adaptiveAlpha = ac.ewa_alpha?.toString() ?? ADAPTIVE_DEFAULTS.alpha;
|
||||||
this._adaptiveMin = (ac.min_interval_days ?? 7).toString();
|
this._adaptiveMin = ac.min_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.min;
|
||||||
this._adaptiveMax = (ac.max_interval_days ?? 365).toString();
|
this._adaptiveMax = ac.max_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.max;
|
||||||
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
|
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
|
||||||
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
|
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
|
||||||
this._adaptiveInitial = this._adaptiveSnapshot();
|
this._adaptiveInitial = this._adaptiveSnapshot();
|
||||||
@@ -497,9 +499,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
|||||||
this._environmentalInitial = "";
|
this._environmentalInitial = "";
|
||||||
this._environmentalAttributeInitial = "";
|
this._environmentalAttributeInitial = "";
|
||||||
this._adaptiveEnabled = false;
|
this._adaptiveEnabled = false;
|
||||||
this._adaptiveAlpha = "0.3";
|
this._adaptiveAlpha = ADAPTIVE_DEFAULTS.alpha;
|
||||||
this._adaptiveMin = "7";
|
this._adaptiveMin = ADAPTIVE_DEFAULTS.min;
|
||||||
this._adaptiveMax = "365";
|
this._adaptiveMax = ADAPTIVE_DEFAULTS.max;
|
||||||
this._adaptiveSeasonal = true;
|
this._adaptiveSeasonal = true;
|
||||||
this._adaptivePrediction = true;
|
this._adaptivePrediction = true;
|
||||||
this._adaptiveInitial = this._adaptiveSnapshot();
|
this._adaptiveInitial = this._adaptiveSnapshot();
|
||||||
@@ -1314,34 +1316,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
` : nothing}
|
` : nothing}
|
||||||
${this._availableAttributes.length > 0
|
${this._renderAttributeSelect({
|
||||||
? html`
|
label: t("attribute_optional", L),
|
||||||
<div class="select-row">
|
value: this._triggerAttribute,
|
||||||
<label>${t("attribute_optional", L)}</label>
|
suggested: this._suggestedAttributes,
|
||||||
<select
|
available: this._availableAttributes,
|
||||||
.value=${this._triggerAttribute}
|
onSelect: (v) => (this._triggerAttribute = v),
|
||||||
@change=${(e: Event) => (this._triggerAttribute = (e.target as HTMLSelectElement).value)}
|
})}
|
||||||
>
|
|
||||||
<option value="" ?selected=${!this._triggerAttribute}>${t("use_entity_state", L)}</option>
|
|
||||||
${this._suggestedAttributes.map(
|
|
||||||
(attr) => html`<option value=${attr} ?selected=${attr === this._triggerAttribute}>${attr} ★</option>`
|
|
||||||
)}
|
|
||||||
${this._availableAttributes
|
|
||||||
.filter((a) => !this._suggestedAttributes.includes(a.name))
|
|
||||||
.map(
|
|
||||||
(a) => html`<option value=${a.name} ?selected=${a.name === this._triggerAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
<ms-textfield
|
|
||||||
label="${t("attribute_optional", L)}"
|
|
||||||
.value=${this._triggerAttribute}
|
|
||||||
@input=${(e: Event) => (this._triggerAttribute = (e.target as HTMLInputElement).value)}
|
|
||||||
></ms-textfield>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
${this._renderTriggerTypeFields()}
|
${this._renderTriggerTypeFields()}
|
||||||
${this._renderTriggerLiveHint()}
|
${this._renderTriggerLiveHint()}
|
||||||
`}
|
`}
|
||||||
@@ -1576,76 +1557,73 @@ export class MaintenanceTaskDialog extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared attribute selector: "use entity state" + suggested★ + remaining
|
||||||
|
* attributes (non-numeric flagged), textfield fallback when none known. */
|
||||||
|
private _renderAttributeSelect(cfg: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
suggested: string[];
|
||||||
|
available: { name: string; numeric: boolean }[];
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const L = this._lang;
|
||||||
|
if (cfg.available.length > 0) {
|
||||||
|
return html`
|
||||||
|
<div class="select-row">
|
||||||
|
<label>${cfg.label}</label>
|
||||||
|
<select
|
||||||
|
.value=${cfg.value}
|
||||||
|
@change=${(e: Event) => cfg.onSelect((e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
<option value="" ?selected=${!cfg.value}>${t("use_entity_state", L)}</option>
|
||||||
|
${cfg.suggested.map(
|
||||||
|
(attr) => html`<option value=${attr} ?selected=${attr === cfg.value}>${attr} ★</option>`
|
||||||
|
)}
|
||||||
|
${cfg.available
|
||||||
|
.filter((a) => !cfg.suggested.includes(a.name))
|
||||||
|
.map(
|
||||||
|
(a) => html`<option value=${a.name} ?selected=${a.name === cfg.value}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<ms-textfield
|
||||||
|
label="${cfg.label}"
|
||||||
|
.value=${cfg.value}
|
||||||
|
@input=${(e: Event) => cfg.onSelect((e.target as HTMLInputElement).value.trim())}
|
||||||
|
></ms-textfield>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Environmental attribute — the same live-fetched dropdown the flat and
|
/** Environmental attribute — the same live-fetched dropdown the flat and
|
||||||
* compound attribute fields use, keyed by the environmental entity. */
|
* compound attribute fields use, keyed by the environmental entity. */
|
||||||
private _renderEnvironmentalAttribute(L: string) {
|
private _renderEnvironmentalAttribute(L: string) {
|
||||||
this._fetchConditionAttributes(this._environmentalEntity);
|
this._fetchConditionAttributes(this._environmentalEntity);
|
||||||
const opts = this._conditionAttrOptions[this._environmentalEntity];
|
const opts = this._conditionAttrOptions[this._environmentalEntity];
|
||||||
if (opts && opts.available.length > 0) {
|
return this._renderAttributeSelect({
|
||||||
return html`
|
label: t("environmental_attribute_optional", L),
|
||||||
<div class="select-row">
|
value: this._environmentalAttribute,
|
||||||
<label>${t("environmental_attribute_optional", L)}</label>
|
suggested: opts?.suggested ?? [],
|
||||||
<select
|
available: opts?.available ?? [],
|
||||||
.value=${this._environmentalAttribute}
|
onSelect: (v) => (this._environmentalAttribute = v),
|
||||||
@change=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLSelectElement).value)}
|
});
|
||||||
>
|
|
||||||
<option value="" ?selected=${!this._environmentalAttribute}>${t("use_entity_state", L)}</option>
|
|
||||||
${opts.suggested.map(
|
|
||||||
(attr) => html`<option value=${attr} ?selected=${attr === this._environmentalAttribute}>${attr} ★</option>`
|
|
||||||
)}
|
|
||||||
${opts.available
|
|
||||||
.filter((a) => !opts.suggested.includes(a.name))
|
|
||||||
.map(
|
|
||||||
(a) => html`<option value=${a.name} ?selected=${a.name === this._environmentalAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<ms-textfield
|
|
||||||
label="${t("environmental_attribute_optional", L)}"
|
|
||||||
.value=${this._environmentalAttribute}
|
|
||||||
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
|
|
||||||
></ms-textfield>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attribute selector for one compound condition — the same live-fetched
|
/** Attribute selector for one compound condition — the same live-fetched
|
||||||
* dropdown the flat editor has, keyed by the condition's first entity. */
|
* dropdown the flat editor has, keyed by the condition's first entity. */
|
||||||
private _renderConditionAttribute(c: CompoundConditionDraft, i: number) {
|
private _renderConditionAttribute(c: CompoundConditionDraft, i: number) {
|
||||||
const L = this._lang;
|
|
||||||
const firstId = c.entityIds.split(",")[0]?.trim() || "";
|
const firstId = c.entityIds.split(",")[0]?.trim() || "";
|
||||||
if (firstId) this._fetchConditionAttributes(firstId);
|
if (firstId) this._fetchConditionAttributes(firstId);
|
||||||
const opts = firstId ? this._conditionAttrOptions[firstId] : undefined;
|
const opts = firstId ? this._conditionAttrOptions[firstId] : undefined;
|
||||||
if (opts && opts.available.length > 0) {
|
return this._renderAttributeSelect({
|
||||||
return html`
|
label: t("attribute_optional", this._lang),
|
||||||
<div class="select-row">
|
value: c.attribute,
|
||||||
<label>${t("attribute_optional", L)}</label>
|
suggested: opts?.suggested ?? [],
|
||||||
<select
|
available: opts?.available ?? [],
|
||||||
.value=${c.attribute}
|
onSelect: (v) => this._patchCondition(i, { attribute: v }),
|
||||||
@change=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLSelectElement).value })}
|
});
|
||||||
>
|
|
||||||
<option value="" ?selected=${!c.attribute}>${t("use_entity_state", L)}</option>
|
|
||||||
${opts.suggested.map(
|
|
||||||
(attr) => html`<option value=${attr} ?selected=${attr === c.attribute}>${attr} ★</option>`
|
|
||||||
)}
|
|
||||||
${opts.available
|
|
||||||
.filter((a) => !opts.suggested.includes(a.name))
|
|
||||||
.map(
|
|
||||||
(a) => html`<option value=${a.name} ?selected=${a.name === c.attribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<ms-textfield
|
|
||||||
label="${t("attribute_optional", L)}"
|
|
||||||
.value=${c.attribute}
|
|
||||||
@input=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLInputElement).value.trim() })}
|
|
||||||
></ms-textfield>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type-specific inputs for a single compound condition (mirrors the flat
|
/** Type-specific inputs for a single compound condition (mirrors the flat
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { downloadUrl } from "../helpers/download";
|
import { downloadUrl } from "../helpers/download";
|
||||||
|
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
|
||||||
import { formatBytes } from "../helpers/format-bytes";
|
import { formatBytes } from "../helpers/format-bytes";
|
||||||
|
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
interface Doc {
|
interface Doc {
|
||||||
@@ -31,16 +33,6 @@ interface Doc {
|
|||||||
part_ids?: string[];
|
part_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
|
||||||
manual: "mdi:book-open-variant",
|
|
||||||
warranty: "mdi:shield-check",
|
|
||||||
invoice: "mdi:receipt-text-outline",
|
|
||||||
spare_parts: "mdi:cog-outline",
|
|
||||||
photo: "mdi:image-outline",
|
|
||||||
other: "mdi:file-document-outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MaintenanceTaskDocuments extends LitElement {
|
export class MaintenanceTaskDocuments extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@property({ attribute: false }) public entryId!: string;
|
@property({ attribute: false }) public entryId!: string;
|
||||||
@@ -59,7 +51,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
|||||||
private _localeReady = false;
|
private _localeReady = false;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The id linked docs reference — a task id or (part mode) a part id. */
|
/** The id linked docs reference — a task id or (part mode) a part id. */
|
||||||
@@ -158,19 +150,9 @@ export class MaintenanceTaskDocuments extends LitElement {
|
|||||||
// A per-task page hint jumps straight to the relevant page via the PDF
|
// A per-task page hint jumps straight to the relevant page via the PDF
|
||||||
// viewer's #page=N fragment (client-side, so it never breaks the signature).
|
// viewer's #page=N fragment (client-side, so it never breaks the signature).
|
||||||
const page = this._pageFor(doc);
|
const page = this._pageFor(doc);
|
||||||
const frag = page ? `#page=${page}` : "";
|
|
||||||
const win = window.open("about:blank", "_blank");
|
|
||||||
try {
|
try {
|
||||||
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 300,
|
|
||||||
});
|
|
||||||
// Absolute URL: a fragment on a *root-relative* path won't resolve against
|
|
||||||
// the blank popup's about:blank base, so it would silently stay blank.
|
|
||||||
if (win) win.location.href = new URL(s.path + frag, window.location.origin).href;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (win) win.close();
|
|
||||||
this._error = describeWsError(e, this._lang);
|
this._error = describeWsError(e, this._lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,12 +178,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
|||||||
|
|
||||||
private async _download(doc: Doc): Promise<void> {
|
private async _download(doc: Doc): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 30,
|
|
||||||
});
|
|
||||||
downloadUrl(s.path, doc.filename || doc.title || "document");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = describeWsError(e, this._lang);
|
this._error = describeWsError(e, this._lang);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
|
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { renderWeibullSection } from "../renderers/weibull";
|
import { renderWeibullSection } from "../renderers/weibull";
|
||||||
import { renderPredictionSection } from "../renderers/prediction";
|
import { renderPredictionSection } from "../renderers/prediction";
|
||||||
@@ -62,7 +62,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
|||||||
private _featuresLoaded = false;
|
private _featuresLoaded = false;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
|
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
|
||||||
|
|||||||
+4
-5
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t, ensureLocale } from "../styles";
|
import { t, ensureLocale, langOf } from "../styles";
|
||||||
|
import { registerCustomCard } from "../helpers/register-card";
|
||||||
import { describeWsError } from "../ws-errors";
|
import { describeWsError } from "../ws-errors";
|
||||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
@@ -55,7 +56,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _isAdmin(): boolean {
|
private get _isAdmin(): boolean {
|
||||||
@@ -317,9 +318,7 @@ if (!customElements.get("maintenance-vacation-section-card")) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as { customCards?: unknown[] }).customCards =
|
registerCustomCard({
|
||||||
(window as { customCards?: unknown[] }).customCards || [];
|
|
||||||
((window as { customCards?: unknown[] }).customCards!).push({
|
|
||||||
type: "maintenance-vacation-section-card",
|
type: "maintenance-vacation-section-card",
|
||||||
name: "Maintenance Supporter — Vacation",
|
name: "Maintenance Supporter — Vacation",
|
||||||
description: "Inline vacation mode toggle + dates",
|
description: "Inline vacation mode toggle + dates",
|
||||||
|
|||||||
@@ -18,4 +18,29 @@ export const LS_KEYS = {
|
|||||||
objectView: "maintenance_supporter_object_view",
|
objectView: "maintenance_supporter_object_view",
|
||||||
objectsCache: "msp-objects-cache",
|
objectsCache: "msp-objects-cache",
|
||||||
gettingStartedDismissed: "msp-gs-dismissed",
|
gettingStartedDismissed: "msp-gs-dismissed",
|
||||||
|
batteryRosterSort: "ms_bf_roster_sort",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarded storage access — localStorage can THROW (Safari private mode /
|
||||||
|
* locked-down policies raise instead of returning null), and an unguarded
|
||||||
|
* call aborts the surrounding handler. The 2026-08 DRY audit found two
|
||||||
|
* hand-written call sites that had missed the guard; every access now goes
|
||||||
|
* through these two (a source tripwire forbids direct localStorage use
|
||||||
|
* outside this module).
|
||||||
|
*/
|
||||||
|
export function lsGet(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lsSet(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
/* private mode / storage blocked */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportovat JSON",
|
"settings_export_json": "Exportovat JSON",
|
||||||
"settings_export_yaml": "Exportovat YAML",
|
"settings_export_yaml": "Exportovat YAML",
|
||||||
"settings_export_csv": "Exportovat CSV",
|
"settings_export_csv": "Exportovat CSV",
|
||||||
|
"settings_export_settings": "Exportovat nastavení (JSON)",
|
||||||
"settings_import_csv": "Importovat CSV",
|
"settings_import_csv": "Importovat CSV",
|
||||||
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
|
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
|
||||||
"settings_import_btn": "Importovat",
|
"settings_import_btn": "Importovat",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Eksporter JSON",
|
"settings_export_json": "Eksporter JSON",
|
||||||
"settings_export_yaml": "Eksporter YAML",
|
"settings_export_yaml": "Eksporter YAML",
|
||||||
"settings_export_csv": "Eksporter CSV",
|
"settings_export_csv": "Eksporter CSV",
|
||||||
|
"settings_export_settings": "Eksportér indstillinger (JSON)",
|
||||||
"settings_import_csv": "Importer CSV",
|
"settings_import_csv": "Importer CSV",
|
||||||
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
|
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
|
||||||
"settings_import_btn": "Importer",
|
"settings_import_btn": "Importer",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON exportieren",
|
"settings_export_json": "JSON exportieren",
|
||||||
"settings_export_yaml": "YAML exportieren",
|
"settings_export_yaml": "YAML exportieren",
|
||||||
"settings_export_csv": "CSV exportieren",
|
"settings_export_csv": "CSV exportieren",
|
||||||
|
"settings_export_settings": "Einstellungen exportieren (JSON)",
|
||||||
"settings_import_csv": "CSV importieren",
|
"settings_import_csv": "CSV importieren",
|
||||||
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
|
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
|
||||||
"settings_import_btn": "Importieren",
|
"settings_import_btn": "Importieren",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Export JSON",
|
"settings_export_json": "Export JSON",
|
||||||
"settings_export_yaml": "Export YAML",
|
"settings_export_yaml": "Export YAML",
|
||||||
"settings_export_csv": "Export CSV",
|
"settings_export_csv": "Export CSV",
|
||||||
|
"settings_export_settings": "Export settings (JSON)",
|
||||||
"settings_import_csv": "Import CSV",
|
"settings_import_csv": "Import CSV",
|
||||||
"settings_import_placeholder": "Paste JSON or CSV content here…",
|
"settings_import_placeholder": "Paste JSON or CSV content here…",
|
||||||
"settings_import_btn": "Import",
|
"settings_import_btn": "Import",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportar JSON",
|
"settings_export_json": "Exportar JSON",
|
||||||
"settings_export_yaml": "Exportar YAML",
|
"settings_export_yaml": "Exportar YAML",
|
||||||
"settings_export_csv": "Exportar CSV",
|
"settings_export_csv": "Exportar CSV",
|
||||||
|
"settings_export_settings": "Exportar ajustes (JSON)",
|
||||||
"settings_import_csv": "Importar CSV",
|
"settings_import_csv": "Importar CSV",
|
||||||
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
|
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
|
||||||
"settings_import_btn": "Importar",
|
"settings_import_btn": "Importar",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Vie JSON",
|
"settings_export_json": "Vie JSON",
|
||||||
"settings_export_yaml": "Vie YAML",
|
"settings_export_yaml": "Vie YAML",
|
||||||
"settings_export_csv": "Vie CSV",
|
"settings_export_csv": "Vie CSV",
|
||||||
|
"settings_export_settings": "Vie asetukset (JSON)",
|
||||||
"settings_import_csv": "Tuo CSV",
|
"settings_import_csv": "Tuo CSV",
|
||||||
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
|
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
|
||||||
"settings_import_btn": "Tuo",
|
"settings_import_btn": "Tuo",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exporter JSON",
|
"settings_export_json": "Exporter JSON",
|
||||||
"settings_export_yaml": "Exporter YAML",
|
"settings_export_yaml": "Exporter YAML",
|
||||||
"settings_export_csv": "Exporter CSV",
|
"settings_export_csv": "Exporter CSV",
|
||||||
|
"settings_export_settings": "Exporter les réglages (JSON)",
|
||||||
"settings_import_csv": "Importer CSV",
|
"settings_import_csv": "Importer CSV",
|
||||||
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
|
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
|
||||||
"settings_import_btn": "Importer",
|
"settings_import_btn": "Importer",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON निर्यात करें",
|
"settings_export_json": "JSON निर्यात करें",
|
||||||
"settings_export_yaml": "YAML निर्यात करें",
|
"settings_export_yaml": "YAML निर्यात करें",
|
||||||
"settings_export_csv": "CSV निर्यात करें",
|
"settings_export_csv": "CSV निर्यात करें",
|
||||||
|
"settings_export_settings": "सेटिंग्स निर्यात करें (JSON)",
|
||||||
"settings_import_csv": "CSV आयात करें",
|
"settings_import_csv": "CSV आयात करें",
|
||||||
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
|
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
|
||||||
"settings_import_btn": "आयात करें",
|
"settings_import_btn": "आयात करें",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON exportálása",
|
"settings_export_json": "JSON exportálása",
|
||||||
"settings_export_yaml": "YAML exportálása",
|
"settings_export_yaml": "YAML exportálása",
|
||||||
"settings_export_csv": "CSV exportálása",
|
"settings_export_csv": "CSV exportálása",
|
||||||
|
"settings_export_settings": "Beállítások exportálása (JSON)",
|
||||||
"settings_import_csv": "CSV importálása",
|
"settings_import_csv": "CSV importálása",
|
||||||
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
|
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
|
||||||
"settings_import_btn": "Importálás",
|
"settings_import_btn": "Importálás",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Esporta JSON",
|
"settings_export_json": "Esporta JSON",
|
||||||
"settings_export_yaml": "Esporta YAML",
|
"settings_export_yaml": "Esporta YAML",
|
||||||
"settings_export_csv": "Esporta CSV",
|
"settings_export_csv": "Esporta CSV",
|
||||||
|
"settings_export_settings": "Esporta impostazioni (JSON)",
|
||||||
"settings_import_csv": "Importa CSV",
|
"settings_import_csv": "Importa CSV",
|
||||||
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
|
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
|
||||||
"settings_import_btn": "Importa",
|
"settings_import_btn": "Importa",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSONをエクスポート",
|
"settings_export_json": "JSONをエクスポート",
|
||||||
"settings_export_yaml": "YAMLをエクスポート",
|
"settings_export_yaml": "YAMLをエクスポート",
|
||||||
"settings_export_csv": "CSVをエクスポート",
|
"settings_export_csv": "CSVをエクスポート",
|
||||||
|
"settings_export_settings": "設定をエクスポート(JSON)",
|
||||||
"settings_import_csv": "CSVをインポート",
|
"settings_import_csv": "CSVをインポート",
|
||||||
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
|
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
|
||||||
"settings_import_btn": "インポート",
|
"settings_import_btn": "インポート",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON 내보내기",
|
"settings_export_json": "JSON 내보내기",
|
||||||
"settings_export_yaml": "YAML 내보내기",
|
"settings_export_yaml": "YAML 내보내기",
|
||||||
"settings_export_csv": "CSV 내보내기",
|
"settings_export_csv": "CSV 내보내기",
|
||||||
|
"settings_export_settings": "설정 내보내기 (JSON)",
|
||||||
"settings_import_csv": "CSV 가져오기",
|
"settings_import_csv": "CSV 가져오기",
|
||||||
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
|
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
|
||||||
"settings_import_btn": "가져오기",
|
"settings_import_btn": "가져오기",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Eksporter JSON",
|
"settings_export_json": "Eksporter JSON",
|
||||||
"settings_export_yaml": "Eksporter YAML",
|
"settings_export_yaml": "Eksporter YAML",
|
||||||
"settings_export_csv": "Eksporter CSV",
|
"settings_export_csv": "Eksporter CSV",
|
||||||
|
"settings_export_settings": "Eksporter innstillinger (JSON)",
|
||||||
"settings_import_csv": "Importer CSV",
|
"settings_import_csv": "Importer CSV",
|
||||||
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
|
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
|
||||||
"settings_import_btn": "Importer",
|
"settings_import_btn": "Importer",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "JSON exporteren",
|
"settings_export_json": "JSON exporteren",
|
||||||
"settings_export_yaml": "YAML exporteren",
|
"settings_export_yaml": "YAML exporteren",
|
||||||
"settings_export_csv": "CSV exporteren",
|
"settings_export_csv": "CSV exporteren",
|
||||||
|
"settings_export_settings": "Instellingen exporteren (JSON)",
|
||||||
"settings_import_csv": "CSV importeren",
|
"settings_import_csv": "CSV importeren",
|
||||||
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
|
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
|
||||||
"settings_import_btn": "Importeren",
|
"settings_import_btn": "Importeren",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Eksportuj JSON",
|
"settings_export_json": "Eksportuj JSON",
|
||||||
"settings_export_yaml": "Eksportuj YAML",
|
"settings_export_yaml": "Eksportuj YAML",
|
||||||
"settings_export_csv": "Eksportuj CSV",
|
"settings_export_csv": "Eksportuj CSV",
|
||||||
|
"settings_export_settings": "Eksportuj ustawienia (JSON)",
|
||||||
"settings_import_csv": "Importuj CSV",
|
"settings_import_csv": "Importuj CSV",
|
||||||
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
|
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
|
||||||
"settings_import_btn": "Importuj",
|
"settings_import_btn": "Importuj",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Exportar JSON",
|
"settings_export_json": "Exportar JSON",
|
||||||
"settings_export_yaml": "Exportar YAML",
|
"settings_export_yaml": "Exportar YAML",
|
||||||
"settings_export_csv": "Exportar CSV",
|
"settings_export_csv": "Exportar CSV",
|
||||||
|
"settings_export_settings": "Exportar configurações (JSON)",
|
||||||
"settings_import_csv": "Importar CSV",
|
"settings_import_csv": "Importar CSV",
|
||||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||||
"settings_import_btn": "Importar",
|
"settings_import_btn": "Importar",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportar JSON",
|
"settings_export_json": "Exportar JSON",
|
||||||
"settings_export_yaml": "Exportar YAML",
|
"settings_export_yaml": "Exportar YAML",
|
||||||
"settings_export_csv": "Exportar CSV",
|
"settings_export_csv": "Exportar CSV",
|
||||||
|
"settings_export_settings": "Exportar definições (JSON)",
|
||||||
"settings_import_csv": "Importar CSV",
|
"settings_import_csv": "Importar CSV",
|
||||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||||
"settings_import_btn": "Importar",
|
"settings_import_btn": "Importar",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Экспорт JSON",
|
"settings_export_json": "Экспорт JSON",
|
||||||
"settings_export_yaml": "Экспорт YAML",
|
"settings_export_yaml": "Экспорт YAML",
|
||||||
"settings_export_csv": "Экспорт CSV",
|
"settings_export_csv": "Экспорт CSV",
|
||||||
|
"settings_export_settings": "Экспорт настроек (JSON)",
|
||||||
"settings_import_csv": "Импорт CSV",
|
"settings_import_csv": "Импорт CSV",
|
||||||
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
|
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
|
||||||
"settings_import_btn": "Импортировать",
|
"settings_import_btn": "Импортировать",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportera JSON",
|
"settings_export_json": "Exportera JSON",
|
||||||
"settings_export_yaml": "Exportera YAML",
|
"settings_export_yaml": "Exportera YAML",
|
||||||
"settings_export_csv": "Exportera CSV",
|
"settings_export_csv": "Exportera CSV",
|
||||||
|
"settings_export_settings": "Exportera inställningar (JSON)",
|
||||||
"settings_import_csv": "Importera CSV",
|
"settings_import_csv": "Importera CSV",
|
||||||
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
|
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
|
||||||
"settings_import_btn": "Importera",
|
"settings_import_btn": "Importera",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON dışa aktar",
|
"settings_export_json": "JSON dışa aktar",
|
||||||
"settings_export_yaml": "YAML dışa aktar",
|
"settings_export_yaml": "YAML dışa aktar",
|
||||||
"settings_export_csv": "CSV dışa aktar",
|
"settings_export_csv": "CSV dışa aktar",
|
||||||
|
"settings_export_settings": "Ayarları dışa aktar (JSON)",
|
||||||
"settings_import_csv": "CSV içe aktar",
|
"settings_import_csv": "CSV içe aktar",
|
||||||
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
|
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
|
||||||
"settings_import_btn": "İçe aktar",
|
"settings_import_btn": "İçe aktar",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Експортувати JSON",
|
"settings_export_json": "Експортувати JSON",
|
||||||
"settings_export_yaml": "Експортувати YAML",
|
"settings_export_yaml": "Експортувати YAML",
|
||||||
"settings_export_csv": "Експортувати CSV",
|
"settings_export_csv": "Експортувати CSV",
|
||||||
|
"settings_export_settings": "Експорт налаштувань (JSON)",
|
||||||
"settings_import_csv": "Імпортувати CSV",
|
"settings_import_csv": "Імпортувати CSV",
|
||||||
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
|
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
|
||||||
"settings_import_btn": "Імпортувати",
|
"settings_import_btn": "Імпортувати",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "导出 JSON",
|
"settings_export_json": "导出 JSON",
|
||||||
"settings_export_yaml": "导出 YAML",
|
"settings_export_yaml": "导出 YAML",
|
||||||
"settings_export_csv": "导出 CSV",
|
"settings_export_csv": "导出 CSV",
|
||||||
|
"settings_export_settings": "导出设置(JSON)",
|
||||||
"settings_import_csv": "导入 CSV",
|
"settings_import_csv": "导入 CSV",
|
||||||
"settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…",
|
"settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…",
|
||||||
"settings_import_btn": "导入",
|
"settings_import_btn": "导入",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import {
|
|||||||
type CalendarEvent,
|
type CalendarEvent,
|
||||||
} from "./helpers/calendar-bucket";
|
} from "./helpers/calendar-bucket";
|
||||||
import { calendarStyles } from "./calendar-styles";
|
import { calendarStyles } from "./calendar-styles";
|
||||||
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
|
||||||
|
import { registerCustomCard } from "./helpers/register-card";
|
||||||
import type {
|
import type {
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
MaintenanceObjectResponse,
|
MaintenanceObjectResponse,
|
||||||
@@ -121,7 +122,7 @@ export class MaintenanceCalendarCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
@@ -642,31 +643,15 @@ if (!customElements.get("maintenance-supporter-calendar-card-editor")) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register with HACS / customCards so the picker shows it
|
// Register with HACS / customCards so the picker shows it
|
||||||
const w = window as unknown as {
|
// The type MUST match the registered element tag exactly (custom:X → tag X),
|
||||||
customCards?: Array<{
|
// or the picker entry resolves to a non-existent element and the strategy's
|
||||||
type: string;
|
// calendar mode throws a config error.
|
||||||
name: string;
|
registerCustomCard({
|
||||||
description: string;
|
type: "maintenance-supporter-calendar-card",
|
||||||
preview?: boolean;
|
name: "Maintenance Supporter — Calendar",
|
||||||
}>;
|
description:
|
||||||
};
|
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
|
||||||
w.customCards = w.customCards || [];
|
preview: true,
|
||||||
// HA's custom-card resolver maps ``custom:X`` → element tag ``X``. Our element
|
});
|
||||||
// is registered as ``maintenance-supporter-calendar-card``, so the customCards
|
|
||||||
// type MUST match that exact suffix or the card-picker entry resolves to a
|
|
||||||
// non-existent element and the strategy's calendar mode throws a config error.
|
|
||||||
const CALENDAR_CARD_TYPE = "maintenance-supporter-calendar-card";
|
|
||||||
const alreadyRegistered = w.customCards.some(
|
|
||||||
(c) => c.type === CALENDAR_CARD_TYPE,
|
|
||||||
);
|
|
||||||
if (!alreadyRegistered) {
|
|
||||||
w.customCards.push({
|
|
||||||
type: CALENDAR_CARD_TYPE,
|
|
||||||
name: "Maintenance Supporter — Calendar",
|
|
||||||
description:
|
|
||||||
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
|
|
||||||
preview: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { LitElement, html, css, nothing } from "lit";
|
import { LitElement, html, css, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { t } from "./styles";
|
import { t, langOf } from "./styles";
|
||||||
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
|
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
|
||||||
|
|
||||||
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
|
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
|
||||||
@@ -18,7 +18,7 @@ export class MaintenanceSupporterCardEditor extends LitElement {
|
|||||||
private _objectsLoaded = false;
|
private _objectsLoaded = false;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config: CardConfig): void {
|
setConfig(config: CardConfig): void {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
|
|||||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
|
||||||
|
import { openSignedDocument } from "./helpers/document-url";
|
||||||
|
import { registerCustomCard } from "./helpers/register-card";
|
||||||
import type {
|
import type {
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
MaintenanceObjectResponse,
|
MaintenanceObjectResponse,
|
||||||
@@ -54,7 +56,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
|||||||
private _docsLoadedFor = new Set<string>();
|
private _docsLoadedFor = new Set<string>();
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getConfigElement() {
|
static getConfigElement() {
|
||||||
@@ -240,16 +242,10 @@ export class MaintenanceSupporterCard extends LitElement {
|
|||||||
window.open(doc.url, "_blank", "noopener");
|
window.open(doc.url, "_blank", "noopener");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const win = window.open("about:blank", "_blank");
|
|
||||||
try {
|
try {
|
||||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
await openSignedDocument(this.hass, doc.id);
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 300,
|
|
||||||
});
|
|
||||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
|
||||||
} catch {
|
} catch {
|
||||||
if (win) win.close();
|
/* silent — the card has no error surface */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,8 +692,7 @@ if (!customElements.get("maintenance-supporter-card")) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register as custom card so the Lovelace card picker lists it.
|
// Register as custom card so the Lovelace card picker lists it.
|
||||||
(window as any).customCards = (window as any).customCards || [];
|
registerCustomCard({
|
||||||
(window as any).customCards.push({
|
|
||||||
type: "maintenance-supporter-card",
|
type: "maintenance-supporter-card",
|
||||||
name: "Maintenance Supporter",
|
name: "Maintenance Supporter",
|
||||||
description: "Overview of your maintenance tasks with quick actions.",
|
description: "Overview of your maintenance tasks with quick actions.",
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { isSafeHttpUrl } from "./helpers/url";
|
|||||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||||
import { isStaleBundle } from "./helpers/bundle-version";
|
import { isStaleBundle } from "./helpers/bundle-version";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
|
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
|
||||||
import { LS_KEYS } from "./helpers/storage-keys";
|
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
|
||||||
|
import { openSignedDocument, signApiPath } from "./helpers/document-url";
|
||||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||||
import { daysProgress } from "./helpers/interval";
|
import { daysProgress } from "./helpers/interval";
|
||||||
@@ -134,17 +135,22 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
@state() private _unsub: (() => void) | null = null;
|
@state() private _unsub: (() => void) | null = null;
|
||||||
@state() private _chartRangeDays = (() => {
|
@state() private _chartRangeDays = (() => {
|
||||||
try {
|
try {
|
||||||
const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10);
|
const v = parseInt(lsGet(LS_KEYS.chartRange) || "", 10);
|
||||||
return [7, 30, 90, 365].includes(v) ? v : 30;
|
return [7, 30, 90, 365].includes(v) ? v : 30;
|
||||||
} catch {
|
} catch {
|
||||||
return 30;
|
return 30;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@state() private _hideOutliers = (() => {
|
@state() private _hideOutliers = (() => {
|
||||||
try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
|
try { return lsGet(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
|
||||||
})();
|
})();
|
||||||
@state() private _historyFilter: string | null = null;
|
@state() private _historyFilter: string | null = null;
|
||||||
@state() private _budget: BudgetStatus | null = null;
|
@state() private _budget: BudgetStatus | null = null;
|
||||||
|
|
||||||
|
private get _currencySymbol(): string {
|
||||||
|
return this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
||||||
|
}
|
||||||
|
|
||||||
@state() private _groups: Record<string, MaintenanceGroup> = {};
|
@state() private _groups: Record<string, MaintenanceGroup> = {};
|
||||||
@state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map();
|
@state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map();
|
||||||
@state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map();
|
@state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map();
|
||||||
@@ -185,7 +191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
// Dashboard redesign state
|
// Dashboard redesign state
|
||||||
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
|
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(LS_KEYS.overviewTab);
|
const v = lsGet(LS_KEYS.overviewTab);
|
||||||
return v === "today" || v === "calendar" ? v : "dashboard";
|
return v === "today" || v === "calendar" ? v : "dashboard";
|
||||||
} catch { return "dashboard"; }
|
} catch { return "dashboard"; }
|
||||||
})();
|
})();
|
||||||
@@ -217,7 +223,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
|
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
|
||||||
// remembered per section across visits.
|
// remembered per section across visits.
|
||||||
@state() private _collapsedSections: Set<string> = (() => {
|
@state() private _collapsedSections: Set<string> = (() => {
|
||||||
try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); }
|
try { return new Set(JSON.parse(lsGet(LS_KEYS.collapsedSections) || "[]")); }
|
||||||
catch { return new Set(); }
|
catch { return new Set(); }
|
||||||
})();
|
})();
|
||||||
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
|
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
|
||||||
@@ -241,7 +247,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
private _lastConnection: unknown = null;
|
private _lastConnection: unknown = null;
|
||||||
|
|
||||||
private get _lang(): string {
|
private get _lang(): string {
|
||||||
return this.hass?.language || "en";
|
return langOf(this.hass);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,19 +320,19 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
// read here would abort connectedCallback and leave the panel blank. Every
|
// read here would abort connectedCallback and leave the panel blank. Every
|
||||||
// other storage access in this file is wrapped; wrap these too.
|
// other storage access in this file is wrapped; wrap these too.
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(LS_KEYS.taskSort);
|
const saved = lsGet(LS_KEYS.taskSort);
|
||||||
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
|
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
|
||||||
this._sortMode = saved as SortMode;
|
this._sortMode = saved as SortMode;
|
||||||
}
|
}
|
||||||
const savedObj = localStorage.getItem(LS_KEYS.objectSort);
|
const savedObj = lsGet(LS_KEYS.objectSort);
|
||||||
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
|
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
|
||||||
this._objectSortMode = savedObj as ObjectSortMode;
|
this._objectSortMode = savedObj as ObjectSortMode;
|
||||||
}
|
}
|
||||||
const savedGroup = localStorage.getItem(LS_KEYS.groupBy);
|
const savedGroup = lsGet(LS_KEYS.groupBy);
|
||||||
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
|
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
|
||||||
this._groupByMode = savedGroup as GroupByMode;
|
this._groupByMode = savedGroup as GroupByMode;
|
||||||
}
|
}
|
||||||
const savedView = localStorage.getItem(LS_KEYS.objectView);
|
const savedView = lsGet(LS_KEYS.objectView);
|
||||||
if (savedView === "cards" || savedView === "table") {
|
if (savedView === "cards" || savedView === "table") {
|
||||||
this._objectViewMode = savedView;
|
this._objectViewMode = savedView;
|
||||||
}
|
}
|
||||||
@@ -640,7 +646,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
private _setChartRange(days: number): void {
|
private _setChartRange(days: number): void {
|
||||||
if (days === this._chartRangeDays) return;
|
if (days === this._chartRangeDays) return;
|
||||||
this._chartRangeDays = days;
|
this._chartRangeDays = days;
|
||||||
try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
|
||||||
const task = this._selectedEntryId && this._selectedTaskId
|
const task = this._selectedEntryId && this._selectedTaskId
|
||||||
? this._getTask(this._selectedEntryId, this._selectedTaskId)
|
? this._getTask(this._selectedEntryId, this._selectedTaskId)
|
||||||
: null;
|
: null;
|
||||||
@@ -659,7 +665,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
// Outlier filtering is client-side on the already-fetched series, so just
|
// Outlier filtering is client-side on the already-fetched series, so just
|
||||||
// flip the flag and let renderChart re-filter — no re-fetch needed.
|
// flip the flag and let renderChart re-filter — no re-fetch needed.
|
||||||
this._hideOutliers = hide;
|
this._hideOutliers = hide;
|
||||||
try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchMiniStatsForOverview(): Promise<void> {
|
private async _fetchMiniStatsForOverview(): Promise<void> {
|
||||||
@@ -948,8 +954,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
// Persist sort/group like the manual controls do, so they stick after reload.
|
// Persist sort/group like the manual controls do, so they stick after reload.
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LS_KEYS.taskSort, this._sortMode);
|
lsSet(LS_KEYS.taskSort, this._sortMode);
|
||||||
localStorage.setItem(LS_KEYS.groupBy, this._groupByMode);
|
lsSet(LS_KEYS.groupBy, this._groupByMode);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore private-mode storage errors
|
// ignore private-mode storage errors
|
||||||
}
|
}
|
||||||
@@ -1387,6 +1393,29 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
|
/** Run a mutating WS action: loading state, data reload, and — on failure —
|
||||||
|
* the SERVER's error message as the toast. The ~16 hand-written action
|
||||||
|
* methods had drifted (most swallowed the server message behind a generic
|
||||||
|
* "Action failed", three never set _actionLoading, one skipped the reload).
|
||||||
|
* Returns the result payload, or null when the call failed (toast shown). */
|
||||||
|
private async _runAction<T = Record<string, unknown>>(
|
||||||
|
msg: Record<string, unknown>,
|
||||||
|
opts?: { successToast?: string },
|
||||||
|
): Promise<T | null> {
|
||||||
|
this._actionLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await this.hass.connection.sendMessagePromise<T>(msg);
|
||||||
|
await this._loadData();
|
||||||
|
if (opts?.successToast) this._showToast(opts.successToast);
|
||||||
|
return (res ?? {}) as T;
|
||||||
|
} catch (e) {
|
||||||
|
this._showToast(describeWsError(e, this._lang));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this._actionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _deleteObject(entryId: string): Promise<void> {
|
private async _deleteObject(entryId: string): Promise<void> {
|
||||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||||
const ok = await dlg?.confirm({
|
const ok = await dlg?.confirm({
|
||||||
@@ -1396,16 +1425,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
const res = await this._runAction({
|
||||||
await this.hass.connection.sendMessagePromise({
|
type: "maintenance_supporter/object/delete",
|
||||||
type: "maintenance_supporter/object/delete",
|
entry_id: entryId,
|
||||||
entry_id: entryId,
|
});
|
||||||
});
|
if (res) this._showOverview();
|
||||||
this._showOverview();
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a printable maintenance report for the object in a new tab (the user
|
/** Open a printable maintenance report for the object in a new tab (the user
|
||||||
@@ -1442,7 +1466,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
const html = buildObjectReportHtml(
|
const html = buildObjectReportHtml(
|
||||||
resp.object, resp.tasks, labels,
|
resp.object, resp.tasks, labels,
|
||||||
(iso) => (iso ? formatDate(iso, L) : ""),
|
(iso) => (iso ? formatDate(iso, L) : ""),
|
||||||
this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
|
this._currencySymbol,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
);
|
);
|
||||||
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
|
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
|
||||||
@@ -1451,20 +1475,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _duplicateObject(entryId: string): Promise<void> {
|
private async _duplicateObject(entryId: string): Promise<void> {
|
||||||
this._actionLoading = true;
|
const res = await this._runAction<{ entry_id?: string }>(
|
||||||
try {
|
{ type: "maintenance_supporter/object/duplicate", entry_id: entryId },
|
||||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
|
{ successToast: t("object_duplicated", this._lang) },
|
||||||
type: "maintenance_supporter/object/duplicate",
|
);
|
||||||
entry_id: entryId,
|
if (res?.entry_id) this._showObject(res.entry_id);
|
||||||
});
|
|
||||||
await this._loadData();
|
|
||||||
this._showToast(t("object_duplicated", this._lang));
|
|
||||||
if (res?.entry_id) this._showObject(res.entry_id);
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _deleteTask(entryId: string, taskId: string): Promise<void> {
|
private async _deleteTask(entryId: string, taskId: string): Promise<void> {
|
||||||
@@ -1476,60 +1491,37 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
const res = await this._runAction({
|
||||||
await this.hass.connection.sendMessagePromise({
|
type: "maintenance_supporter/task/delete",
|
||||||
type: "maintenance_supporter/task/delete",
|
entry_id: entryId,
|
||||||
entry_id: entryId,
|
task_id: taskId,
|
||||||
task_id: taskId,
|
});
|
||||||
});
|
if (res) this._showObject(entryId);
|
||||||
this._showObject(entryId);
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.10.0: archive / unarchive a single task (reversible — no confirm).
|
// v2.10.0: archive / unarchive a single task (reversible — no confirm).
|
||||||
private async _duplicateTask(entryId: string, taskId: string): Promise<void> {
|
private async _duplicateTask(entryId: string, taskId: string): Promise<void> {
|
||||||
this._moreMenuOpen = false;
|
this._moreMenuOpen = false;
|
||||||
this._actionLoading = true;
|
const res = await this._runAction<{ task_id?: string }>(
|
||||||
try {
|
{ type: "maintenance_supporter/task/duplicate", entry_id: entryId, task_id: taskId },
|
||||||
const res = await this.hass.connection.sendMessagePromise<{ task_id?: string }>({
|
{ successToast: t("task_duplicated", this._lang) },
|
||||||
type: "maintenance_supporter/task/duplicate",
|
);
|
||||||
entry_id: entryId,
|
// Jump straight to the copy so the user can rename/adjust it.
|
||||||
task_id: taskId,
|
if (res?.task_id) this._showTask(entryId, res.task_id);
|
||||||
});
|
|
||||||
await this._loadData();
|
|
||||||
this._showToast(t("task_duplicated", this._lang));
|
|
||||||
// Jump straight to the copy so the user can rename/adjust it.
|
|
||||||
if (res?.task_id) this._showTask(entryId, res.task_id);
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> {
|
private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> {
|
||||||
this._actionLoading = true;
|
const res = await this._runAction({
|
||||||
try {
|
type: archived
|
||||||
await this.hass.connection.sendMessagePromise({
|
? "maintenance_supporter/task/unarchive"
|
||||||
type: archived
|
: "maintenance_supporter/task/archive",
|
||||||
? "maintenance_supporter/task/unarchive"
|
entry_id: entryId,
|
||||||
: "maintenance_supporter/task/archive",
|
task_id: taskId,
|
||||||
entry_id: entryId,
|
});
|
||||||
task_id: taskId,
|
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
|
||||||
});
|
if (res && !archived) {
|
||||||
await this._loadData();
|
this._showUndoToast(t("task_archived", this._lang),
|
||||||
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
|
() => this._toggleArchiveTask(entryId, taskId, true));
|
||||||
if (!archived) {
|
|
||||||
this._showUndoToast(t("task_archived", this._lang),
|
|
||||||
() => this._toggleArchiveTask(entryId, taskId, true));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1537,20 +1529,15 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
// is fully reversible, so instead of a blocking confirm we run it immediately
|
// is fully reversible, so instead of a blocking confirm we run it immediately
|
||||||
// and offer an Undo toast (v2.14.0).
|
// and offer an Undo toast (v2.14.0).
|
||||||
private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> {
|
private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> {
|
||||||
try {
|
const res = await this._runAction({
|
||||||
await this.hass.connection.sendMessagePromise({
|
type: archived
|
||||||
type: archived
|
? "maintenance_supporter/object/unarchive"
|
||||||
? "maintenance_supporter/object/unarchive"
|
: "maintenance_supporter/object/archive",
|
||||||
: "maintenance_supporter/object/archive",
|
entry_id: entryId,
|
||||||
entry_id: entryId,
|
});
|
||||||
});
|
if (res && !archived) {
|
||||||
await this._loadData();
|
this._showUndoToast(t("object_archived", this._lang),
|
||||||
if (!archived) {
|
() => this._toggleArchiveObject(entryId, true));
|
||||||
this._showUndoToast(t("object_archived", this._lang),
|
|
||||||
() => this._toggleArchiveObject(entryId, true));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1567,31 +1554,21 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
inputType: "date",
|
inputType: "date",
|
||||||
});
|
});
|
||||||
if (!result?.confirmed) return;
|
if (!result?.confirmed) return;
|
||||||
try {
|
const msg: Record<string, unknown> = {
|
||||||
const msg: Record<string, unknown> = {
|
type: "maintenance_supporter/object/pause",
|
||||||
type: "maintenance_supporter/object/pause",
|
entry_id: entryId,
|
||||||
entry_id: entryId,
|
};
|
||||||
};
|
if (result.value) msg.until = result.value;
|
||||||
if (result.value) msg.until = result.value;
|
if (await this._runAction(msg)) {
|
||||||
await this.hass.connection.sendMessagePromise(msg);
|
|
||||||
await this._loadData();
|
|
||||||
this._showUndoToast(t("object_paused", this._lang),
|
this._showUndoToast(t("object_paused", this._lang),
|
||||||
() => this._togglePauseObject(entryId, true));
|
() => this._togglePauseObject(entryId, true));
|
||||||
} catch (e) {
|
|
||||||
this._showToast(describeWsError(e, this._lang));
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
await this._runAction(
|
||||||
await this.hass.connection.sendMessagePromise({
|
{ type: "maintenance_supporter/object/resume", entry_id: entryId },
|
||||||
type: "maintenance_supporter/object/resume",
|
{ successToast: t("object_resumed", this._lang) },
|
||||||
entry_id: entryId,
|
);
|
||||||
});
|
|
||||||
await this._loadData();
|
|
||||||
this._showToast(t("object_resumed", this._lang));
|
|
||||||
} catch (e) {
|
|
||||||
this._showToast(describeWsError(e, this._lang));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.20 (N1): replace a worn-out object with a successor — the old one is
|
// v2.20 (N1): replace a worn-out object with a successor — the old one is
|
||||||
@@ -1608,71 +1585,44 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
inputValue: currentName,
|
inputValue: currentName,
|
||||||
});
|
});
|
||||||
if (!result?.confirmed) return;
|
if (!result?.confirmed) return;
|
||||||
this._actionLoading = true;
|
const res = await this._runAction<{ entry_id?: string }>(
|
||||||
try {
|
{
|
||||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
|
|
||||||
type: "maintenance_supporter/object/replace",
|
type: "maintenance_supporter/object/replace",
|
||||||
entry_id: entryId,
|
entry_id: entryId,
|
||||||
name: result.value || currentName,
|
name: result.value || currentName,
|
||||||
});
|
},
|
||||||
await this._loadData();
|
{ successToast: t("object_replaced", this._lang) },
|
||||||
this._showToast(t("object_replaced", this._lang));
|
);
|
||||||
if (res?.entry_id) this._showObject(res.entry_id);
|
if (res?.entry_id) this._showObject(res.entry_id);
|
||||||
} catch (e) {
|
|
||||||
this._showToast(describeWsError(e, this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
|
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
|
||||||
this._actionLoading = true;
|
const msg: Record<string, unknown> = {
|
||||||
try {
|
type: "maintenance_supporter/task/skip",
|
||||||
const msg: Record<string, unknown> = {
|
entry_id: entryId,
|
||||||
type: "maintenance_supporter/task/skip",
|
task_id: taskId,
|
||||||
entry_id: entryId,
|
};
|
||||||
task_id: taskId,
|
if (reason) msg.reason = reason;
|
||||||
};
|
await this._runAction(msg);
|
||||||
if (reason) msg.reason = reason;
|
|
||||||
await this.hass.connection.sendMessagePromise(msg);
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> {
|
private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> {
|
||||||
this._actionLoading = true;
|
const msg: Record<string, unknown> = {
|
||||||
try {
|
type: "maintenance_supporter/task/reset",
|
||||||
const msg: Record<string, unknown> = {
|
entry_id: entryId,
|
||||||
type: "maintenance_supporter/task/reset",
|
task_id: taskId,
|
||||||
entry_id: entryId,
|
};
|
||||||
task_id: taskId,
|
if (resetDate) msg.date = resetDate;
|
||||||
};
|
await this._runAction(msg);
|
||||||
if (resetDate) msg.date = resetDate;
|
|
||||||
await this.hass.connection.sendMessagePromise(msg);
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> {
|
private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> {
|
||||||
try {
|
await this._runAction({
|
||||||
await this.hass.connection.sendMessagePromise({
|
type: "maintenance_supporter/task/apply_suggestion",
|
||||||
type: "maintenance_supporter/task/apply_suggestion",
|
entry_id: entryId,
|
||||||
entry_id: entryId,
|
task_id: taskId,
|
||||||
task_id: taskId,
|
interval: interval,
|
||||||
interval: interval,
|
});
|
||||||
});
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _openSeasonalOverrides(task: MaintenanceTask): void {
|
private _openSeasonalOverrides(task: MaintenanceTask): void {
|
||||||
@@ -1683,28 +1633,24 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> {
|
private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> {
|
||||||
try {
|
const res = await this._runAction<{
|
||||||
const res = await this.hass.connection.sendMessagePromise({
|
recommended_interval: number | null;
|
||||||
type: "maintenance_supporter/task/analyze_interval",
|
confidence: string;
|
||||||
entry_id: entryId,
|
data_points: number;
|
||||||
task_id: taskId,
|
recommendation_reason: string | null;
|
||||||
}) as {
|
}>({
|
||||||
recommended_interval: number | null;
|
type: "maintenance_supporter/task/analyze_interval",
|
||||||
confidence: string;
|
entry_id: entryId,
|
||||||
data_points: number;
|
task_id: taskId,
|
||||||
recommendation_reason: string | null;
|
});
|
||||||
};
|
if (!res) return;
|
||||||
if (res.recommended_interval) {
|
if (res.recommended_interval) {
|
||||||
this._showToast(
|
this._showToast(
|
||||||
`${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` +
|
`${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` +
|
||||||
`(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`,
|
`(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this._showToast(t("reanalyze_insufficient_data", this._lang));
|
this._showToast(t("reanalyze_insufficient_data", this._lang));
|
||||||
}
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1737,21 +1683,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
|
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
|
||||||
this._actionLoading = true;
|
await this._runAction(
|
||||||
try {
|
{ type: "maintenance_supporter/task/postpone", entry_id: entryId, task_id: taskId, until },
|
||||||
await this.hass.connection.sendMessagePromise({
|
{ successToast: t("postponed", this._lang) },
|
||||||
type: "maintenance_supporter/task/postpone",
|
);
|
||||||
entry_id: entryId,
|
|
||||||
task_id: taskId,
|
|
||||||
until,
|
|
||||||
});
|
|
||||||
this._showToast(t("postponed", this._lang));
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
|
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
|
||||||
@@ -1769,19 +1704,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
|
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
|
||||||
this._actionLoading = true;
|
// Now reloads like every sibling action — this was the one mutation that
|
||||||
try {
|
// skipped the refresh, leaving the snoozed due date stale until the next
|
||||||
await this.hass.connection.sendMessagePromise({
|
// poll.
|
||||||
type: "maintenance_supporter/task/snooze",
|
await this._runAction(
|
||||||
entry_id: entryId,
|
{ type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId },
|
||||||
task_id: taskId,
|
{ successToast: t("snoozed", this._lang) },
|
||||||
});
|
);
|
||||||
this._showToast(t("snoozed", this._lang));
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
} finally {
|
|
||||||
this._actionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dismissSuggestion(entryId?: string, taskId?: string): void {
|
private _dismissSuggestion(entryId?: string, taskId?: string): void {
|
||||||
@@ -1850,11 +1779,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
if (manual) {
|
if (manual) {
|
||||||
const start = manual.task_pages![taskId];
|
const start = manual.task_pages![taskId];
|
||||||
const count = 4;
|
const count = 4;
|
||||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
const signed = {
|
||||||
type: "auth/sign_path",
|
path: await signApiPath(
|
||||||
path: `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
|
this.hass,
|
||||||
expires: 3600,
|
`/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
|
||||||
});
|
3600,
|
||||||
|
),
|
||||||
|
};
|
||||||
excerpt = {
|
excerpt = {
|
||||||
title: manual.title || manual.filename || "Manual",
|
title: manual.title || manual.filename || "Manual",
|
||||||
startPage: start, endPage: start + count - 1,
|
startPage: start, endPage: start + count - 1,
|
||||||
@@ -1920,19 +1851,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
|
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Open the tab synchronously (inside the click gesture) so it isn't
|
void openSignedDocument(this.hass, doc.id).catch(() => {
|
||||||
// popup-blocked, then point it at the freshly signed URL.
|
/* tab already closed by the helper; the panel toast adds no value here */
|
||||||
const win = window.open("about:blank", "_blank");
|
});
|
||||||
void this.hass.connection
|
|
||||||
.sendMessagePromise<{ path: string }>({
|
|
||||||
type: "auth/sign_path",
|
|
||||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
|
||||||
expires: 300,
|
|
||||||
})
|
|
||||||
.then((signed) => {
|
|
||||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
|
||||||
})
|
|
||||||
.catch(() => win?.close());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** #73: persist one checklist tick. Sends the FULL current state (the
|
/** #73: persist one checklist tick. Sends the FULL current state (the
|
||||||
@@ -1947,15 +1868,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
const current = task.checklist_progress?.[step] ?? false;
|
const current = task.checklist_progress?.[step] ?? false;
|
||||||
state[step] = step === item ? done : current;
|
state[step] = step === item ? done : current;
|
||||||
}
|
}
|
||||||
try {
|
await this._runAction({
|
||||||
await this.hass.connection.sendMessagePromise({
|
type: "maintenance_supporter/task/checklist_progress",
|
||||||
type: "maintenance_supporter/task/checklist_progress",
|
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
});
|
||||||
});
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
||||||
@@ -1988,7 +1904,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
// #104 follow-up: parts carry unit costs — the dialog offers their sum
|
// #104 follow-up: parts carry unit costs — the dialog offers their sum
|
||||||
// as a one-click cost suggestion (buy task: restock qty × unit cost).
|
// as a one-click cost suggestion (buy task: restock qty × unit cost).
|
||||||
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
|
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
|
||||||
dlg.currencySymbol = this._budget?.currency_symbol || "";
|
dlg.currencySymbol = this._currencySymbol;
|
||||||
// #111: a link may point at another object's pool — name that object, and
|
// #111: a link may point at another object's pool — name that object, and
|
||||||
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
|
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
|
||||||
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
|
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
|
||||||
@@ -2275,7 +2191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
|
|
||||||
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
|
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
|
||||||
this._overviewTab = tab;
|
this._overviewTab = tab;
|
||||||
try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
|
||||||
this._scrollContentToTop();
|
this._scrollContentToTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2441,7 +2357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
|
this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
|
||||||
this._activeViewId = "";
|
this._activeViewId = "";
|
||||||
try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
|
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
|
||||||
@@ -2460,7 +2376,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
|
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
|
||||||
this._activeViewId = "";
|
this._activeViewId = "";
|
||||||
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
|
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
|
||||||
@@ -2765,7 +2681,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
.value=${this._objectSortMode}
|
.value=${this._objectSortMode}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
|
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
|
||||||
localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode);
|
try { lsSet(LS_KEYS.objectSort, this._objectSortMode); } catch { /* private mode */ }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
|
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
|
||||||
@@ -2794,7 +2710,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
.value=${this._groupByMode}
|
.value=${this._groupByMode}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
|
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
|
||||||
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
|
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
|
||||||
@@ -2843,7 +2759,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
|
|
||||||
private _setObjectViewMode(mode: "cards" | "table"): void {
|
private _setObjectViewMode(mode: "cards" | "table"): void {
|
||||||
this._objectViewMode = mode;
|
this._objectViewMode = mode;
|
||||||
localStorage.setItem(LS_KEYS.objectView, mode);
|
try { lsSet(LS_KEYS.objectView, mode); } catch { /* private mode */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── #130: instance-wide parts overview ────────────────────────────────────
|
// ── #130: instance-wide parts overview ────────────────────────────────────
|
||||||
@@ -2851,7 +2767,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
private _renderAllParts() {
|
private _renderAllParts() {
|
||||||
const L = this._lang;
|
const L = this._lang;
|
||||||
const rows = this._allParts;
|
const rows = this._allParts;
|
||||||
const currency = this._budget?.currency_symbol || "";
|
const currency = this._currencySymbol;
|
||||||
return html`
|
return html`
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<ha-icon-button @click=${() => this._showAllObjects()}>
|
<ha-icon-button @click=${() => this._showAllObjects()}>
|
||||||
@@ -3127,15 +3043,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
})
|
})
|
||||||
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
|
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
await this._runAction({
|
||||||
await this.hass.connection.sendMessagePromise({
|
type: "maintenance_supporter/group/delete",
|
||||||
type: "maintenance_supporter/group/delete",
|
group_id: groupId,
|
||||||
group_id: groupId,
|
});
|
||||||
});
|
|
||||||
await this._loadData();
|
|
||||||
} catch {
|
|
||||||
this._showToast(t("action_error", this._lang));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Budget as KPI tiles in the stats strip (#125) — replaces the old
|
/** Budget as KPI tiles in the stats strip (#125) — replaces the old
|
||||||
@@ -3148,7 +3059,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
const b = this._budget;
|
const b = this._budget;
|
||||||
if (!b) return nothing;
|
if (!b) return nothing;
|
||||||
const L = this._lang;
|
const L = this._lang;
|
||||||
const cs = b.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
const cs = this._currencySymbol;
|
||||||
const tile = (label: string, spent: number, budget: number | null) => {
|
const tile = (label: string, spent: number, budget: number | null) => {
|
||||||
if (budget !== null) {
|
if (budget !== null) {
|
||||||
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
|
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
|
||||||
@@ -3415,7 +3326,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
.entryId=${obj.entry_id}
|
.entryId=${obj.entry_id}
|
||||||
.parts=${obj.parts || []}
|
.parts=${obj.parts || []}
|
||||||
.canWrite=${!isOperator}
|
.canWrite=${!isOperator}
|
||||||
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
|
.currencySymbol=${this._currencySymbol}
|
||||||
@parts-changed=${() => this._loadData()}
|
@parts-changed=${() => this._loadData()}
|
||||||
></maintenance-parts-section>
|
></maintenance-parts-section>
|
||||||
</div>
|
</div>
|
||||||
@@ -3506,7 +3417,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
|
|
||||||
private _gsDismissed(): Set<string> {
|
private _gsDismissed(): Set<string> {
|
||||||
try {
|
try {
|
||||||
return new Set(JSON.parse(localStorage.getItem(LS_KEYS.gettingStartedDismissed) || "[]"));
|
return new Set(JSON.parse(lsGet(LS_KEYS.gettingStartedDismissed) || "[]"));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
@@ -3515,7 +3426,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
private _dismissGettingStarted(id: string): void {
|
private _dismissGettingStarted(id: string): void {
|
||||||
const next = this._gsDismissed();
|
const next = this._gsDismissed();
|
||||||
next.add(id);
|
next.add(id);
|
||||||
try { localStorage.setItem(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
|
try { lsSet(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3612,7 +3523,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
const next = new Set(this._collapsedSections);
|
const next = new Set(this._collapsedSections);
|
||||||
if (next.has(key)) next.delete(key); else next.add(key);
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
this._collapsedSections = next;
|
this._collapsedSections = next;
|
||||||
try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
|
try { lsSet(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the context the history renderers need from panel state. */
|
/** Build the context the history renderers need from panel state. */
|
||||||
@@ -3639,7 +3550,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
hass: this.hass,
|
hass: this.hass,
|
||||||
filter: this._historyFilter,
|
filter: this._historyFilter,
|
||||||
search: this._historySearch,
|
search: this._historySearch,
|
||||||
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
|
currencySymbol: this._currencySymbol,
|
||||||
setFilter: (f) => { this._historyFilter = f; },
|
setFilter: (f) => { this._historyFilter = f; },
|
||||||
setSearch: (s) => { this._historySearch = s; },
|
setSearch: (s) => { this._historySearch = s; },
|
||||||
openEdit: (entry) => this._openHistoryEdit(entry),
|
openEdit: (entry) => this._openHistoryEdit(entry),
|
||||||
@@ -3674,7 +3585,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
moreMenuOpen: this._moreMenuOpen,
|
moreMenuOpen: this._moreMenuOpen,
|
||||||
activeTab: this._activeTab,
|
activeTab: this._activeTab,
|
||||||
features: this._features,
|
features: this._features,
|
||||||
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
|
currencySymbol: this._currencySymbol,
|
||||||
collapsedSections: this._collapsedSections,
|
collapsedSections: this._collapsedSections,
|
||||||
costDurationToggle: this._costDurationToggle,
|
costDurationToggle: this._costDurationToggle,
|
||||||
suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`),
|
suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`),
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ export function t(key: string, lang?: string): string {
|
|||||||
return STORE[l]?.[key] ?? STORE.en[key] ?? key;
|
return STORE[l]?.[key] ?? STORE.en[key] ?? key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UI language for a hass object — the ONE rule every component's `_lang`
|
||||||
|
* getter delegates to. Before this helper the 23 hand-copied getters had
|
||||||
|
* drifted into three variants (`|| "en"`, `?? navigator.language...`, and a
|
||||||
|
* `locale.language` probe), so sibling components could resolve different
|
||||||
|
* languages from the same hass.
|
||||||
|
*/
|
||||||
|
export function langOf(hass?: { language?: string }): string {
|
||||||
|
return hass?.language || "en";
|
||||||
|
}
|
||||||
|
|
||||||
/** True when *lang*'s table is in memory (English is always bundled). */
|
/** True when *lang*'s table is in memory (English is always bundled). */
|
||||||
export function isLocaleLoaded(lang?: string): boolean {
|
export function isLocaleLoaded(lang?: string): boolean {
|
||||||
const l = normLang(lang);
|
const l = normLang(lang);
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportovat JSON",
|
"settings_export_json": "Exportovat JSON",
|
||||||
"settings_export_yaml": "Exportovat YAML",
|
"settings_export_yaml": "Exportovat YAML",
|
||||||
"settings_export_csv": "Exportovat CSV",
|
"settings_export_csv": "Exportovat CSV",
|
||||||
|
"settings_export_settings": "Exportovat nastavení (JSON)",
|
||||||
"settings_import_csv": "Importovat CSV",
|
"settings_import_csv": "Importovat CSV",
|
||||||
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
|
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
|
||||||
"settings_import_btn": "Importovat",
|
"settings_import_btn": "Importovat",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Eksporter JSON",
|
"settings_export_json": "Eksporter JSON",
|
||||||
"settings_export_yaml": "Eksporter YAML",
|
"settings_export_yaml": "Eksporter YAML",
|
||||||
"settings_export_csv": "Eksporter CSV",
|
"settings_export_csv": "Eksporter CSV",
|
||||||
|
"settings_export_settings": "Eksportér indstillinger (JSON)",
|
||||||
"settings_import_csv": "Importer CSV",
|
"settings_import_csv": "Importer CSV",
|
||||||
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
|
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
|
||||||
"settings_import_btn": "Importer",
|
"settings_import_btn": "Importer",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON exportieren",
|
"settings_export_json": "JSON exportieren",
|
||||||
"settings_export_yaml": "YAML exportieren",
|
"settings_export_yaml": "YAML exportieren",
|
||||||
"settings_export_csv": "CSV exportieren",
|
"settings_export_csv": "CSV exportieren",
|
||||||
|
"settings_export_settings": "Einstellungen exportieren (JSON)",
|
||||||
"settings_import_csv": "CSV importieren",
|
"settings_import_csv": "CSV importieren",
|
||||||
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
|
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
|
||||||
"settings_import_btn": "Importieren",
|
"settings_import_btn": "Importieren",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Export JSON",
|
"settings_export_json": "Export JSON",
|
||||||
"settings_export_yaml": "Export YAML",
|
"settings_export_yaml": "Export YAML",
|
||||||
"settings_export_csv": "Export CSV",
|
"settings_export_csv": "Export CSV",
|
||||||
|
"settings_export_settings": "Export settings (JSON)",
|
||||||
"settings_import_csv": "Import CSV",
|
"settings_import_csv": "Import CSV",
|
||||||
"settings_import_placeholder": "Paste JSON or CSV content here…",
|
"settings_import_placeholder": "Paste JSON or CSV content here…",
|
||||||
"settings_import_btn": "Import",
|
"settings_import_btn": "Import",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportar JSON",
|
"settings_export_json": "Exportar JSON",
|
||||||
"settings_export_yaml": "Exportar YAML",
|
"settings_export_yaml": "Exportar YAML",
|
||||||
"settings_export_csv": "Exportar CSV",
|
"settings_export_csv": "Exportar CSV",
|
||||||
|
"settings_export_settings": "Exportar ajustes (JSON)",
|
||||||
"settings_import_csv": "Importar CSV",
|
"settings_import_csv": "Importar CSV",
|
||||||
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
|
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
|
||||||
"settings_import_btn": "Importar",
|
"settings_import_btn": "Importar",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Vie JSON",
|
"settings_export_json": "Vie JSON",
|
||||||
"settings_export_yaml": "Vie YAML",
|
"settings_export_yaml": "Vie YAML",
|
||||||
"settings_export_csv": "Vie CSV",
|
"settings_export_csv": "Vie CSV",
|
||||||
|
"settings_export_settings": "Vie asetukset (JSON)",
|
||||||
"settings_import_csv": "Tuo CSV",
|
"settings_import_csv": "Tuo CSV",
|
||||||
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
|
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
|
||||||
"settings_import_btn": "Tuo",
|
"settings_import_btn": "Tuo",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exporter JSON",
|
"settings_export_json": "Exporter JSON",
|
||||||
"settings_export_yaml": "Exporter YAML",
|
"settings_export_yaml": "Exporter YAML",
|
||||||
"settings_export_csv": "Exporter CSV",
|
"settings_export_csv": "Exporter CSV",
|
||||||
|
"settings_export_settings": "Exporter les réglages (JSON)",
|
||||||
"settings_import_csv": "Importer CSV",
|
"settings_import_csv": "Importer CSV",
|
||||||
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
|
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
|
||||||
"settings_import_btn": "Importer",
|
"settings_import_btn": "Importer",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON निर्यात करें",
|
"settings_export_json": "JSON निर्यात करें",
|
||||||
"settings_export_yaml": "YAML निर्यात करें",
|
"settings_export_yaml": "YAML निर्यात करें",
|
||||||
"settings_export_csv": "CSV निर्यात करें",
|
"settings_export_csv": "CSV निर्यात करें",
|
||||||
|
"settings_export_settings": "सेटिंग्स निर्यात करें (JSON)",
|
||||||
"settings_import_csv": "CSV आयात करें",
|
"settings_import_csv": "CSV आयात करें",
|
||||||
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
|
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
|
||||||
"settings_import_btn": "आयात करें",
|
"settings_import_btn": "आयात करें",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON exportálása",
|
"settings_export_json": "JSON exportálása",
|
||||||
"settings_export_yaml": "YAML exportálása",
|
"settings_export_yaml": "YAML exportálása",
|
||||||
"settings_export_csv": "CSV exportálása",
|
"settings_export_csv": "CSV exportálása",
|
||||||
|
"settings_export_settings": "Beállítások exportálása (JSON)",
|
||||||
"settings_import_csv": "CSV importálása",
|
"settings_import_csv": "CSV importálása",
|
||||||
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
|
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
|
||||||
"settings_import_btn": "Importálás",
|
"settings_import_btn": "Importálás",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Esporta JSON",
|
"settings_export_json": "Esporta JSON",
|
||||||
"settings_export_yaml": "Esporta YAML",
|
"settings_export_yaml": "Esporta YAML",
|
||||||
"settings_export_csv": "Esporta CSV",
|
"settings_export_csv": "Esporta CSV",
|
||||||
|
"settings_export_settings": "Esporta impostazioni (JSON)",
|
||||||
"settings_import_csv": "Importa CSV",
|
"settings_import_csv": "Importa CSV",
|
||||||
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
|
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
|
||||||
"settings_import_btn": "Importa",
|
"settings_import_btn": "Importa",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSONをエクスポート",
|
"settings_export_json": "JSONをエクスポート",
|
||||||
"settings_export_yaml": "YAMLをエクスポート",
|
"settings_export_yaml": "YAMLをエクスポート",
|
||||||
"settings_export_csv": "CSVをエクスポート",
|
"settings_export_csv": "CSVをエクスポート",
|
||||||
|
"settings_export_settings": "設定をエクスポート(JSON)",
|
||||||
"settings_import_csv": "CSVをインポート",
|
"settings_import_csv": "CSVをインポート",
|
||||||
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
|
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
|
||||||
"settings_import_btn": "インポート",
|
"settings_import_btn": "インポート",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON 내보내기",
|
"settings_export_json": "JSON 내보내기",
|
||||||
"settings_export_yaml": "YAML 내보내기",
|
"settings_export_yaml": "YAML 내보내기",
|
||||||
"settings_export_csv": "CSV 내보내기",
|
"settings_export_csv": "CSV 내보내기",
|
||||||
|
"settings_export_settings": "설정 내보내기 (JSON)",
|
||||||
"settings_import_csv": "CSV 가져오기",
|
"settings_import_csv": "CSV 가져오기",
|
||||||
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
|
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
|
||||||
"settings_import_btn": "가져오기",
|
"settings_import_btn": "가져오기",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Eksporter JSON",
|
"settings_export_json": "Eksporter JSON",
|
||||||
"settings_export_yaml": "Eksporter YAML",
|
"settings_export_yaml": "Eksporter YAML",
|
||||||
"settings_export_csv": "Eksporter CSV",
|
"settings_export_csv": "Eksporter CSV",
|
||||||
|
"settings_export_settings": "Eksporter innstillinger (JSON)",
|
||||||
"settings_import_csv": "Importer CSV",
|
"settings_import_csv": "Importer CSV",
|
||||||
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
|
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
|
||||||
"settings_import_btn": "Importer",
|
"settings_import_btn": "Importer",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "JSON exporteren",
|
"settings_export_json": "JSON exporteren",
|
||||||
"settings_export_yaml": "YAML exporteren",
|
"settings_export_yaml": "YAML exporteren",
|
||||||
"settings_export_csv": "CSV exporteren",
|
"settings_export_csv": "CSV exporteren",
|
||||||
|
"settings_export_settings": "Instellingen exporteren (JSON)",
|
||||||
"settings_import_csv": "CSV importeren",
|
"settings_import_csv": "CSV importeren",
|
||||||
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
|
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
|
||||||
"settings_import_btn": "Importeren",
|
"settings_import_btn": "Importeren",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Eksportuj JSON",
|
"settings_export_json": "Eksportuj JSON",
|
||||||
"settings_export_yaml": "Eksportuj YAML",
|
"settings_export_yaml": "Eksportuj YAML",
|
||||||
"settings_export_csv": "Eksportuj CSV",
|
"settings_export_csv": "Eksportuj CSV",
|
||||||
|
"settings_export_settings": "Eksportuj ustawienia (JSON)",
|
||||||
"settings_import_csv": "Importuj CSV",
|
"settings_import_csv": "Importuj CSV",
|
||||||
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
|
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
|
||||||
"settings_import_btn": "Importuj",
|
"settings_import_btn": "Importuj",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "Exportar JSON",
|
"settings_export_json": "Exportar JSON",
|
||||||
"settings_export_yaml": "Exportar YAML",
|
"settings_export_yaml": "Exportar YAML",
|
||||||
"settings_export_csv": "Exportar CSV",
|
"settings_export_csv": "Exportar CSV",
|
||||||
|
"settings_export_settings": "Exportar configurações (JSON)",
|
||||||
"settings_import_csv": "Importar CSV",
|
"settings_import_csv": "Importar CSV",
|
||||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||||
"settings_import_btn": "Importar",
|
"settings_import_btn": "Importar",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportar JSON",
|
"settings_export_json": "Exportar JSON",
|
||||||
"settings_export_yaml": "Exportar YAML",
|
"settings_export_yaml": "Exportar YAML",
|
||||||
"settings_export_csv": "Exportar CSV",
|
"settings_export_csv": "Exportar CSV",
|
||||||
|
"settings_export_settings": "Exportar definições (JSON)",
|
||||||
"settings_import_csv": "Importar CSV",
|
"settings_import_csv": "Importar CSV",
|
||||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||||
"settings_import_btn": "Importar",
|
"settings_import_btn": "Importar",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Экспорт JSON",
|
"settings_export_json": "Экспорт JSON",
|
||||||
"settings_export_yaml": "Экспорт YAML",
|
"settings_export_yaml": "Экспорт YAML",
|
||||||
"settings_export_csv": "Экспорт CSV",
|
"settings_export_csv": "Экспорт CSV",
|
||||||
|
"settings_export_settings": "Экспорт настроек (JSON)",
|
||||||
"settings_import_csv": "Импорт CSV",
|
"settings_import_csv": "Импорт CSV",
|
||||||
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
|
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
|
||||||
"settings_import_btn": "Импортировать",
|
"settings_import_btn": "Импортировать",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Exportera JSON",
|
"settings_export_json": "Exportera JSON",
|
||||||
"settings_export_yaml": "Exportera YAML",
|
"settings_export_yaml": "Exportera YAML",
|
||||||
"settings_export_csv": "Exportera CSV",
|
"settings_export_csv": "Exportera CSV",
|
||||||
|
"settings_export_settings": "Exportera inställningar (JSON)",
|
||||||
"settings_import_csv": "Importera CSV",
|
"settings_import_csv": "Importera CSV",
|
||||||
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
|
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
|
||||||
"settings_import_btn": "Importera",
|
"settings_import_btn": "Importera",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
"settings_export_json": "JSON dışa aktar",
|
"settings_export_json": "JSON dışa aktar",
|
||||||
"settings_export_yaml": "YAML dışa aktar",
|
"settings_export_yaml": "YAML dışa aktar",
|
||||||
"settings_export_csv": "CSV dışa aktar",
|
"settings_export_csv": "CSV dışa aktar",
|
||||||
|
"settings_export_settings": "Ayarları dışa aktar (JSON)",
|
||||||
"settings_import_csv": "CSV içe aktar",
|
"settings_import_csv": "CSV içe aktar",
|
||||||
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
|
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
|
||||||
"settings_import_btn": "İçe aktar",
|
"settings_import_btn": "İçe aktar",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"settings_export_json": "Експортувати JSON",
|
"settings_export_json": "Експортувати JSON",
|
||||||
"settings_export_yaml": "Експортувати YAML",
|
"settings_export_yaml": "Експортувати YAML",
|
||||||
"settings_export_csv": "Експортувати CSV",
|
"settings_export_csv": "Експортувати CSV",
|
||||||
|
"settings_export_settings": "Експорт налаштувань (JSON)",
|
||||||
"settings_import_csv": "Імпортувати CSV",
|
"settings_import_csv": "Імпортувати CSV",
|
||||||
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
|
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
|
||||||
"settings_import_btn": "Імпортувати",
|
"settings_import_btn": "Імпортувати",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user