diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index 857c6a98..8496b9b5 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -650,6 +650,7 @@ async def _async_register_services(hass: HomeAssistant) -> None: if not coordinator: _LOGGER.error("No TaskMate coordinator available") return + await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) try: await coordinator.async_read_aloud( child_id=call.data[ATTR_CHILD_ID], diff --git a/custom_components/taskmate/button.py b/custom_components/taskmate/button.py index 0bb7b209..6fc46729 100644 --- a/custom_components/taskmate/button.py +++ b/custom_components/taskmate/button.py @@ -7,10 +7,12 @@ import logging from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import Unauthorized from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import authz from .const import DOMAIN from .coordinator import TaskMateCoordinator from .entity import taskmate_device_info @@ -152,6 +154,11 @@ class CompleteChoreButton(TaskMateBaseButton): async def async_press(self) -> None: """Handle the button press.""" + ctx = getattr(self, "_context", None) + if not await authz.async_context_allows_child( + getattr(self, "hass", None), self.coordinator, ctx, self.child_id + ): + raise Unauthorized(context=ctx) try: await self.coordinator.async_complete_chore(self.chore_id, self.child_id) except ValueError as err: @@ -236,6 +243,11 @@ class ClaimRewardButton(TaskMateBaseButton): async def async_press(self) -> None: """Handle the button press.""" + ctx = getattr(self, "_context", None) + if not await authz.async_context_allows_child( + getattr(self, "hass", None), self.coordinator, ctx, self.child_id + ): + raise Unauthorized(context=ctx) try: await self.coordinator.async_claim_reward(self.reward_id, self.child_id) except ValueError as err: diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index e8e9c0cd..5937dc01 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -17,6 +17,10 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +# Cap on simultaneously-pending swap requests, so a scripted caller can't grow +# storage / the approval queue without bound. +_MAX_PENDING_SWAP_REQUESTS = 50 + def _add_months(d: date, months: int) -> date: """Step a date forward by calendar months, clamping to the month's last day.""" @@ -158,6 +162,14 @@ class ChoresMixin: current = getattr(chore, "assignment_current_child_id", "") or "" if current == requester_id: raise ValueError("Chore is already assigned to that child today") + # Reject a duplicate pending request for the same chore+requester, and + # cap the pending queue, so a scripted caller can't flood storage or the + # parent's approval list with identical swap requests. + pending = [r for r in self.storage.get_swap_requests() if r.get("status") == "pending"] + if any(r.get("chore_id") == chore_id and r.get("requester_id") == requester_id for r in pending): + raise ValueError("A swap request for this chore is already pending") + if len(pending) >= _MAX_PENDING_SWAP_REQUESTS: + raise ValueError("Too many pending swap requests") req = { "id": generate_id(), "chore_id": chore_id, @@ -739,6 +751,24 @@ class ChoresMixin: ) return None + # specific_days chores were historically only filtered by the child card + # (see get_due_chores_for_child), so a crafted service / entity / Dev + # Tools call could complete one that is disabled, not scheduled today, or + # assigned to a different child. Enforce the same eligibility the card + # uses, server-side. Parents completing on behalf are the authority and + # stay exempt. + if ( + not as_parent + and getattr(chore, "schedule_mode", "specific_days") == "specific_days" + and not self._is_chore_completable_by_child(chore, child_id) + ): + _LOGGER.debug( + "complete_chore no-op: '%s' not eligible for %s today (assignment/schedule/availability)", + chore.name, + child.name, + ) + return None + # Check recurrence window for Mode B chores if getattr(chore, "schedule_mode", "specific_days") == "recurring" and not self.is_chore_available_for_child( chore, child_id @@ -1473,6 +1503,30 @@ class ChoresMixin: days_since = (today - last_dt).days return days_since >= window_days + def _is_chore_completable_by_child(self, chore, child_id: str) -> bool: + """Whether ``child_id`` may complete ``chore`` right now (card parity). + + Combines ``is_chore_available_for_child`` + (enabled/vacation/disabled_for/rotation/visibility/weather/deadline/ + recurrence) with everyone-mode ``assigned_to`` membership and the + ``specific_days`` ``due_days`` day-of-week filter. Does NOT apply the + daily-limit cap — callers handle that. This is the single source of + truth the child card, the todo platform and the completion path share. + """ + if not self.is_chore_available_for_child(chore, child_id): + return False + assigned = getattr(chore, "assigned_to", []) or [] + if assigned and child_id not in assigned: + return False + if getattr(chore, "schedule_mode", "specific_days") == "specific_days": + due_days = getattr(chore, "due_days", []) or [] + if due_days: + today = dt_util.as_local(dt_util.now()).date() + dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()] + if dow not in due_days: + return False + return True + def get_due_chores_for_child(self, child_id: str) -> list: """Chores this child should still act on today (their outstanding to-dos). @@ -1485,20 +1539,12 @@ class ChoresMixin: Used by the todo platform (and reusable by cards/automations). """ today = dt_util.as_local(dt_util.now()).date() - dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()] out = [] with self.availability_build_scope(): completions = self._cached_completions() for chore in self.storage.get_chores(): - if not self.is_chore_available_for_child(chore, child_id): + if not self._is_chore_completable_by_child(chore, child_id): continue - assigned = getattr(chore, "assigned_to", []) or [] - if assigned and child_id not in assigned: - continue - if getattr(chore, "schedule_mode", "specific_days") == "specific_days": - due_days = getattr(chore, "due_days", []) or [] - if due_days and dow not in due_days: - continue limit = getattr(chore, "daily_limit", 1) or 1 done = 0 for c in completions: diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index a9f268ed..e4b958fd 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -20,6 +20,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_change +from . import authz from .const import ( DEFAULT_NOTIFICATION_GROUP, DEFAULT_NOTIFICATION_NAV_URL, @@ -554,6 +555,13 @@ class NotificationCoordinator: coordinator = getattr(self, "coordinator", None) if coordinator is None: return + # Approving/rejecting is a parent action. The event bus is a second door + # into it, so enforce the same admin/parent identity check the service + # and WebSocket approval paths use — otherwise anyone who can fire this + # event (e.g. a child's Companion-app registration) could self-approve. + if not await authz.async_context_is_parent(self.hass, coordinator, getattr(event, "context", None)): + _LOGGER.warning("Ignoring TaskMate mobile action from a non-parent user") + return if action.startswith("TASKMATE_APPROVE_"): entry_id = action[len("TASKMATE_APPROVE_") :] diff --git a/custom_components/taskmate/coord_timed.py b/custom_components/taskmate/coord_timed.py index 2ffeff73..8b2c3703 100644 --- a/custom_components/taskmate/coord_timed.py +++ b/custom_components/taskmate/coord_timed.py @@ -33,24 +33,31 @@ class TimedMixin: now = dt_util.now() today = dt_util.as_local(now).date().isoformat() + cap_seconds = chore.timed_max_daily_minutes * 60 if chore.timed_max_daily_minutes > 0 else 0 existing = self.storage.get_active_timed_session(chore_id, child_id) if existing and existing.state == "running": raise ValueError("Timer is already running") + # Seconds already credited today survive a stop (which removes the live + # session); count them so the cap is a true daily budget rather than a + # per-session one that resets on every stop. + credited_today = self._timed_seconds_credited_today(chore_id, child_id) + if existing and existing.state == "paused": - # Check daily cap before resuming - if chore.timed_max_daily_minutes > 0 and existing.total_seconds_today >= chore.timed_max_daily_minutes * 60: + if cap_seconds and (credited_today + existing.total_seconds_today) >= cap_seconds: raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)") existing.state = "running" existing.segments.append({"start": now.isoformat(), "end": None}) self.storage.save_timed_session(existing) else: - # Check daily cap before starting fresh - if chore.timed_max_daily_minutes > 0: - old_session = self.storage.get_timed_session(chore_id, child_id, today) - if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60: - raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)") + # Fresh start: enforce the same assignment/enabled/schedule + # eligibility the child card uses, so a disabled, off-day, or + # not-yours timed chore can't be farmed via a crafted start call. + if not self._timed_start_allowed(chore, child_id): + raise ValueError(f"'{chore.name}' is not available for {child.name} right now") + if cap_seconds and credited_today >= cap_seconds: + raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)") session = TimedSession( chore_id=chore_id, child_id=child_id, @@ -102,10 +109,13 @@ class TimedMixin: total_seconds = self._calc_session_seconds(session) - # Clamp to daily cap + # Clamp to the remaining daily budget (cap minus what was already + # credited today), so repeated start/stop cycles can't exceed the cap. if chore.timed_max_daily_minutes > 0: - max_seconds = chore.timed_max_daily_minutes * 60 - total_seconds = min(total_seconds, max_seconds) + remaining = max( + 0, chore.timed_max_daily_minutes * 60 - self._timed_seconds_credited_today(chore_id, child_id) + ) + total_seconds = min(total_seconds, remaining) # Calculate points. Guard against a mis-configured zero rate, which # would otherwise raise ZeroDivisionError and wedge the session. @@ -143,6 +153,53 @@ class TimedMixin: await self.async_refresh() + def _timed_start_allowed(self, chore, child_id: str) -> bool: + """Assignment/enabled/schedule eligibility for starting a timed task. + + A lightweight, self-contained gate (no live-state lookups): the chore + must be enabled, not per-child disabled, assigned to this child if it + has an assignee list, and — for a specific_days schedule — due today. + """ + if not getattr(chore, "enabled", True): + return False + if child_id in (getattr(chore, "disabled_for", []) or []): + return False + assigned = getattr(chore, "assigned_to", []) or [] + if assigned and child_id not in assigned: + return False + if getattr(chore, "schedule_mode", "specific_days") == "specific_days": + due_days = getattr(chore, "due_days", []) or [] + if due_days: + today = dt_util.as_local(dt_util.now()).date() + dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()] + if dow not in due_days: + return False + return True + + def _timed_seconds_credited_today(self, chore_id: str, child_id: str) -> int: + """Seconds already credited today for this timed chore + child. + + Sums ``timed_duration_seconds`` across today's completions (approved or + pending). Stop removes the live session, so the daily cap is enforced + against these persisted completions instead of a session that vanishes. + """ + today = dt_util.as_local(dt_util.now()).date() + total = 0 + for c in self.storage.get_completions(): + if c.chore_id != chore_id or c.child_id != child_id: + continue + if getattr(c, "bonus_subtask_id", ""): + continue + secs = getattr(c, "timed_duration_seconds", 0) or 0 + if secs <= 0: + continue + try: + if dt_util.as_local(c.completed_at).date() == today: + total += int(secs) + except (AttributeError, TypeError, ValueError): + continue + return total + def _calc_session_seconds(self, session: TimedSession) -> int: """Calculate total elapsed seconds from session segments.""" total = 0 diff --git a/custom_components/taskmate/http_calendar.py b/custom_components/taskmate/http_calendar.py index 6f900af3..7bcbae0d 100644 --- a/custom_components/taskmate/http_calendar.py +++ b/custom_components/taskmate/http_calendar.py @@ -53,7 +53,14 @@ class TaskMateCalendarFeedView(HomeAssistantView): expected = coordinator.storage.get_setting("ics_token", "") supplied = request.query.get("token", "") - if not expected or not supplied or not hmac.compare_digest(str(expected), supplied): + # Compare as bytes: hmac.compare_digest raises on non-ASCII str inputs, + # which on this pre-auth endpoint would turn a crafted ?token= into an + # unhandled 500 + traceback rather than a clean 401. + if ( + not expected + or not supplied + or not hmac.compare_digest(str(expected).encode("utf-8"), supplied.encode("utf-8")) + ): return web.Response(status=HTTPStatus.UNAUTHORIZED) days = coordinator._calendar_projection_days() diff --git a/custom_components/taskmate/http_photos.py b/custom_components/taskmate/http_photos.py index 1d9f8d79..a19a8492 100644 --- a/custom_components/taskmate/http_photos.py +++ b/custom_components/taskmate/http_photos.py @@ -20,12 +20,51 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant from . import photos +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) HTTP_VIEWS_REGISTERED = "photo_http_registered" +def _get_coordinator(hass: HomeAssistant): + from .coordinator import TaskMateCoordinator + + for value in hass.data.get(DOMAIN, {}).values(): + if isinstance(value, TaskMateCoordinator): + return value + return None + + +async def _may_view_photo(hass: HomeAssistant, request: web.Request, filename: str) -> bool: + """Whether the requesting user may read this evidence photo. + + Evidence photos can contain images of children, so being merely logged in is + not enough: allow an admin, a configured TaskMate parent, or the child whose + own completion references the file (via their linked HA user). Signed + ```` requests resolve to the signing user, so per-user client-side + signing keeps this bound to the actual viewer. + """ + user = request.get("hass_user") + if user is None: + return False + if getattr(user, "is_admin", False): + return True + coordinator = _get_coordinator(hass) + if coordinator is None: + return False + if user.id in (coordinator.storage.get_parent_user_ids() or []): + return True + photo_url = f"{photos.URL_PREFIX}/{filename}" + for comp in coordinator.storage.get_completions(): + if getattr(comp, "photo_url", "") != photo_url: + continue + child = coordinator.get_child(comp.child_id) + if child and getattr(child, "linked_user_id", "") == user.id: + return True + return False + + class TaskMatePhotoUploadView(HomeAssistantView): """Receive a multipart image upload and store it under the config dir.""" @@ -45,9 +84,14 @@ class TaskMatePhotoUploadView(HomeAssistantView): except (ValueError, AssertionError): return self.json_message("Expected multipart form", HTTPStatus.BAD_REQUEST) - # Find the "file" part. + # Find the "file" part. Bound the scan so a stream of endlessly-named + # non-"file" parts can't hold a handler open indefinitely. field = await reader.next() + parts_scanned = 0 while field is not None and field.name != "file": + parts_scanned += 1 + if parts_scanned > 16: + return self.json_message("Too many form parts", HTTPStatus.BAD_REQUEST) field = await reader.next() if field is None: return self.json_message("No file provided", HTTPStatus.BAD_REQUEST) @@ -101,6 +145,11 @@ class TaskMatePhotoServeView(HomeAssistantView): if not photos.FILENAME_RE.match(filename): return web.Response(status=HTTPStatus.NOT_FOUND) + # Return 404 (not 403) when unauthorized so the endpoint doesn't confirm + # a file exists to a caller who may not read it. + if not await _may_view_photo(self.hass, request, filename): + return web.Response(status=HTTPStatus.NOT_FOUND) + path = photos.photos_path(self.hass) / filename def _read() -> bytes | None: @@ -116,7 +165,11 @@ class TaskMatePhotoServeView(HomeAssistantView): return web.Response( body=data, content_type=photos.content_type_for(filename), - headers={"Cache-Control": "private, max-age=31536000"}, + headers={ + "Cache-Control": "private, max-age=31536000", + "X-Content-Type-Options": "nosniff", + "Content-Disposition": "inline", + }, ) diff --git a/custom_components/taskmate/ics.py b/custom_components/taskmate/ics.py index 6521179e..fb8e81cf 100644 --- a/custom_components/taskmate/ics.py +++ b/custom_components/taskmate/ics.py @@ -14,8 +14,21 @@ PRODID = "-//TaskMate//Chores//EN" def _escape(text: str) -> str: - """Escape a value per RFC 5545 (backslash, comma, semicolon, newline).""" - return str(text).replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;") + """Escape a value per RFC 5545 (backslash, comma, semicolon, newline). + + Carriage returns are normalised to the escaped ``\\n`` too: content lines + terminate on CRLF, so a bare ``\\r`` left in a SUMMARY/DESCRIPTION could be + read by a lenient parser as a line break and let a value inject a property. + """ + return ( + str(text) + .replace("\\", "\\\\") + .replace("\r\n", "\\n") + .replace("\r", "\\n") + .replace("\n", "\\n") + .replace(",", "\\,") + .replace(";", "\\;") + ) def _fold(line: str) -> str: diff --git a/custom_components/taskmate/manifest.json b/custom_components/taskmate/manifest.json index 1ae72185..5825d2cb 100644 --- a/custom_components/taskmate/manifest.json +++ b/custom_components/taskmate/manifest.json @@ -17,5 +17,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/tempus2016/taskmate/issues", "requirements": [], - "version": "5.4.1" + "version": "5.4.3" } diff --git a/custom_components/taskmate/number.py b/custom_components/taskmate/number.py index 8370db5e..46b79a4d 100644 --- a/custom_components/taskmate/number.py +++ b/custom_components/taskmate/number.py @@ -10,10 +10,12 @@ from __future__ import annotations from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import Unauthorized from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import authz from .const import DOMAIN from .coordinator import TaskMateCoordinator from .entity import taskmate_device_info @@ -62,6 +64,12 @@ class TaskMateSettingNumber(CoordinatorEntity, NumberEntity): return float(self._default) async def async_set_native_value(self, value: float) -> None: + # These entities write panel settings that are admin-only over the + # WebSocket; enforce the same gate here so a non-admin call_service + # can't retune the point economy. + ctx = getattr(self, "_context", None) + if not await authz.async_context_is_admin(getattr(self, "hass", None), ctx): + raise Unauthorized(context=ctx) stored = int(value) if float(value).is_integer() else value self.coordinator.storage.set_setting(self._key, stored) await self.coordinator.storage.async_save() diff --git a/custom_components/taskmate/select.py b/custom_components/taskmate/select.py index ada03a84..0b6ee27f 100644 --- a/custom_components/taskmate/select.py +++ b/custom_components/taskmate/select.py @@ -5,10 +5,12 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import Unauthorized from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import authz from .const import DOMAIN from .coordinator import TaskMateCoordinator from .entity import taskmate_device_info @@ -57,6 +59,10 @@ class TaskMateSettingSelect(CoordinatorEntity, SelectEntity): return val if val in self._attr_options else self._default async def async_select_option(self, option: str) -> None: + # Panel setting write — admin-only, matching the WebSocket gate. + ctx = getattr(self, "_context", None) + if not await authz.async_context_is_admin(getattr(self, "hass", None), ctx): + raise Unauthorized(context=ctx) if option not in self._attr_options: return self.coordinator.storage.set_setting(self._key, option) diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index f87c877c..570623a3 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import images, photos +from . import images from .const import DOMAIN from .coordinator import TaskMateCoordinator from .entity import taskmate_device_info @@ -389,11 +389,14 @@ def _build_todays_completions(common: dict) -> list[dict]: } if timed_secs > 0: rec["timed_duration_seconds"] = timed_secs - # Sign the evidence photo so a card's (which carries no bearer - # token) can load it from the auth-gated serve view. + # Emit the bare (unsigned) photo path. A card's carries no bearer + # token, so the card signs each path per-viewer via auth/sign_path before + # rendering — this keeps a self-authenticating URL out of this + # world-readable attribute (a signed URL here would be redeemable by + # anyone who could read the state). photo = getattr(comp, "photo_url", "") or "" if photo: - rec["photo_url"] = photos.sign_photo_url(common["hass"], photo) + rec["photo_url"] = photo out.append(rec) return out @@ -1344,9 +1347,12 @@ class PendingApprovalsSensor(TaskMateBaseSensor): } if timed_secs > 0: detail["timed_duration_seconds"] = timed_secs + # Bare (unsigned) path; the card signs per-viewer via + # auth/sign_path so no self-authenticating URL lands in this + # world-readable attribute. photo = getattr(comp, "photo_url", "") or "" if photo: - detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo) + detail["photo_url"] = photo completion_details.append(detail) reward_details = [] diff --git a/custom_components/taskmate/storage.py b/custom_components/taskmate/storage.py index 911777ee..148e4c0d 100644 --- a/custom_components/taskmate/storage.py +++ b/custom_components/taskmate/storage.py @@ -1172,13 +1172,38 @@ class TaskMateStorage: """Re-validate untrusted inner records after a full-replace import (SEC-5). ``import_data`` deep-copies the payload in with only top-level coercion, - so a crafted backup could smuggle a ``photo_url`` that bypasses the - ``is_taskmate_photo_url`` gate enforced at the ``complete_chore`` - boundary. Strip any completion ``photo_url`` that isn't one of our own - well-formed photo URLs so the panel never renders a foreign/dangerous one. + so a crafted backup could smuggle values that never passed the WebSocket + schemas: a foreign ``photo_url``/``image_url`` (bypassing the gates at the + service/WS boundary), or an enum field the panel renders into markup. + Normalise every field that reaches a template — unknown enum values fall + back to their safe default and foreign URLs are dropped — so a restored + backup can never carry stored content into the admin panel. """ + from .const import ASSIGNMENT_MODES, BADGE_TIERS, SCHEDULE_MODES, TASK_GROUP_POLICIES + from .images import is_taskmate_image_url from .photos import is_taskmate_photo_url + _STREAK_MODES = ("reset", "pause") + _CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible") + _NUMERIC_SETTINGS = ( + "history_days", + "weekend_multiplier", + "difficulty_multiplier_easy", + "difficulty_multiplier_medium", + "difficulty_multiplier_hard", + "calendar_projection_days", + "surprise_bonus_chance", + "surprise_bonus_min", + "surprise_bonus_max", + "roulette_multiplier", + "roulette_daily_spins", + "points_decay_percent", + "level_xp_step", + "spend_cap_amount", + "interest_percent", + "perfect_week_bonus", + ) + for comp in self._data.get("completions", []): if not isinstance(comp, dict): continue @@ -1190,6 +1215,50 @@ class TaskMateStorage: ) comp["photo_url"] = "" + for chore in self._data.get("chores", []): + if not isinstance(chore, dict): + continue + if chore.get("assignment_mode") not in ASSIGNMENT_MODES: + chore["assignment_mode"] = "everyone" + if chore.get("schedule_mode") not in SCHEDULE_MODES: + chore["schedule_mode"] = "specific_days" + img = chore.get("image_url") + if img and not is_taskmate_image_url(img): + _LOGGER.warning( + "Import: dropped non-TaskMate image_url on chore %s", + chore.get("id", "?"), + ) + chore["image_url"] = "" + + for grp in self._data.get("task_groups", []): + if isinstance(grp, dict) and grp.get("policy") not in TASK_GROUP_POLICIES: + grp["policy"] = "sticky" + + for badge in self._data.get("badges", []): + if isinstance(badge, dict) and badge.get("tier") not in BADGE_TIERS: + badge["tier"] = "bronze" + + settings = self._data.get("settings") + if isinstance(settings, dict): + if settings.get("streak_reset_mode") not in _STREAK_MODES: + settings.pop("streak_reset_mode", None) + if settings.get("card_design") not in _CARD_DESIGNS: + settings.pop("card_design", None) + # Numeric settings are rendered into the panel's number inputs; the + # WebSocket update path coerces them, but import does not, so coerce + # here too. A value that isn't a number (e.g. a crafted string) is + # coerced if it parses, else dropped so its default applies — it can + # never reach the panel as raw markup. + for key in _NUMERIC_SETTINGS: + if key not in settings: + continue + val = settings[key] + if isinstance(val, bool) or not isinstance(val, (int, float)): + try: + settings[key] = float(val) + except (TypeError, ValueError): + settings.pop(key, None) + def replace_completions(self, completions: list[ChoreCompletion]) -> None: """Replace all completions with the given list.""" self._data["completions"] = [c.to_dict() for c in completions] diff --git a/custom_components/taskmate/todo.py b/custom_components/taskmate/todo.py index 3494dcf9..07e59bfc 100644 --- a/custom_components/taskmate/todo.py +++ b/custom_components/taskmate/todo.py @@ -16,10 +16,12 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import Unauthorized from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import authz from .const import DOMAIN from .coordinator import TaskMateCoordinator from .entity import taskmate_device_info @@ -71,5 +73,10 @@ class TaskMateChildTodoList(CoordinatorEntity, TodoListEntity): async def async_update_todo_item(self, item: TodoItem) -> None: """Checking an item off completes the chore for this child.""" + ctx = getattr(self, "_context", None) + if not await authz.async_context_allows_child( + getattr(self, "hass", None), self.coordinator, ctx, self._child_id + ): + raise Unauthorized(context=ctx) if item.status == TodoItemStatus.COMPLETED and item.uid: await self.coordinator.async_complete_chore(item.uid, self._child_id) diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 1aba07fe..5d3d6f89 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -16,7 +16,7 @@ "activity.claimed": "behauptet", "activity.completed": "vollendet", "activity.completed_chores_will_appear": "Abgeschlossene Aufgaben werden hier angezeigt", - "activity.days_ago": "{count}d ago", + "activity.days_ago": "vor {count} T.", "activity.default_title": "Aktivität", "activity.recent_events": "Letzte Ereignisse", "activity.undo": "Rückgängig", @@ -25,30 +25,30 @@ "activity.undo_confirm_txn": "„{detail}“ für {child} rückgängig machen? Dies kehrt {points} Punkte um.", "activity.undo_error_title": "TaskMate — Rückgängig fehlgeschlagen", "activity.undo_error_body": "Konnte nicht rückgängig gemacht werden: {message}", - "activity.editor.accent_stripes_helper": "Show the 4px coloured stripe at the left of each row indicating event type", - "activity.editor.accent_stripes": "Coloured event stripes", + "activity.editor.accent_stripes_helper": "Zeigt links an jeder Zeile den 4 px breiten Farbstreifen, der den Ereignistyp angibt", + "activity.editor.accent_stripes": "Farbige Ereignisstreifen", "activity.editor.filter_child_helper": "Nur Aktivität für dieses Kind anzeigen", "activity.editor.max_items": "Maximale Anzahl an Artikeln", "activity.editor.max_items_helper": "Maximale Anzahl anzuzeigender Ereignisse (Standard: 30)", - "activity.editor.show_relative_time_helper": "Show 'just now' / '5m ago' alongside the absolute time on each row", - "activity.editor.show_relative_time": "Relative time labels", - "activity.editor.show_filter_chips_helper": "Show the All / Chores / Rewards / Adjustments chip bar below the header", - "activity.editor.show_filter_chips": "Show filter chips", + "activity.editor.show_relative_time_helper": "Zeigt „gerade eben“ / „vor 5 Min.“ neben der genauen Uhrzeit in jeder Zeile", + "activity.editor.show_relative_time": "Relative Zeitangaben", + "activity.editor.show_filter_chips_helper": "Zeigt die Leiste Alle / Hausarbeiten / Belohnungen / Anpassungen unter der Kopfzeile", + "activity.editor.show_filter_chips": "Filterleiste anzeigen", "activity.editor.show_undo": "Rückgängig-Schaltflächen anzeigen", "activity.editor.show_undo_helper": "Zeigt die Rückgängig-Schaltfläche in jeder Zeile. Für ein kindgerechtes Dashboard deaktivieren, damit Kinder ihre eigene Aktivität nicht rückgängig machen können.", "activity.events_count": "{count} Ereignisse", - "activity.just_now": "just now", - "activity.hours_ago": "{count}h ago", - "activity.filter_rewards": "Rewards", - "activity.filter_chores": "Chores", - "activity.filter_aria_label": "Filter activity by type", - "activity.filter_all": "All", - "activity.filter_adjustments": "Adjustments", - "activity.feed_end": "— end of feed —", + "activity.just_now": "gerade eben", + "activity.hours_ago": "vor {count} Std.", + "activity.filter_rewards": "Belohnungen", + "activity.filter_chores": "Hausarbeiten", + "activity.filter_aria_label": "Aktivität nach Typ filtern", + "activity.filter_all": "Alle", + "activity.filter_adjustments": "Anpassungen", + "activity.feed_end": "— Ende des Verlaufs —", "activity.lost": "verloren", - "activity.minutes_ago": "{count}m ago", + "activity.minutes_ago": "vor {count} Min.", "activity.no_activity_yet": "Noch keine Aktivität", - "activity.no_events_for_filter": "No matching events", + "activity.no_events_for_filter": "Keine passenden Ereignisse", "activity.points_manually": "Punkte manuell eingeben", "activity.reason_allocated_to_pool": "Dem Pool zugewiesen: {name}", "activity.reason_admin_adjustment": "Manuelle Anpassung", @@ -64,7 +64,7 @@ "activity.received": "erhalten", "activity.redeemed": "eingelöst", "activity.spent": "ausgegeben", - "activity.try_different_filter": "Try a different filter", + "activity.try_different_filter": "Probieren Sie einen anderen Filter", "activity.unknown_child": "Unbekannt", "approvals.all_caught_up": "Alles aufgeholt!", "approvals.approve": "Genehmigen", @@ -559,7 +559,7 @@ "panel.btn_save_order": "Reihenfolge speichern", "panel.btn_save_settings": "Einstellungen speichern", "panel.btn_save_template": "Vorlage speichern", - "panel.btn_skip_chore": "Skip rotation (advance to next child)", + "panel.btn_skip_chore": "Rotation überspringen (zum nächsten Kind wechseln)", "panel.bulk_assigned_to": "Zugewiesen an", "panel.bulk_chore_names_hint": "Alle Aufgaben teilen sich die folgenden Einstellungen.", "panel.bulk_chore_names_label": "Aufgabennamen (einer pro Zeile oder kommagetrennt)", @@ -839,7 +839,7 @@ "panel.settings_title": "Einstellungen", "panel.settings_weekend_multiplier_hint": "Bonus an Sa/So (1,0 = aus)", "panel.settings_weekend_multiplier_label": "Wochenend-Multiplikator", - "panel.skip_chore_confirm": "Skip this chore's current assignee and advance the rotation?", + "panel.skip_chore_confirm": "Das aktuell zugewiesene Kind für diese Hausarbeit überspringen und die Rotation weiterschalten?", "panel.streak_pause": "Pause — Serie wird bis zur nächsten Erledigung beibehalten", "panel.streak_reset": "Zurücksetzen — Serie geht bei ausgelassenem Tag auf 0", "panel.tab_activity": "Aktivität", @@ -984,8 +984,8 @@ "panel.toast_reward_updated": "Belohnung aktualisiert", "panel.toast_save_failed": "Speichern fehlgeschlagen: {error}", "panel.toast_settings_saved": "Gespeichert ({count} Feld(er))", - "panel.toast_skip_done": "Rotation skipped — next child assigned", - "panel.toast_skip_failed": "Skip failed: {error}", + "panel.toast_skip_done": "Rotation übersprungen — nächstes Kind zugewiesen", + "panel.toast_skip_failed": "Überspringen fehlgeschlagen: {error}", "panel.toast_template_created": "Vorlage \"{name}\" erstellt", "panel.toast_template_deleted": "Vorlage gelöscht", "panel.toast_template_failed_apply": "Vorlage konnte nicht angewendet werden", @@ -1094,7 +1094,7 @@ "points_display.editor.mode_cumulative": "∑ Kombiniert", "points_display.editor.mode_label": "Modus", "points_display.editor.mode_multi": "👥 Alle Kinder", - "points_display.editor.mode_single": "⭐ Single", + "points_display.editor.mode_single": "⭐ Einzeln", "points_display.editor.primary_career_score": "🏆 Karrierepunkte", "points_display.editor.primary_current_points": "💰 Aktuelle Punkte", "points_display.editor.primary_display": "Primäranzeige", @@ -1267,10 +1267,10 @@ "weekly.day_fri": "Fr", "weekly.day_mon": "Mo", "weekly.day_sat": "Sa", - "weekly.day_sun": "Sonne", + "weekly.day_sun": "So", "weekly.day_thu": "Do", "weekly.day_tue": "Di", - "weekly.day_wed": "Heiraten", + "weekly.day_wed": "Mi", "weekly.default_title": "Diese Woche", "weekly.editor.child_helper": "Wöchentliche Zusammenfassung nur für ein bestimmtes Kind anzeigen", "weekly.editor.title": "Titel", @@ -1504,8 +1504,8 @@ "child.avatar_change": "Avatar ändern", "child.avatar_locked": "Gesperrt", "child.avatar_change_failed": "Avatar konnte nicht geändert werden.", - "panel.tab_challenges": "Challenges", - "panel.challenge_title": "Challenges", + "panel.tab_challenges": "Herausforderungen", + "panel.challenge_title": "Herausforderungen", "panel.search_challenges": "Challenges suchen …", "panel.btn_add_challenge": "Challenge hinzufügen", "panel.challenge_add_tile": "Challenge hinzufügen", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 8100e851..e7e5c457 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -16,7 +16,7 @@ "activity.claimed": "a réclamé", "activity.completed": "a terminé", "activity.completed_chores_will_appear": "Les tâches terminées apparaîtront ici", - "activity.days_ago": "{count}d ago", + "activity.days_ago": "il y a {count}j", "activity.default_title": "Activité", "activity.recent_events": "Événements récents", "activity.undo": "Annuler", @@ -25,30 +25,30 @@ "activity.undo_confirm_txn": "Annuler « {detail} » pour {child} ? Cela inverse {points} points.", "activity.undo_error_title": "TaskMate — échec de l'annulation", "activity.undo_error_body": "Impossible d'annuler : {message}", - "activity.editor.accent_stripes_helper": "Show the 4px coloured stripe at the left of each row indicating event type", - "activity.editor.accent_stripes": "Coloured event stripes", + "activity.editor.accent_stripes_helper": "Afficher la bande de couleur de 4 px à gauche de chaque ligne, qui indique le type d'événement", + "activity.editor.accent_stripes": "Bandes de couleur par événement", "activity.editor.filter_child_helper": "Afficher uniquement l'activité de cet enfant", "activity.editor.max_items": "Nombre max d'éléments", "activity.editor.max_items_helper": "Nombre maximum d'événements à afficher (par défaut : 30)", - "activity.editor.show_relative_time_helper": "Show 'just now' / '5m ago' alongside the absolute time on each row", - "activity.editor.show_relative_time": "Relative time labels", - "activity.editor.show_filter_chips_helper": "Show the All / Chores / Rewards / Adjustments chip bar below the header", - "activity.editor.show_filter_chips": "Show filter chips", + "activity.editor.show_relative_time_helper": "Afficher « à l'instant » / « il y a 5 min » à côté de l'heure exacte sur chaque ligne", + "activity.editor.show_relative_time": "Horodatage relatif", + "activity.editor.show_filter_chips_helper": "Afficher la barre Tout / Tâches / Récompenses / Ajustements sous l'en-tête", + "activity.editor.show_filter_chips": "Afficher les puces de filtre", "activity.editor.show_undo": "Afficher les boutons d'annulation", "activity.editor.show_undo_helper": "Affiche le bouton d'annulation sur chaque ligne. Désactivez-le pour un tableau de bord adapté aux enfants, afin qu'ils ne puissent pas annuler leur propre activité.", "activity.events_count": "{count} événements", - "activity.just_now": "just now", - "activity.hours_ago": "{count}h ago", - "activity.filter_rewards": "Rewards", - "activity.filter_chores": "Chores", - "activity.filter_aria_label": "Filter activity by type", - "activity.filter_all": "All", - "activity.filter_adjustments": "Adjustments", - "activity.feed_end": "— end of feed —", + "activity.just_now": "à l'instant", + "activity.hours_ago": "il y a {count} h", + "activity.filter_rewards": "Récompenses", + "activity.filter_chores": "Tâches", + "activity.filter_aria_label": "Filtrer l'activité par type", + "activity.filter_all": "Tout", + "activity.filter_adjustments": "Ajustements", + "activity.feed_end": "— fin du flux —", "activity.lost": "a perdu", - "activity.minutes_ago": "{count}m ago", + "activity.minutes_ago": "il y a {count} min", "activity.no_activity_yet": "Aucune activité pour le moment", - "activity.no_events_for_filter": "No matching events", + "activity.no_events_for_filter": "Aucun événement correspondant", "activity.points_manually": "points manuellement", "activity.reason_allocated_to_pool": "Alloué à la cagnotte : {name}", "activity.reason_admin_adjustment": "Ajustement manuel", @@ -64,7 +64,7 @@ "activity.received": "a reçu", "activity.redeemed": "a échangé", "activity.spent": "a dépensé", - "activity.try_different_filter": "Try a different filter", + "activity.try_different_filter": "Essayez un autre filtre", "activity.unknown_child": "Inconnu", "approvals.all_caught_up": "Tout est à jour !", "approvals.approve": "Approuver", @@ -559,7 +559,7 @@ "panel.btn_save_order": "Enregistrer l'ordre", "panel.btn_save_settings": "Enregistrer les paramètres", "panel.btn_save_template": "Enregistrer le modèle", - "panel.btn_skip_chore": "Skip rotation (advance to next child)", + "panel.btn_skip_chore": "Passer la rotation (enfant suivant)", "panel.bulk_assigned_to": "Assigné à", "panel.bulk_chore_names_hint": "Toutes les tâches partageront les paramètres ci-dessous.", "panel.bulk_chore_names_label": "Noms des tâches (un par ligne ou séparés par des virgules)", @@ -839,7 +839,7 @@ "panel.settings_title": "Paramètres", "panel.settings_weekend_multiplier_hint": "Bonus le Sam/Dim (1.0 = désactivé)", "panel.settings_weekend_multiplier_label": "Multiplicateur weekend", - "panel.skip_chore_confirm": "Skip this chore's current assignee and advance the rotation?", + "panel.skip_chore_confirm": "Passer l'enfant actuellement assigné à cette tâche et faire avancer la rotation ?", "panel.streak_pause": "Pause — série préservée jusqu'à la prochaine complétion", "panel.streak_reset": "Réinitialisation — la série retombe à 0 si un jour est manqué", "panel.tab_activity": "Activité", @@ -984,8 +984,8 @@ "panel.toast_reward_updated": "Récompense mise à jour", "panel.toast_save_failed": "Échec de l'enregistrement : {error}", "panel.toast_settings_saved": "Enregistré ({count} champ(s))", - "panel.toast_skip_done": "Rotation skipped — next child assigned", - "panel.toast_skip_failed": "Skip failed: {error}", + "panel.toast_skip_done": "Rotation passée — enfant suivant assigné", + "panel.toast_skip_failed": "Échec du passage : {error}", "panel.toast_template_created": "Modèle « {name} » créé", "panel.toast_template_deleted": "Modèle supprimé", "panel.toast_template_failed_apply": "Échec de l'application du modèle", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 6c101ba3..a316b4b2 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -16,7 +16,7 @@ "activity.claimed": "krevde", "activity.completed": "fullførte", "activity.completed_chores_will_appear": "Fullførte oppgaver vises her", - "activity.days_ago": "{count}d ago", + "activity.days_ago": "{count}d siden", "activity.default_title": "Aktivitet", "activity.recent_events": "Nylige hendelser", "activity.undo": "Angre", @@ -25,30 +25,30 @@ "activity.undo_confirm_txn": "Angre «{detail}» for {child}? Dette reverserer {points} poeng.", "activity.undo_error_title": "TaskMate — angring mislyktes", "activity.undo_error_body": "Kunne ikke angre: {message}", - "activity.editor.accent_stripes_helper": "Show the 4px coloured stripe at the left of each row indicating event type", - "activity.editor.accent_stripes": "Coloured event stripes", + "activity.editor.accent_stripes_helper": "Vis den 4 px brede fargestripen til venstre i hver rad som viser hendelsestypen", + "activity.editor.accent_stripes": "Fargede hendelsesstriper", "activity.editor.filter_child_helper": "Vis kun aktivitet for dette barnet", "activity.editor.max_items": "Maks antall", "activity.editor.max_items_helper": "Maksimalt antall hendelser å vise (standard: 30)", - "activity.editor.show_relative_time_helper": "Show 'just now' / '5m ago' alongside the absolute time on each row", - "activity.editor.show_relative_time": "Relative time labels", - "activity.editor.show_filter_chips_helper": "Show the All / Chores / Rewards / Adjustments chip bar below the header", - "activity.editor.show_filter_chips": "Show filter chips", + "activity.editor.show_relative_time_helper": "Vis «nettopp» / «5m siden» ved siden av det nøyaktige klokkeslettet i hver rad", + "activity.editor.show_relative_time": "Relative tidsetiketter", + "activity.editor.show_filter_chips_helper": "Vis raden Alle / Oppgaver / Belønninger / Justeringer under overskriften", + "activity.editor.show_filter_chips": "Vis filterknapper", "activity.editor.show_undo": "Vis angre-knapper", "activity.editor.show_undo_helper": "Viser angre-knappen på hver rad. Slå av for et barnevennlig dashbord slik at barn ikke kan angre sin egen aktivitet.", "activity.events_count": "{count} hendelser", - "activity.just_now": "just now", - "activity.hours_ago": "{count}h ago", - "activity.filter_rewards": "Rewards", - "activity.filter_chores": "Chores", - "activity.filter_aria_label": "Filter activity by type", - "activity.filter_all": "All", - "activity.filter_adjustments": "Adjustments", - "activity.feed_end": "— end of feed —", + "activity.just_now": "nettopp", + "activity.hours_ago": "{count}t siden", + "activity.filter_rewards": "Belønninger", + "activity.filter_chores": "Oppgaver", + "activity.filter_aria_label": "Filtrer aktivitet etter type", + "activity.filter_all": "Alle", + "activity.filter_adjustments": "Justeringer", + "activity.feed_end": "— slutten av feeden —", "activity.lost": "mistet", - "activity.minutes_ago": "{count}m ago", + "activity.minutes_ago": "{count}m siden", "activity.no_activity_yet": "Ingen aktivitet ennå", - "activity.no_events_for_filter": "No matching events", + "activity.no_events_for_filter": "Ingen samsvarende hendelser", "activity.points_manually": "poeng manuelt", "activity.reason_allocated_to_pool": "Tildelt til sparegris: {name}", "activity.reason_admin_adjustment": "Manuell justering", @@ -64,7 +64,7 @@ "activity.received": "mottatt", "activity.redeemed": "innløste", "activity.spent": "brukte", - "activity.try_different_filter": "Try a different filter", + "activity.try_different_filter": "Prøv et annet filter", "activity.unknown_child": "Ukjent", "approvals.all_caught_up": "Alt er oppdatert!", "approvals.approve": "Godkjenn", @@ -559,7 +559,7 @@ "panel.btn_save_order": "Lagre rekkefølge", "panel.btn_save_settings": "Lagre innstillinger", "panel.btn_save_template": "Lagre mal", - "panel.btn_skip_chore": "Skip rotation (advance to next child)", + "panel.btn_skip_chore": "Hopp over rotasjonen (gå til neste barn)", "panel.bulk_assigned_to": "Tildelt til", "panel.bulk_chore_names_hint": "Alle oppgaver deler innstillingene nedenfor.", "panel.bulk_chore_names_label": "Oppgavenavn (ett per linje, eller kommaseparert)", @@ -839,7 +839,7 @@ "panel.settings_title": "Innstillinger", "panel.settings_weekend_multiplier_hint": "Bonus lør/søn (1.0 = av)", "panel.settings_weekend_multiplier_label": "Helgemultiplikator", - "panel.skip_chore_confirm": "Skip this chore's current assignee and advance the rotation?", + "panel.skip_chore_confirm": "Hoppe over barnet som er tildelt denne oppgaven, og gå videre i rotasjonen?", "panel.streak_pause": "Pause — serien bevares til neste fullføring", "panel.streak_reset": "Tilbakestill — serien går til 0 ved uteglemt dag", "panel.tab_activity": "Aktivitet", @@ -984,8 +984,8 @@ "panel.toast_reward_updated": "Belønning oppdatert", "panel.toast_save_failed": "Lagring mislyktes: {error}", "panel.toast_settings_saved": "Lagret ({count} felt)", - "panel.toast_skip_done": "Rotation skipped — next child assigned", - "panel.toast_skip_failed": "Skip failed: {error}", + "panel.toast_skip_done": "Rotasjonen ble hoppet over — neste barn er tildelt", + "panel.toast_skip_failed": "Kunne ikke hoppe over: {error}", "panel.toast_template_created": "Malen «{name}» opprettet", "panel.toast_template_deleted": "Mal slettet", "panel.toast_template_failed_apply": "Kunne ikke bruke mal", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 4b429546..771cffcb 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -16,7 +16,7 @@ "activity.claimed": "kravde", "activity.completed": "fullførte", "activity.completed_chores_will_appear": "Fullførte oppgåver kjem her", - "activity.days_ago": "{count}d ago", + "activity.days_ago": "{count}d sidan", "activity.default_title": "Aktivitet", "activity.recent_events": "Nylege hendingar", "activity.undo": "Angre", @@ -25,30 +25,30 @@ "activity.undo_confirm_txn": "Angre «{detail}» for {child}? Dette reverserer {points} poeng.", "activity.undo_error_title": "TaskMate — angring mislukkast", "activity.undo_error_body": "Kunne ikkje angre: {message}", - "activity.editor.accent_stripes_helper": "Show the 4px coloured stripe at the left of each row indicating event type", - "activity.editor.accent_stripes": "Coloured event stripes", + "activity.editor.accent_stripes_helper": "Vis den 4 px breie fargestripa til venstre i kvar rad som viser hendingstypen", + "activity.editor.accent_stripes": "Farga hendingsstriper", "activity.editor.filter_child_helper": "Vis berre aktivitet for dette bornet", "activity.editor.max_items": "Maks tal", "activity.editor.max_items_helper": "Maks tal hendingar å vise (standard: 30)", - "activity.editor.show_relative_time_helper": "Show 'just now' / '5m ago' alongside the absolute time on each row", - "activity.editor.show_relative_time": "Relative time labels", - "activity.editor.show_filter_chips_helper": "Show the All / Chores / Rewards / Adjustments chip bar below the header", - "activity.editor.show_filter_chips": "Show filter chips", + "activity.editor.show_relative_time_helper": "Vis «nettopp» / «5m sidan» ved sida av det nøyaktige klokkeslettet i kvar rad", + "activity.editor.show_relative_time": "Relative tidsetikettar", + "activity.editor.show_filter_chips_helper": "Vis rada Alle / Oppgåver / Premiar / Justeringar under overskrifta", + "activity.editor.show_filter_chips": "Vis filterknappar", "activity.editor.show_undo": "Vis angre-knappar", "activity.editor.show_undo_helper": "Viser angre-knappen på kvar rad. Slå av for eit barnevenleg dashbord slik at barn ikkje kan angre sin eigen aktivitet.", "activity.events_count": "{count} hendingar", - "activity.just_now": "just now", - "activity.hours_ago": "{count}h ago", - "activity.filter_rewards": "Rewards", - "activity.filter_chores": "Chores", - "activity.filter_aria_label": "Filter activity by type", - "activity.filter_all": "All", - "activity.filter_adjustments": "Adjustments", - "activity.feed_end": "— end of feed —", + "activity.just_now": "nettopp", + "activity.hours_ago": "{count}t sidan", + "activity.filter_rewards": "Premiar", + "activity.filter_chores": "Oppgåver", + "activity.filter_aria_label": "Filtrer aktivitet etter type", + "activity.filter_all": "Alle", + "activity.filter_adjustments": "Justeringar", + "activity.feed_end": "— slutten av feeden —", "activity.lost": "mista", - "activity.minutes_ago": "{count}m ago", + "activity.minutes_ago": "{count}m sidan", "activity.no_activity_yet": "Ingen aktivitet enno", - "activity.no_events_for_filter": "No matching events", + "activity.no_events_for_filter": "Ingen samsvarande hendingar", "activity.points_manually": "poeng manuelt", "activity.reason_allocated_to_pool": "Tildelt til sparegris: {name}", "activity.reason_admin_adjustment": "Manuell justering", @@ -64,7 +64,7 @@ "activity.received": "motteke", "activity.redeemed": "løyste inn", "activity.spent": "brukte", - "activity.try_different_filter": "Try a different filter", + "activity.try_different_filter": "Prøv eit anna filter", "activity.unknown_child": "Ukjend", "approvals.all_caught_up": "Alt er oppdatert!", "approvals.approve": "Godkjenn", @@ -559,7 +559,7 @@ "panel.btn_save_order": "Lagre rekkjefølgje", "panel.btn_save_settings": "Lagre innstillingar", "panel.btn_save_template": "Lagre mal", - "panel.btn_skip_chore": "Skip rotation (advance to next child)", + "panel.btn_skip_chore": "Hopp over rotasjonen (gå til neste barn)", "panel.bulk_assigned_to": "Tildelt til", "panel.bulk_chore_names_hint": "Alle oppgåver vil dele innstillingane nedanfor.", "panel.bulk_chore_names_label": "Oppgåvenamn (eitt per linje, eller kommaskilde)", @@ -839,7 +839,7 @@ "panel.settings_title": "Innstillingar", "panel.settings_weekend_multiplier_hint": "Bonus på lau/sun (1.0 = av)", "panel.settings_weekend_multiplier_label": "Helgemultiplikator", - "panel.skip_chore_confirm": "Skip this chore's current assignee and advance the rotation?", + "panel.skip_chore_confirm": "Hoppe over barnet som er tildelt denne oppgåva, og gå vidare i rotasjonen?", "panel.streak_pause": "Pause — rekkja vert bevart til neste fullføring", "panel.streak_reset": "Nullstill — rekkja går til 0 ved missa dag", "panel.tab_activity": "Aktivitet", @@ -861,7 +861,7 @@ "panel.notif_custom_message_placeholder": "Melding (du kan bruke {child_name})", "panel.notif_custom_name_placeholder": "Namn på påminning", "panel.notif_day_fri": "Fre", - "panel.notif_day_mon": "Man", + "panel.notif_day_mon": "Mån", "panel.notif_day_sat": "Lau", "panel.notif_day_sun": "Sun", "panel.notif_day_thu": "Tor", @@ -984,8 +984,8 @@ "panel.toast_reward_updated": "Premie oppdatert", "panel.toast_save_failed": "Lagring feila: {error}", "panel.toast_settings_saved": "Lagra ({count} felt)", - "panel.toast_skip_done": "Rotation skipped — next child assigned", - "panel.toast_skip_failed": "Skip failed: {error}", + "panel.toast_skip_done": "Rotasjonen vart hoppa over — neste barn er tildelt", + "panel.toast_skip_failed": "Kunne ikkje hoppe over: {error}", "panel.toast_template_created": "Malen «{name}» oppretta", "panel.toast_template_deleted": "Mal sletta", "panel.toast_template_failed_apply": "Klarte ikkje å bruke mal", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 607c24fb..71e89d5f 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -16,7 +16,7 @@ "activity.claimed": "reclamou", "activity.completed": "concluiu", "activity.completed_chores_will_appear": "As tarefas concluídas aparecerão aqui", - "activity.days_ago": "{count}d ago", + "activity.days_ago": "{count}d atrás", "activity.default_title": "Atividade", "activity.recent_events": "Eventos recentes", "activity.undo": "Desfazer", @@ -25,30 +25,30 @@ "activity.undo_confirm_txn": "Desfazer “{detail}” para {child}? Isso reverte {points} pontos.", "activity.undo_error_title": "TaskMate — falha ao desfazer", "activity.undo_error_body": "Não foi possível desfazer: {message}", - "activity.editor.accent_stripes_helper": "Show the 4px coloured stripe at the left of each row indicating event type", - "activity.editor.accent_stripes": "Coloured event stripes", + "activity.editor.accent_stripes_helper": "Mostra a barra colorida de 4 px à esquerda de cada linha, que indica o tipo de evento", + "activity.editor.accent_stripes": "Barras coloridas de evento", "activity.editor.filter_child_helper": "Mostrar apenas atividade desta criança", "activity.editor.max_items": "Máximo de Itens", "activity.editor.max_items_helper": "Número máximo de eventos a mostrar (padrão: 30)", - "activity.editor.show_relative_time_helper": "Show 'just now' / '5m ago' alongside the absolute time on each row", - "activity.editor.show_relative_time": "Relative time labels", - "activity.editor.show_filter_chips_helper": "Show the All / Chores / Rewards / Adjustments chip bar below the header", - "activity.editor.show_filter_chips": "Show filter chips", + "activity.editor.show_relative_time_helper": "Mostra \"agora mesmo\" / \"5min atrás\" ao lado da hora exata em cada linha", + "activity.editor.show_relative_time": "Etiquetas de tempo relativo", + "activity.editor.show_filter_chips_helper": "Mostra a barra Todos / Tarefas / Recompensas / Ajustes abaixo do cabeçalho", + "activity.editor.show_filter_chips": "Mostrar filtros", "activity.editor.show_undo": "Mostrar botões de desfazer", "activity.editor.show_undo_helper": "Mostra o botão de desfazer em cada linha. Desative para um painel adequado para crianças, para que elas não possam desfazer a própria atividade.", "activity.events_count": "{count} eventos", - "activity.just_now": "just now", - "activity.hours_ago": "{count}h ago", - "activity.filter_rewards": "Rewards", - "activity.filter_chores": "Chores", - "activity.filter_aria_label": "Filter activity by type", - "activity.filter_all": "All", - "activity.filter_adjustments": "Adjustments", - "activity.feed_end": "— end of feed —", + "activity.just_now": "agora mesmo", + "activity.hours_ago": "{count}h atrás", + "activity.filter_rewards": "Recompensas", + "activity.filter_chores": "Tarefas", + "activity.filter_aria_label": "Filtrar atividade por tipo", + "activity.filter_all": "Todos", + "activity.filter_adjustments": "Ajustes", + "activity.feed_end": "— fim do histórico —", "activity.lost": "perdeu", - "activity.minutes_ago": "{count}m ago", + "activity.minutes_ago": "{count}min atrás", "activity.no_activity_yet": "Ainda sem atividade", - "activity.no_events_for_filter": "No matching events", + "activity.no_events_for_filter": "Nenhum evento correspondente", "activity.points_manually": "pontos manualmente", "activity.reason_allocated_to_pool": "Alocado ao cofrinho: {name}", "activity.reason_admin_adjustment": "Ajuste manual", @@ -64,7 +64,7 @@ "activity.received": "recebeu", "activity.redeemed": "resgatou", "activity.spent": "gastou", - "activity.try_different_filter": "Try a different filter", + "activity.try_different_filter": "Tente outro filtro", "activity.unknown_child": "Desconhecido", "approvals.all_caught_up": "Está tudo em dia!", "approvals.approve": "Aprovar", @@ -559,7 +559,7 @@ "panel.btn_save_order": "Salvar ordem", "panel.btn_save_settings": "Salvar configurações", "panel.btn_save_template": "Salvar modelo", - "panel.btn_skip_chore": "Skip rotation (advance to next child)", + "panel.btn_skip_chore": "Pular a rotação (avançar para a próxima criança)", "panel.bulk_assigned_to": "Atribuído a", "panel.bulk_chore_names_hint": "Todas as tarefas compartilharão as configurações abaixo.", "panel.bulk_chore_names_label": "Nomes das tarefas (um por linha ou separados por vírgula)", @@ -839,7 +839,7 @@ "panel.settings_title": "Configurações", "panel.settings_weekend_multiplier_hint": "Bônus no sáb/dom (1.0 = desligado)", "panel.settings_weekend_multiplier_label": "Multiplicador de fim de semana", - "panel.skip_chore_confirm": "Skip this chore's current assignee and advance the rotation?", + "panel.skip_chore_confirm": "Pular a criança atualmente atribuída a esta tarefa e avançar a rotação?", "panel.streak_pause": "Pausar — sequência preservada até a próxima conclusão", "panel.streak_reset": "Reiniciar — sequência vai a 0 no dia perdido", "panel.tab_activity": "Atividade", @@ -984,8 +984,8 @@ "panel.toast_reward_updated": "Recompensa atualizada", "panel.toast_save_failed": "Falha ao salvar: {error}", "panel.toast_settings_saved": "Salvo ({count} campo(s))", - "panel.toast_skip_done": "Rotation skipped — next child assigned", - "panel.toast_skip_failed": "Skip failed: {error}", + "panel.toast_skip_done": "Rotação pulada — próxima criança atribuída", + "panel.toast_skip_failed": "Falha ao pular: {error}", "panel.toast_template_created": "Modelo \"{name}\" criado", "panel.toast_template_deleted": "Modelo excluído", "panel.toast_template_failed_apply": "Falha ao aplicar modelo", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index 7cf33c86..f03e8195 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -16,7 +16,7 @@ "activity.claimed": "reclamou", "activity.completed": "concluiu", "activity.completed_chores_will_appear": "As tarefas concluídas aparecerão aqui", - "activity.days_ago": "{count}d ago", + "activity.days_ago": "há {count}d", "activity.default_title": "Atividade", "activity.recent_events": "Eventos recentes", "activity.undo": "Anular", @@ -25,30 +25,30 @@ "activity.undo_confirm_txn": "Anular “{detail}” para {child}? Isto reverte {points} pontos.", "activity.undo_error_title": "TaskMate — falha ao anular", "activity.undo_error_body": "Não foi possível anular: {message}", - "activity.editor.accent_stripes_helper": "Show the 4px coloured stripe at the left of each row indicating event type", - "activity.editor.accent_stripes": "Coloured event stripes", + "activity.editor.accent_stripes_helper": "Mostra a barra colorida de 4 px à esquerda de cada linha, que indica o tipo de evento", + "activity.editor.accent_stripes": "Barras coloridas de evento", "activity.editor.filter_child_helper": "Mostrar apenas atividade desta criança", "activity.editor.max_items": "Máximo de Itens", "activity.editor.max_items_helper": "Número máximo de eventos a mostrar (padrão: 30)", - "activity.editor.show_relative_time_helper": "Show 'just now' / '5m ago' alongside the absolute time on each row", - "activity.editor.show_relative_time": "Relative time labels", - "activity.editor.show_filter_chips_helper": "Show the All / Chores / Rewards / Adjustments chip bar below the header", - "activity.editor.show_filter_chips": "Show filter chips", + "activity.editor.show_relative_time_helper": "Mostra «agora mesmo» / «há 5min» ao lado da hora exata em cada linha", + "activity.editor.show_relative_time": "Etiquetas de tempo relativo", + "activity.editor.show_filter_chips_helper": "Mostra a barra Todos / Tarefas / Recompensas / Ajustes por baixo do cabeçalho", + "activity.editor.show_filter_chips": "Mostrar filtros", "activity.editor.show_undo": "Mostrar botões de desfazer", "activity.editor.show_undo_helper": "Mostra o botão de desfazer em cada linha. Desative para um painel adequado a crianças, para que não possam desfazer a própria atividade.", "activity.events_count": "{count} eventos", - "activity.just_now": "just now", - "activity.hours_ago": "{count}h ago", - "activity.filter_rewards": "Rewards", - "activity.filter_chores": "Chores", - "activity.filter_aria_label": "Filter activity by type", - "activity.filter_all": "All", - "activity.filter_adjustments": "Adjustments", - "activity.feed_end": "— end of feed —", + "activity.just_now": "agora mesmo", + "activity.hours_ago": "há {count}h", + "activity.filter_rewards": "Recompensas", + "activity.filter_chores": "Tarefas", + "activity.filter_aria_label": "Filtrar atividade por tipo", + "activity.filter_all": "Todos", + "activity.filter_adjustments": "Ajustes", + "activity.feed_end": "— fim do histórico —", "activity.lost": "perdeu", - "activity.minutes_ago": "{count}m ago", + "activity.minutes_ago": "há {count}min", "activity.no_activity_yet": "Ainda sem atividade", - "activity.no_events_for_filter": "No matching events", + "activity.no_events_for_filter": "Nenhum evento correspondente", "activity.points_manually": "pontos manualmente", "activity.reason_allocated_to_pool": "Alocado ao mealheiro: {name}", "activity.reason_admin_adjustment": "Ajuste manual", @@ -64,7 +64,7 @@ "activity.received": "recebeu", "activity.redeemed": "resgatou", "activity.spent": "gastou", - "activity.try_different_filter": "Try a different filter", + "activity.try_different_filter": "Experimenta outro filtro", "activity.unknown_child": "Desconhecido", "approvals.all_caught_up": "Está tudo em dia!", "approvals.approve": "Aprovar", @@ -564,7 +564,7 @@ "panel.btn_save_order": "Guardar ordem", "panel.btn_save_settings": "Guardar definições", "panel.btn_save_template": "Guardar modelo", - "panel.btn_skip_chore": "Skip rotation (advance to next child)", + "panel.btn_skip_chore": "Saltar a rotação (avançar para a criança seguinte)", "panel.bulk_assigned_to": "Atribuída a", "panel.bulk_chore_names_hint": "Todas as tarefas partilharão as definições abaixo.", "panel.bulk_chore_names_label": "Nomes das tarefas (um por linha ou separados por vírgula)", @@ -844,7 +844,7 @@ "panel.settings_title": "Definições", "panel.settings_weekend_multiplier_hint": "Bónus ao Sáb/Dom (1.0 = desligado)", "panel.settings_weekend_multiplier_label": "Multiplicador de fim de semana", - "panel.skip_chore_confirm": "Skip this chore's current assignee and advance the rotation?", + "panel.skip_chore_confirm": "Saltar a criança atualmente atribuída a esta tarefa e avançar a rotação?", "panel.streak_pause": "Pausa — sequência preservada até à próxima conclusão", "panel.streak_reset": "Reiniciar — sequência volta a 0 no dia falhado", "panel.tab_activity": "Atividade", @@ -989,8 +989,8 @@ "panel.toast_reward_updated": "Recompensa atualizada", "panel.toast_save_failed": "Falha ao guardar: {error}", "panel.toast_settings_saved": "Guardado ({count} campo(s))", - "panel.toast_skip_done": "Rotation skipped — next child assigned", - "panel.toast_skip_failed": "Skip failed: {error}", + "panel.toast_skip_done": "Rotação saltada — criança seguinte atribuída", + "panel.toast_skip_failed": "Falha ao saltar: {error}", "panel.toast_template_created": "Modelo \"{name}\" criado", "panel.toast_template_deleted": "Modelo eliminado", "panel.toast_template_failed_apply": "Falha ao aplicar modelo", diff --git a/custom_components/taskmate/www/taskmate-approvals-card.js b/custom_components/taskmate/www/taskmate-approvals-card.js index c47408aa..db1e561b 100644 --- a/custom_components/taskmate/www/taskmate-approvals-card.js +++ b/custom_components/taskmate/www/taskmate-approvals-card.js @@ -17,6 +17,13 @@ const css = LitElement.prototype.css; const tmSafePhotoUrl = (u) => typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : ""; +// A pending-claims attribute is only ever usable as a list. The resolver used +// to hand back the pending-approvals sensor's scalar COUNT under this name, +// which turned .filter()/.some() into a TypeError and blanked the whole card +// (#834). The name collision is fixed in the resolver; this keeps a stray +// value from taking the card down again. +const tmClaimList = (v) => (Array.isArray(v) ? v : []); + const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); class TaskMateApprovalsCard extends LitElement { @@ -25,6 +32,7 @@ class TaskMateApprovalsCard extends LitElement { hass: { type: Object }, config: { type: Object }, _loading: { type: Object }, + _signed: { state: true }, }; } @@ -40,6 +48,8 @@ class TaskMateApprovalsCard extends LitElement { constructor() { super(); this._loading = {}; + this._signed = {}; // safe photo path -> per-viewer signed path + this._inflight = new Set(); } _t(key, params) { @@ -47,6 +57,56 @@ class TaskMateApprovalsCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + // Evidence photos are served from an auth-gated view; an carries no + // bearer token, so each path is signed per-viewer via auth/sign_path. The + // sensor now publishes bare paths (no self-authenticating URL in shared + // state), so this binds photo access to the actual viewer. + _photoHref(raw) { + const safe = tmSafePhotoUrl(raw); + return (safe && this._signed[safe]) || ""; + } + + _collectPhotoUrls() { + const out = []; + const st = this.hass && this.hass.states; + if (!st) return out; + for (const eid in st) { + if (eid.indexOf("sensor.taskmate") !== 0) continue; + const attrs = (st[eid] && st[eid].attributes) || {}; + for (const k in attrs) { + const v = attrs[k]; + if (!Array.isArray(v)) continue; + for (const item of v) { + if (item && typeof item === "object" && typeof item.photo_url === "string" && item.photo_url) { + out.push(item.photo_url); + } + } + } + } + return out; + } + + async _ensureSignedPhotos(rawUrls) { + for (const raw of rawUrls) { + const url = tmSafePhotoUrl(raw); + if (!url || this._signed[url] || this._inflight.has(url)) continue; + this._inflight.add(url); + try { + const res = await this.hass.callWS({ type: "auth/sign_path", path: url, expires: 3600 }); + if (res && res.path) this._signed = { ...this._signed, [url]: res.path }; + } catch (e) { + console.warn("taskmate: sign_path failed", e); + } finally { + this._inflight.delete(url); + } + } + } + + updated() { + const urls = this._collectPhotoUrls(); + if (urls.length) this._ensureSignedPhotos(urls); + } + static get styles() { const base = css` :host { @@ -530,10 +590,9 @@ class TaskMateApprovalsCard extends LitElement { // Pending reward claims: supported via either the pending_approvals sensor // (reward_claims attribute) or the rewards sensor (pending_reward_claims). - let rewardClaims = - entity.attributes.reward_claims || - attrs.pending_reward_claims || - []; + let rewardClaims = tmClaimList( + entity.attributes.reward_claims || attrs.pending_reward_claims, + ); const filteredClaims = this._filterClaimsByChild(rewardClaims); // Missed mandatory chores awaiting parent review (#532) @@ -611,7 +670,7 @@ class TaskMateApprovalsCard extends LitElement { if (!completions) completions = (attrs.todays_completions || []).filter(c => !c.approved); const filteredCompletions = this._filterByChild(completions); - const rewardClaims = entity.attributes.reward_claims || attrs.pending_reward_claims || []; + const rewardClaims = tmClaimList(entity.attributes.reward_claims || attrs.pending_reward_claims); const filteredClaims = this._filterClaimsByChild(rewardClaims); const misses = this._filterMissesByChild(attrs.mandatory_misses || []); @@ -805,7 +864,7 @@ class TaskMateApprovalsCard extends LitElement { } _apPhotoDesigned(it, cls) { - const photoUrl = tmSafePhotoUrl(it.photo); + const photoUrl = this._photoHref(it.photo); if (it.kind !== "completion" || !photoUrl) { return it.kind === "claim" && it.icon ? html`
` @@ -1332,11 +1391,11 @@ class TaskMateApprovalsCard extends LitElement { ${completion.points} - ${tmSafePhotoUrl(completion.photo_url) ? html` - - + ` : ''} diff --git a/custom_components/taskmate/www/taskmate-attr-resolver.js b/custom_components/taskmate/www/taskmate-attr-resolver.js index 4c2c7b7d..cc9d7d6c 100644 --- a/custom_components/taskmate/www/taskmate-attr-resolver.js +++ b/custom_components/taskmate/www/taskmate-attr-resolver.js @@ -37,6 +37,30 @@ "sensor.taskmate_pending_approvals", ]; + // Attributes a companion must NOT contribute to the merge, because another + // sensor already owns that name for different data (#834). + // + // sensor.taskmate_pending_approvals publishes both the full lists and a + // scalar count of each. Its `pending_reward_claims` count collides with + // sensor.taskmate_rewards' `pending_reward_claims` LIST, and the approvals + // sensor merges last, so the number won — every card that did + // `claims.filter(...)` / `claims.some(...)` threw as soon as a claim was + // pending, blanking the card. Zero pending claims hid it: the cards' own + // `|| []` fallback catches a count of 0 because it is falsy, so the crash + // only appeared once a child actually claimed something. + // + // Nothing reads these counts through the resolver — the admin panel takes + // its badge counts from the taskmate/get_state WebSocket call and the cards + // derive theirs from the lists' length. They stay readable directly off + // sensor.taskmate_pending_approvals for templates and automations. + const COMPANION_SKIP_KEYS = { + "sensor.taskmate_pending_approvals": [ + "pending_chore_completions", + "pending_reward_claims", + "pending_mandatory_misses", + ], + }; + function mergedAttributes(hass, primaryEntityId) { if (!hass || !hass.states) return {}; const merged = {}; @@ -46,8 +70,14 @@ } for (const id of COMPANIONS) { const s = hass.states[id]; - if (s && s.attributes) { + if (!s || !s.attributes) continue; + const skip = COMPANION_SKIP_KEYS[id]; + if (!skip) { Object.assign(merged, s.attributes); + continue; + } + for (const [key, value] of Object.entries(s.attributes)) { + if (!skip.includes(key)) merged[key] = value; } } return merged; diff --git a/custom_components/taskmate/www/taskmate-localize.js b/custom_components/taskmate/www/taskmate-localize.js index 5638dd91..1cdef625 100644 --- a/custom_components/taskmate/www/taskmate-localize.js +++ b/custom_components/taskmate/www/taskmate-localize.js @@ -84,7 +84,11 @@ function localize(hass, key, params) { str = _cache[_FALLBACK][key]; } if (str === undefined) { - str = key; + // Missing translation: only echo the key back when it's a plain identifier. + // A key carrying HTML-significant characters is not a real key — it's caller + // data concatenated into the lookup (e.g. `panel.assign_${value}_short`) — + // so refuse to reflect it, since some call sites render _t() output as HTML. + str = /^[\w.-]+$/.test(key) ? key : ""; } // Trigger background loads for any language not yet in cache @@ -93,10 +97,12 @@ function localize(hass, key, params) { } if (!(_FALLBACK in _cache)) _loadLocale(_FALLBACK); - // Replace {placeholder} tokens + // Replace {placeholder} tokens. Use a replacer function so a value containing + // "$&", "$1" etc. is inserted literally rather than interpreted as a + // replacement pattern. if (params && typeof str === 'string') { for (const [k, v] of Object.entries(params)) { - str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); + str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), () => String(v)); } } diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index a0a6c6a3..c59004c9 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -3349,7 +3349,7 @@ class TaskMatePanel extends HTMLElement { : ((c.due_days || []).length === 0 ? this._t("panel.common_daily") : (c.due_days || []).map(d => this._labelOf(DAYS, d)).join(" · ")); const schedClass = c.schedule_mode === "recurring" ? "tm-pill-accent" : c.schedule_mode === "one_shot" ? "tm-pill-warn" : "tm-pill-success"; const modeBadge = c.assignment_mode && c.assignment_mode !== "everyone" - ? `${this._t(`panel.assign_${c.assignment_mode}_short`)}` : ""; + ? `${this._t(`panel.assign_${c.assignment_mode}_short`)}` : ""; const nameCell = renaming ? ` @@ -3571,7 +3571,7 @@ class TaskMatePanel extends HTMLElement { } _tierClass(tier) { - return `tm-badge-tier-${(tier || "bronze").toLowerCase()}`; + return `tm-badge-tier-${this._esc((tier || "bronze").toLowerCase())}`; } _criteriaLabel(criteria, combinator = "AND") { @@ -3973,7 +3973,7 @@ class TaskMatePanel extends HTMLElement {

${this._esc(g.name)}

${this._idBadge(g.id)} -
${this._t(`panel.group_policy_${g.policy}_short`)} · ${this._t("panel.group_chore_count", {count: (g.chore_ids || []).length})}
+
${this._t(`panel.group_policy_${g.policy}_short`)} · ${this._t("panel.group_chore_count", {count: (g.chore_ids || []).length})}