182 files
This commit is contained in:
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MaintenanceStatus
|
||||
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MaintenanceStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import MaintenanceSupporterData
|
||||
@@ -27,9 +27,46 @@ _COUNTED_STATUSES = (
|
||||
)
|
||||
|
||||
|
||||
def get_object_entries(hass: HomeAssistant) -> list[ConfigEntry]:
|
||||
"""Return all non-global config entries for this domain."""
|
||||
return [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
|
||||
def object_name(entry: ConfigEntry) -> str:
|
||||
"""The object's display name, falling back to the entry title.
|
||||
|
||||
One rule for the ~13 hand-written fallbacks that existed in four spellings
|
||||
— half used ``.get("name", title)``, which kept an EMPTY name instead of
|
||||
falling through to the title."""
|
||||
return str((entry.data.get(CONF_OBJECT) or {}).get("name") or entry.title)
|
||||
|
||||
|
||||
def merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
|
||||
"""Merged task data (static ConfigEntry + dynamic Store) for an entry.
|
||||
|
||||
THE read path for task dicts outside the coordinator — it was re-implemented
|
||||
eight times, and only one copy overlaid the in-cycle checklist ticks. The
|
||||
ticks are overlaid HERE rather than via the Store merge whitelist: merged
|
||||
dicts feed MaintenanceTask.from_dict all over the coordinator, and this
|
||||
field is presentation state the model never needs. Degrades to the static
|
||||
data when the Store is unavailable.
|
||||
"""
|
||||
tasks_data: dict[str, Any] = entry.data.get(CONF_TASKS, {})
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is None:
|
||||
return tasks_data
|
||||
merged: dict[str, Any] = store.merge_all_tasks(tasks_data)
|
||||
for tid, td in merged.items():
|
||||
progress = store.get_task_state(tid).get("checklist_progress")
|
||||
if progress:
|
||||
td["checklist_progress"] = progress
|
||||
return merged
|
||||
|
||||
|
||||
def get_object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]:
|
||||
"""Return all non-global config entries for this domain, optionally
|
||||
narrowed to a selection (``entry_ids=None`` means all objects)."""
|
||||
return [
|
||||
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 get_runtime_data(hass: HomeAssistant, entry_id: str) -> MaintenanceSupporterData | None:
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..const import (
|
||||
MAX_CHECKLIST_ITEM_LENGTH,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
)
|
||||
from .aggregate import merged_tasks
|
||||
from .dates import INTERVAL_UNITS
|
||||
from .global_options import get_default_warning_days
|
||||
from .schedule import read_legacy_fields
|
||||
@@ -33,6 +34,11 @@ _COLUMNS = [
|
||||
"object_area_id",
|
||||
"object_installation_date",
|
||||
"object_warranty_expiry",
|
||||
# The 2026-08 round-trip audit: the IMPORT read these two from day one,
|
||||
# but the export never wrote the columns — a CSV migration silently
|
||||
# dropped object notes + documentation URL.
|
||||
"object_documentation_url",
|
||||
"object_notes",
|
||||
"task_name",
|
||||
"task_type",
|
||||
"enabled",
|
||||
@@ -83,10 +89,7 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
|
||||
for entry in entries:
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
# Merge static + Store dynamic data
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
static_tasks = entry.data.get(CONF_TASKS, {})
|
||||
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
|
||||
tasks_data = merged_tasks(entry)
|
||||
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
coord_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
@@ -104,6 +107,8 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
|
||||
"object_area_id": _csv_safe(obj_data.get("area_id", "")),
|
||||
"object_installation_date": _csv_safe(obj_data.get("installation_date", "")),
|
||||
"object_warranty_expiry": _csv_safe(obj_data.get("warranty_expiry", "")),
|
||||
"object_documentation_url": _csv_safe(obj_data.get("documentation_url") or ""),
|
||||
"object_notes": _csv_safe(obj_data.get("notes") or ""),
|
||||
"task_name": _csv_safe(tdata.get("name", "")),
|
||||
"task_type": tdata.get("type", "custom"),
|
||||
"enabled": tdata.get("enabled", True),
|
||||
@@ -169,10 +174,7 @@ def export_object_records_csv(hass: HomeAssistant, entry_ids: set[str] | None =
|
||||
|
||||
for entry in entries:
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
static_tasks = entry.data.get(CONF_TASKS, {})
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
|
||||
tasks_data = merged_tasks(entry)
|
||||
writer.writerow(
|
||||
{
|
||||
"object_name": _csv_safe(obj_data.get("name", "")),
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_PANEL_TITLE,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
@@ -46,6 +47,16 @@ def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
|
||||
return (entry.options or entry.data) if entry is not None else {}
|
||||
|
||||
|
||||
def is_schedule_time_enabled(hass: HomeAssistant) -> bool:
|
||||
"""The global advanced flag for time-of-day scheduling.
|
||||
|
||||
Reads the global entry's options on every call (cheap), so a toggle in
|
||||
Settings takes effect on the next refresh without a restart. Was copied
|
||||
inline by the coordinator, calendar, and sensor before living here.
|
||||
"""
|
||||
return bool(get_global_options(hass).get(CONF_ADVANCED_SCHEDULE_TIME, False))
|
||||
|
||||
|
||||
def get_default_warning_days(hass: HomeAssistant) -> int:
|
||||
"""Resolve the integration-wide default warning_days for new tasks.
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ from ..const import (
|
||||
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
|
||||
DEFAULT_SNOOZE_DURATION_HOURS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MaintenanceStatus,
|
||||
)
|
||||
from .global_options import get_global_options
|
||||
from .i18n import normalize_language
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -572,6 +572,17 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
def _service_payload(title: str, message: str, *, tag: str, url: str = "/maintenance-supporter") -> dict[str, Any]:
|
||||
"""Notify payload with the deep link doubled into ``url`` (iOS) and
|
||||
``clickAction`` (Android) — five hand-built copies each repeated the
|
||||
link string twice."""
|
||||
return {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"data": {"tag": tag, "url": url, "clickAction": url},
|
||||
}
|
||||
|
||||
|
||||
def _notif_t(key: str, lang: str, **kwargs: str) -> str:
|
||||
"""Get notification translation string."""
|
||||
strings = _NOTIFICATION_STRINGS.get(lang, _NOTIFICATION_STRINGS["en"])
|
||||
@@ -705,10 +716,66 @@ class NotificationManager:
|
||||
@property
|
||||
def _global_options(self) -> Mapping[str, Any]:
|
||||
"""Get global options from the global config entry."""
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
return entry.options or entry.data
|
||||
return {}
|
||||
return get_global_options(self.hass)
|
||||
|
||||
def _rate_limited(self, key: str, min_seconds: float) -> bool:
|
||||
"""True when ``key`` fired less than ``min_seconds`` ago.
|
||||
|
||||
The _SENT_ONCE sentinel never blocks here — only the status-change
|
||||
path stores it, with its own interval-hours semantics. Extracted from
|
||||
the bundle/budget copies of the elapsed check."""
|
||||
last = self._last_notified.get(key)
|
||||
if last is None or last == _SENT_ONCE:
|
||||
return False
|
||||
return (dt_util.now() - last).total_seconds() < min_seconds
|
||||
|
||||
async def _resolve_and_send(
|
||||
self,
|
||||
responsible_user_id: str | None,
|
||||
*,
|
||||
title: str,
|
||||
message: str,
|
||||
entry_id: str,
|
||||
task_id: str,
|
||||
) -> bool:
|
||||
"""Resolve targets (per-user services, else the global service) and
|
||||
fan the notification out; True when at least one send succeeded.
|
||||
|
||||
The status-change and lead-reminder paths carried drifting copies of
|
||||
this block — one logged the per-user fallback, the other was silent.
|
||||
"""
|
||||
target_services: list[str] = []
|
||||
if responsible_user_id:
|
||||
user_services = await get_user_notify_services(self.hass, responsible_user_id)
|
||||
if user_services:
|
||||
target_services = user_services
|
||||
_LOGGER.debug(
|
||||
"Sending notification to user %s services: %s",
|
||||
responsible_user_id,
|
||||
user_services,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"User %s has no notification services, falling back to global",
|
||||
responsible_user_id,
|
||||
)
|
||||
if not target_services and self.notify_service:
|
||||
target_services = [self.notify_service]
|
||||
if not target_services:
|
||||
_LOGGER.warning("No notification services available")
|
||||
return False
|
||||
|
||||
success = False
|
||||
for service in target_services:
|
||||
if await self._async_send_notification_to_service(
|
||||
service=service,
|
||||
title=title,
|
||||
message=message,
|
||||
entry_id=entry_id,
|
||||
task_id=task_id,
|
||||
):
|
||||
success = True
|
||||
return success
|
||||
|
||||
@property
|
||||
def _lang(self) -> str:
|
||||
@@ -937,44 +1004,13 @@ class NotificationManager:
|
||||
lang = self._lang
|
||||
title, message = self._build_message(new_status, lang, task_name, object_name, days_until_due, next_due)
|
||||
|
||||
# Determine target services: user-specific or global
|
||||
target_services = []
|
||||
if responsible_user_id:
|
||||
user_services = await get_user_notify_services(self.hass, responsible_user_id)
|
||||
if user_services:
|
||||
target_services = user_services
|
||||
_LOGGER.debug(
|
||||
"Sending notification to user %s services: %s",
|
||||
responsible_user_id,
|
||||
user_services,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"User %s has no notification services, falling back to global",
|
||||
responsible_user_id,
|
||||
)
|
||||
|
||||
# Fallback to global service
|
||||
if not target_services and self.notify_service:
|
||||
target_services = [self.notify_service]
|
||||
|
||||
if not target_services:
|
||||
_LOGGER.warning("No notification services available")
|
||||
return
|
||||
|
||||
# Send to all target services
|
||||
success = False
|
||||
for service in target_services:
|
||||
if await self._async_send_notification_to_service(
|
||||
service=service,
|
||||
title=title,
|
||||
message=message,
|
||||
entry_id=entry_id,
|
||||
task_id=task_id,
|
||||
):
|
||||
success = True
|
||||
|
||||
if not success:
|
||||
if not await self._resolve_and_send(
|
||||
responsible_user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
entry_id=entry_id,
|
||||
task_id=task_id,
|
||||
):
|
||||
return
|
||||
|
||||
# Record send time (only on success)
|
||||
@@ -1085,15 +1121,14 @@ class NotificationManager:
|
||||
}
|
||||
)
|
||||
|
||||
service_data: dict[str, Any] = {"title": title, "message": message}
|
||||
data: dict[str, Any] = {
|
||||
"tag": f"maintenance_{task_id}",
|
||||
"url": f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
|
||||
"clickAction": f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
|
||||
}
|
||||
service_data = _service_payload(
|
||||
title,
|
||||
message,
|
||||
tag=f"maintenance_{task_id}",
|
||||
url=f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
|
||||
)
|
||||
if actions:
|
||||
data["actions"] = actions[:3] # Android supports max 3
|
||||
service_data["data"] = data
|
||||
service_data["data"]["actions"] = actions[:3] # Android supports max 3
|
||||
|
||||
try:
|
||||
return await async_dispatch_notify(self.hass, service, service_data)
|
||||
@@ -1117,12 +1152,8 @@ class NotificationManager:
|
||||
|
||||
# Rate-limit bundled notifications (once per hour)
|
||||
bundle_key = f"{entry_id}_bundled"
|
||||
if bundle_key in self._last_notified:
|
||||
last = self._last_notified[bundle_key]
|
||||
if last != _SENT_ONCE:
|
||||
elapsed = (dt_util.now() - last).total_seconds()
|
||||
if elapsed < 3600:
|
||||
return
|
||||
if self._rate_limited(bundle_key, 3600):
|
||||
return
|
||||
|
||||
if not self._check_daily_limit():
|
||||
return
|
||||
@@ -1148,15 +1179,12 @@ class NotificationManager:
|
||||
if self.title_style == "object_name" and object_name:
|
||||
title = object_name
|
||||
|
||||
service_data: dict[str, Any] = {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"data": {
|
||||
"tag": f"maintenance_bundled_{entry_id}",
|
||||
"url": f"/maintenance-supporter?entry_id={entry_id}",
|
||||
"clickAction": f"/maintenance-supporter?entry_id={entry_id}",
|
||||
},
|
||||
}
|
||||
service_data = _service_payload(
|
||||
title,
|
||||
message,
|
||||
tag=f"maintenance_bundled_{entry_id}",
|
||||
url=f"/maintenance-supporter?entry_id={entry_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
if await async_dispatch_notify(self.hass, self.notify_service, service_data):
|
||||
@@ -1178,15 +1206,11 @@ class NotificationManager:
|
||||
if not self.enabled or not self.notify_service:
|
||||
return
|
||||
lang = self._lang
|
||||
service_data: dict[str, Any] = {
|
||||
"title": _notif_t("digest_title", lang),
|
||||
"message": _notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
|
||||
"data": {
|
||||
"tag": "maintenance_weekly_digest",
|
||||
"url": "/maintenance-supporter",
|
||||
"clickAction": "/maintenance-supporter",
|
||||
},
|
||||
}
|
||||
service_data = _service_payload(
|
||||
_notif_t("digest_title", lang),
|
||||
_notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
|
||||
tag="maintenance_weekly_digest",
|
||||
)
|
||||
try:
|
||||
await async_dispatch_notify(self.hass, self.notify_service, service_data)
|
||||
_LOGGER.debug("Weekly digest sent: %s overdue, %s due soon", overdue, due_soon)
|
||||
@@ -1202,21 +1226,17 @@ class NotificationManager:
|
||||
if not self.enabled or not self.notify_service or not names:
|
||||
return
|
||||
lang = self._lang
|
||||
service_data: dict[str, Any] = {
|
||||
"title": _notif_t("warranty_title", lang),
|
||||
"message": _notif_t(
|
||||
service_data = _service_payload(
|
||||
_notif_t("warranty_title", lang),
|
||||
_notif_t(
|
||||
"warranty_message",
|
||||
lang,
|
||||
count=str(len(names)),
|
||||
days=str(days),
|
||||
names=", ".join(names),
|
||||
),
|
||||
"data": {
|
||||
"tag": "maintenance_warranty_reminder",
|
||||
"url": "/maintenance-supporter",
|
||||
"clickAction": "/maintenance-supporter",
|
||||
},
|
||||
}
|
||||
tag="maintenance_warranty_reminder",
|
||||
)
|
||||
try:
|
||||
await async_dispatch_notify(self.hass, self.notify_service, service_data)
|
||||
_LOGGER.debug("Warranty reminder sent: %s object(s)", len(names))
|
||||
@@ -1269,27 +1289,13 @@ class NotificationManager:
|
||||
due=next_due if next_due is not None else "?",
|
||||
)
|
||||
|
||||
target_services: list[str] = []
|
||||
if responsible_user_id:
|
||||
user_services = await get_user_notify_services(self.hass, responsible_user_id)
|
||||
if user_services:
|
||||
target_services = user_services
|
||||
if not target_services and self.notify_service:
|
||||
target_services = [self.notify_service]
|
||||
if not target_services:
|
||||
return
|
||||
|
||||
success = False
|
||||
for service in target_services:
|
||||
if await self._async_send_notification_to_service(
|
||||
service=service,
|
||||
title=title,
|
||||
message=message,
|
||||
entry_id=entry_id,
|
||||
task_id=task_id,
|
||||
):
|
||||
success = True
|
||||
if success:
|
||||
if await self._resolve_and_send(
|
||||
responsible_user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
entry_id=entry_id,
|
||||
task_id=task_id,
|
||||
):
|
||||
self._daily_count += 1
|
||||
_LOGGER.debug("Lead reminder sent: %s due in %s day(s)", task_name, days)
|
||||
|
||||
@@ -1309,12 +1315,8 @@ class NotificationManager:
|
||||
|
||||
# Rate-limit budget alerts (once per 24 hours per period)
|
||||
budget_key = f"_budget_{period}"
|
||||
if budget_key in self._last_notified:
|
||||
last = self._last_notified[budget_key]
|
||||
if last != _SENT_ONCE:
|
||||
elapsed = (dt_util.now() - last).total_seconds()
|
||||
if elapsed < 86400: # 24 hours
|
||||
return
|
||||
if self._rate_limited(budget_key, 86400):
|
||||
return
|
||||
|
||||
if not self._check_daily_limit():
|
||||
return
|
||||
@@ -1331,15 +1333,7 @@ class NotificationManager:
|
||||
budget=f"{budget:.2f}{currency_symbol}",
|
||||
)
|
||||
|
||||
service_data: dict[str, Any] = {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"data": {
|
||||
"tag": f"maintenance_budget_{period}",
|
||||
"url": "/maintenance-supporter",
|
||||
"clickAction": "/maintenance-supporter",
|
||||
},
|
||||
}
|
||||
service_data = _service_payload(title, message, tag=f"maintenance_budget_{period}")
|
||||
|
||||
try:
|
||||
if await async_dispatch_notify(self.hass, self.notify_service, service_data):
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..const import (
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_ADOPTED_NOTES,
|
||||
)
|
||||
from .aggregate import object_name
|
||||
|
||||
PROBLEM_DEVICE_CLASS = "problem"
|
||||
# safety (NAS disk-health / lifespan thresholds) and tamper alarms behave like
|
||||
@@ -95,7 +96,7 @@ def _object_by_device(hass: HomeAssistant) -> dict[str, dict[str, str]]:
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
dev = obj.get("ha_device_id")
|
||||
if dev:
|
||||
out[dev] = {"entry_id": entry.entry_id, "name": obj.get("name", entry.title)}
|
||||
out[dev] = {"entry_id": entry.entry_id, "name": object_name(entry)}
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -108,24 +108,17 @@ def _coerce_int(value: Any, default: int) -> int:
|
||||
|
||||
def _global_options(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Return the global entry's options (or data), or {} when absent."""
|
||||
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
|
||||
from .global_options import get_global_options
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
opts: dict[str, Any] = dict(entry.options or entry.data)
|
||||
return opts
|
||||
return {}
|
||||
return dict(get_global_options(hass))
|
||||
|
||||
|
||||
def _merged_tasks(entry: Any) -> dict[str, Any]:
|
||||
"""Static (ConfigEntry) + dynamic (Store) task data for an object entry."""
|
||||
from ..const import CONF_TASKS
|
||||
|
||||
tasks = entry.data.get(CONF_TASKS, {})
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
merged: dict[str, Any] = store.merge_all_tasks(tasks) if store is not None else tasks
|
||||
return merged
|
||||
from .aggregate import merged_tasks
|
||||
|
||||
return merged_tasks(entry)
|
||||
|
||||
|
||||
async def async_run_retention_sweep(hass: HomeAssistant) -> None:
|
||||
@@ -171,17 +164,18 @@ async def async_run_retention_sweep(hass: HomeAssistant) -> None:
|
||||
continue
|
||||
|
||||
if to_archive:
|
||||
new_tasks = dict(entry.data.get(CONF_TASKS, {}))
|
||||
from .entry_tasks import write_tasks
|
||||
|
||||
existing = entry.data.get(CONF_TASKS, {})
|
||||
updates: dict[str, dict[str, Any]] = {}
|
||||
for tid in to_archive:
|
||||
if tid not in new_tasks:
|
||||
if tid not in existing:
|
||||
continue
|
||||
td = dict(new_tasks[tid])
|
||||
td = dict(existing[tid])
|
||||
td["archived_at"] = now_iso
|
||||
td["archived_reason"] = _REASON_AUTO
|
||||
new_tasks[tid] = td
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
updates[tid] = td
|
||||
write_tasks(hass, entry, updates)
|
||||
_LOGGER.info(
|
||||
"Auto-archived %d completed one-off task(s) in %s",
|
||||
len(to_archive),
|
||||
|
||||
@@ -98,6 +98,37 @@ def _cap_strings(d: dict[str, Any], limits: dict[str, int]) -> None:
|
||||
d[field] = v[:max_len]
|
||||
|
||||
|
||||
# Keys a FRESH task copy must never inherit: per-task-unique identity
|
||||
# (entity_slug, nfc_tag_id) and dynamic lifecycle state (history, schedule
|
||||
# anchors, a one-shot due_override defer, adaptive tuning, archive markers).
|
||||
# One list for task-duplicate, object-duplicate, and object-replace — the
|
||||
# three hand-copied lists had drifted (task-duplicate kept archive markers,
|
||||
# and none of them dropped due_override).
|
||||
_FRESH_COPY_STRIP_KEYS = (
|
||||
"entity_slug",
|
||||
"nfc_tag_id",
|
||||
"history",
|
||||
"last_performed",
|
||||
"last_planned_due",
|
||||
"due_override",
|
||||
"adaptive_config",
|
||||
"archived_at",
|
||||
"archived_reason",
|
||||
)
|
||||
|
||||
|
||||
def strip_task_runtime_state(task: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove unique/dynamic state from a task dict copy, in place.
|
||||
|
||||
Returns the same dict for fluent use.
|
||||
"""
|
||||
for key in _FRESH_COPY_STRIP_KEYS:
|
||||
task.pop(key, None)
|
||||
if isinstance(task.get("trigger_config"), dict):
|
||||
task["trigger_config"].pop("_trigger_state", None)
|
||||
return task
|
||||
|
||||
|
||||
def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Truncate user-controllable strings + numerics on a task dict in-place.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..const import (
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
)
|
||||
from .dates import add_interval
|
||||
from .dates import add_interval, parse_iso_date
|
||||
from .schedule import (
|
||||
KIND_DAY_OF_MONTH,
|
||||
KIND_NTH_WEEKDAY,
|
||||
@@ -122,12 +122,7 @@ class VacationState:
|
||||
|
||||
def _coerce_date(value: Any) -> date | None:
|
||||
"""Parse an ISO YYYY-MM-DD string from config; tolerant of None / junk."""
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parse_iso_date(value)
|
||||
|
||||
|
||||
def _coerce_buffer(value: Any) -> int:
|
||||
|
||||
Reference in New Issue
Block a user