28 files
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_") :]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
``<img>`` 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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.4.1"
|
||||
"version": "5.4.3"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <img> (which carries no bearer
|
||||
# token) can load it from the auth-gated serve view.
|
||||
# Emit the bare (unsigned) photo path. A card's <img> 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 = []
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <img> 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`<div class="${cls}"><ha-icon icon="${it.icon}"></ha-icon></div>`
|
||||
@@ -1332,11 +1391,11 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
${completion.points}
|
||||
</span>
|
||||
</div>
|
||||
${tmSafePhotoUrl(completion.photo_url) ? html`
|
||||
<a class="approval-photo" href="${tmSafePhotoUrl(completion.photo_url)}" target="_blank" rel="noopener"
|
||||
@click="${(e) => this._openPhoto(e, tmSafePhotoUrl(completion.photo_url), this._photoCaption(completion.chore_name, completion.child_name, completion.completed_at))}"
|
||||
${this._photoHref(completion.photo_url) ? html`
|
||||
<a class="approval-photo" href="${this._photoHref(completion.photo_url)}" target="_blank" rel="noopener"
|
||||
@click="${(e) => this._openPhoto(e, this._photoHref(completion.photo_url), this._photoCaption(completion.chore_name, completion.child_name, completion.completed_at))}"
|
||||
title="${this._t('approvals.view_photo') || 'View photo'}">
|
||||
<img src="${tmSafePhotoUrl(completion.photo_url)}" alt="" loading="lazy">
|
||||
<img src="${this._photoHref(completion.photo_url)}" alt="" loading="lazy">
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
? `<span class="tm-pill tm-pill-${c.assignment_mode}">${this._t(`panel.assign_${c.assignment_mode}_short`)}</span>` : "";
|
||||
? `<span class="tm-pill tm-pill-${this._esc(c.assignment_mode)}">${this._t(`panel.assign_${c.assignment_mode}_short`)}</span>` : "";
|
||||
const nameCell = renaming
|
||||
? `<input class="tm-inline-input" type="text" data-field="_inlineRename" value="${this._esc(this._inlineRename.value)}" autofocus>
|
||||
<button type="button" class="tm-icon-btn" data-act="rename-chore-commit" title="${this._t("panel.tooltip_save")}">✓</button>
|
||||
@@ -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 {
|
||||
<div class="tm-child-name">
|
||||
<h3>${this._esc(g.name)}</h3>
|
||||
${this._idBadge(g.id)}
|
||||
<div class="tm-meta"><span class="tm-pill tm-pill-${g.policy}">${this._t(`panel.group_policy_${g.policy}_short`)}</span> · ${this._t("panel.group_chore_count", {count: (g.chore_ids || []).length})}</div>
|
||||
<div class="tm-meta"><span class="tm-pill tm-pill-${this._esc(g.policy)}">${this._t(`panel.group_policy_${g.policy}_short`)}</span> · ${this._t("panel.group_chore_count", {count: (g.chore_ids || []).length})}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="tm-group-list">
|
||||
@@ -4103,7 +4103,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
|
||||
_renderTemplateChoreCard(chore, idx) {
|
||||
const expanded = chore._expanded;
|
||||
const schedLabel = (chore.due_days || []).length === 0 ? this._t("panel.common_daily") : (chore.due_days || []).map(d => this._labelOf(DAYS, d)).join(", ");
|
||||
const schedLabel = (chore.due_days || []).length === 0 ? this._t("panel.common_daily") : (chore.due_days || []).map(d => this._esc(this._labelOf(DAYS, d))).join(", ");
|
||||
return `
|
||||
<div class="tm-tpl-preview-card">
|
||||
<div class="tm-tpl-preview-header" data-act="tpl-toggle-expand" data-idx="${idx}">
|
||||
@@ -5387,7 +5387,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
const value = f === "assigned_to"
|
||||
? (v || []).map(id => this._esc((children.find(c => c.id === id) || {}).name || id)).join(", ")
|
||||
|| this._t("panel.sched_value_nobody")
|
||||
: Array.isArray(v) ? v.join(", ")
|
||||
: Array.isArray(v) ? this._esc(v.join(", "))
|
||||
: typeof v === "boolean" ? this._t(v ? "panel.sched_value_yes" : "panel.sched_value_no")
|
||||
: this._esc(String(v));
|
||||
return `${label}: <strong>${value}</strong>`;
|
||||
|
||||
@@ -19,6 +19,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 TaskMateParentDashboardCard extends LitElement {
|
||||
@@ -29,6 +36,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
_loading: { type: Object },
|
||||
_activeSection: { type: String },
|
||||
_expanded: { type: Object },
|
||||
_signed: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +77,58 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
this._loading = {};
|
||||
this._activeSection = "overview";
|
||||
this._expanded = {};
|
||||
this._signed = {}; // safe photo path -> per-viewer signed path
|
||||
this._inflight = new Set();
|
||||
}
|
||||
|
||||
// Evidence photos come from an auth-gated view; an <img> sends no bearer
|
||||
// token, so sign each path per-viewer via auth/sign_path. The sensor now
|
||||
// publishes bare paths, keeping any self-authenticating URL out of shared
|
||||
// state and binding 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() {
|
||||
@@ -527,7 +587,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
// approve button after a recurring chore resets. `completions` stays
|
||||
// today-only for the overview tab's activity display.
|
||||
const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved);
|
||||
const pendingRewardClaims = attrs.pending_reward_claims || [];
|
||||
const pendingRewardClaims = tmClaimList(attrs.pending_reward_claims);
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const pointsName = attrs.points_name || this._t('common.points');
|
||||
const totalPending = pendingCompletions.length + pendingRewardClaims.length;
|
||||
@@ -662,7 +722,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
const chores = attrs.chores || [];
|
||||
const completions = attrs.todays_completions || [];
|
||||
const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved);
|
||||
const pendingRewardClaims = attrs.pending_reward_claims || [];
|
||||
const pendingRewardClaims = tmClaimList(attrs.pending_reward_claims);
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const pointsName = attrs.points_name || this._t('common.points');
|
||||
const totalPending = pendingCompletions.length + pendingRewardClaims.length;
|
||||
@@ -816,7 +876,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
const time = comp.completed_at
|
||||
? new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
|
||||
const pts = comp.points || choreMap[comp.chore_id]?.points || 0;
|
||||
const photoUrl = tmSafePhotoUrl(comp.photo_url);
|
||||
const photoUrl = this._photoHref(comp.photo_url);
|
||||
const photoCap = this._photoCaption(comp.chore_name, child && child.name, comp.completed_at);
|
||||
const photo = photoUrl
|
||||
? html`<a class="pd-photo-link" href="${photoUrl}" target="_blank" rel="noopener"
|
||||
@@ -1149,11 +1209,11 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
+${comp.points || chore?.points || 0}
|
||||
</span>
|
||||
</div>
|
||||
${tmSafePhotoUrl(comp.photo_url) ? html`
|
||||
<a class="approval-photo" href="${tmSafePhotoUrl(comp.photo_url)}" target="_blank" rel="noopener"
|
||||
@click="${(e) => this._openPhoto(e, tmSafePhotoUrl(comp.photo_url), this._photoCaption(comp.chore_name || chore?.name, child && child.name, comp.completed_at))}"
|
||||
${this._photoHref(comp.photo_url) ? html`
|
||||
<a class="approval-photo" href="${this._photoHref(comp.photo_url)}" target="_blank" rel="noopener"
|
||||
@click="${(e) => this._openPhoto(e, this._photoHref(comp.photo_url), this._photoCaption(comp.chore_name || chore?.name, child && child.name, comp.completed_at))}"
|
||||
title="${this._t('approvals.view_photo') || 'View photo'}">
|
||||
<img src="${tmSafePhotoUrl(comp.photo_url)}" alt="" loading="lazy">
|
||||
<img src="${this._photoHref(comp.photo_url)}" alt="" loading="lazy">
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,10 @@ class TaskMatePhotoGalleryCard extends LitElement {
|
||||
async _ensureSigned(items) {
|
||||
for (const it of items) {
|
||||
const url = it.photo_url;
|
||||
if (!url || this._signed[url] || this._inflight.has(url)) continue;
|
||||
// Only sign our own photo paths; never hand auth/sign_path a foreign or
|
||||
// javascript:/data: value that could round-trip into a clickable href.
|
||||
if (!url || typeof url !== "string" || !url.startsWith("/api/taskmate/photo/")) continue;
|
||||
if (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 });
|
||||
|
||||
@@ -15,6 +15,13 @@ const LitElement = customElements.get("hui-masonry-view")
|
||||
const html = LitElement.prototype.html;
|
||||
const css = LitElement.prototype.css;
|
||||
|
||||
// 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 TaskMateRewardsCard extends LitElement {
|
||||
@@ -1363,7 +1370,8 @@ class TaskMateRewardsCard extends LitElement {
|
||||
|
||||
// Pending claim check
|
||||
const pcAttrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
const pendingClaims = pcAttrs.pending_reward_claims || [];
|
||||
// Always a list, even if the attribute arrives as something else (#834).
|
||||
const pendingClaims = tmClaimList(pcAttrs.pending_reward_claims);
|
||||
const hasPendingClaim = pendingClaims.some(c =>
|
||||
c.reward_id === reward.id && (!childId || c.child_id === childId)
|
||||
);
|
||||
@@ -1842,7 +1850,8 @@ class TaskMateRewardsCard extends LitElement {
|
||||
|
||||
const pcAttrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
||||
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
const pendingClaims = pcAttrs.pending_reward_claims || [];
|
||||
// Always a list, even if the attribute arrives as something else (#834).
|
||||
const pendingClaims = tmClaimList(pcAttrs.pending_reward_claims);
|
||||
const hasPendingClaim = pendingClaims.some(c =>
|
||||
c.reward_id === reward.id && (!childId || c.child_id === childId)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user