diff --git a/.ha_run.lock b/.ha_run.lock index 9a4bdca4..4c76df85 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.10.1", "start_ts": 1791844372.9210048} \ No newline at end of file +{"pid": 73, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792001005.2570755} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index 1b88d636..9ee90105 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -81,117 +81,117 @@ }, { "id": "88b90f2b1d6b45f0b44bee786ee1a525", - "url": "/taskmate/taskmate-attr-resolver.js?v=6.2.0", + "url": "/taskmate/taskmate-attr-resolver.js?v=6.3.0", "type": "module" }, { "id": "0796c92931dd458e81dc9c2677248274", - "url": "/taskmate/taskmate-localize.js?v=6.2.0", + "url": "/taskmate/taskmate-localize.js?v=6.3.0", "type": "module" }, { "id": "d2e281920ac7409583eafff1f1db69bd", - "url": "/taskmate/taskmate-design.js?v=6.2.0", + "url": "/taskmate/taskmate-design.js?v=6.3.0", "type": "module" }, { "id": "9eae9632f0734480be5edd784b030673", - "url": "/taskmate/taskmate-badges-card.js?v=6.2.0", + "url": "/taskmate/taskmate-badges-card.js?v=6.3.0", "type": "module" }, { "id": "5df49cd8a4eb420497b5df22cb31362c", - "url": "/taskmate/taskmate-child-card.js?v=6.2.0", + "url": "/taskmate/taskmate-child-card.js?v=6.3.0", "type": "module" }, { "id": "b44c87d0d74f468cb96c5eabc340f9ee", - "url": "/taskmate/taskmate-rewards-card.js?v=6.2.0", + "url": "/taskmate/taskmate-rewards-card.js?v=6.3.0", "type": "module" }, { "id": "787a96f9c30548708165a48457c99feb", - "url": "/taskmate/taskmate-approvals-card.js?v=6.2.0", + "url": "/taskmate/taskmate-approvals-card.js?v=6.3.0", "type": "module" }, { "id": "84031597de424ad68e7c2bc66d06b72e", - "url": "/taskmate/taskmate-points-card.js?v=6.2.0", + "url": "/taskmate/taskmate-points-card.js?v=6.3.0", "type": "module" }, { "id": "02613225844746b699bfbfac9642eca2", - "url": "/taskmate/taskmate-reorder-card.js?v=6.2.0", + "url": "/taskmate/taskmate-reorder-card.js?v=6.3.0", "type": "module" }, { "id": "7a06a07e17ba4b31b712bc91b77360b4", - "url": "/taskmate/taskmate-overview-card.js?v=6.2.0", + "url": "/taskmate/taskmate-overview-card.js?v=6.3.0", "type": "module" }, { "id": "08b42b53b33b4fa88db932455cf8d5aa", - "url": "/taskmate/taskmate-activity-card.js?v=6.2.0", + "url": "/taskmate/taskmate-activity-card.js?v=6.3.0", "type": "module" }, { "id": "15907eff51784acdba88ff694b574509", - "url": "/taskmate/taskmate-streak-card.js?v=6.2.0", + "url": "/taskmate/taskmate-streak-card.js?v=6.3.0", "type": "module" }, { "id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39", - "url": "/taskmate/taskmate-weekly-card.js?v=6.2.0", + "url": "/taskmate/taskmate-weekly-card.js?v=6.3.0", "type": "module" }, { "id": "289ce5943f5a4972a593697db4c96ece", - "url": "/taskmate/taskmate-graph-card.js?v=6.2.0", + "url": "/taskmate/taskmate-graph-card.js?v=6.3.0", "type": "module" }, { "id": "d2904acee4c74a398dcc7b996c23e7e7", - "url": "/taskmate/taskmate-reward-progress-card.js?v=6.2.0", + "url": "/taskmate/taskmate-reward-progress-card.js?v=6.3.0", "type": "module" }, { "id": "46d378736fd54a26be253f2dfb27202e", - "url": "/taskmate/taskmate-leaderboard-card.js?v=6.2.0", + "url": "/taskmate/taskmate-leaderboard-card.js?v=6.3.0", "type": "module" }, { "id": "db11ae36457842fa826c7991e1d9c6f4", - "url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.2.0", + "url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.3.0", "type": "module" }, { "id": "ce85f301b59a412e9abfe5ffd51cb9a2", - "url": "/taskmate/taskmate-penalties-card.js?v=6.2.0", + "url": "/taskmate/taskmate-penalties-card.js?v=6.3.0", "type": "module" }, { "id": "1959e1629de2422daa8adaf87069c288", - "url": "/taskmate/taskmate-bonuses-card.js?v=6.2.0", + "url": "/taskmate/taskmate-bonuses-card.js?v=6.3.0", "type": "module" }, { "id": "2548524b269c4959bc7e0e967df943af", - "url": "/taskmate/taskmate-points-display-card.js?v=6.2.0", + "url": "/taskmate/taskmate-points-display-card.js?v=6.3.0", "type": "module" }, { "id": "36ada49368db4187a283fbbbef45454f", - "url": "/taskmate/taskmate-calendar-card.js?v=6.2.0", + "url": "/taskmate/taskmate-calendar-card.js?v=6.3.0", "type": "module" }, { "id": "67c32687acd2497b90030ef141f4b6db", - "url": "/taskmate/taskmate-photo-gallery-card.js?v=6.2.0", + "url": "/taskmate/taskmate-photo-gallery-card.js?v=6.3.0", "type": "module" }, { "id": "6f0c8348a91f4b74a9b846ab3f62621f", - "url": "/taskmate/taskmate-family-goal-card.js?v=6.2.0", + "url": "/taskmate/taskmate-family-goal-card.js?v=6.3.0", "type": "module" }, { @@ -276,7 +276,7 @@ }, { "id": "73dbd2de03de4530b451a55bfe6c46cf", - "url": "/taskmate/taskmate-routine-card.js?v=6.2.0", + "url": "/taskmate/taskmate-routine-card.js?v=6.3.0", "type": "module" }, { @@ -291,7 +291,7 @@ }, { "id": "1a4939ce1d2b47f89502176d3c5ac6e7", - "url": "/taskmate/taskmate-sounds.js?v=6.2.0", + "url": "/taskmate/taskmate-sounds.js?v=6.3.0", "type": "module" }, { @@ -306,32 +306,32 @@ }, { "id": "2cc757922cd142b69ce8844afc0e8410", - "url": "/taskmate/taskmate-wishlist-card.js?v=6.2.0", + "url": "/taskmate/taskmate-wishlist-card.js?v=6.3.0", "type": "module" }, { "id": "107319877a72413fbbea40b7f8adb06c", - "url": "/taskmate/taskmate-kiosk-card.js?v=6.2.0", + "url": "/taskmate/taskmate-kiosk-card.js?v=6.3.0", "type": "module" }, { "id": "5a2899c29e9046c78f060580ba113d9f", - "url": "/taskmate/taskmate-bounty-card.js?v=6.2.0", + "url": "/taskmate/taskmate-bounty-card.js?v=6.3.0", "type": "module" }, { "id": "a1aae7e334ba4c2d8dd3849178f780f6", - "url": "/taskmate/taskmate-auction-card.js?v=6.2.0", + "url": "/taskmate/taskmate-auction-card.js?v=6.3.0", "type": "module" }, { "id": "464b73198b7d4fe480c09bbf386a4e5d", - "url": "/taskmate/taskmate-recap-card.js?v=6.2.0", + "url": "/taskmate/taskmate-recap-card.js?v=6.3.0", "type": "module" }, { "id": "35f1f324116d4b589f3087065519360b", - "url": "/taskmate/taskmate-chore-board-card.js?v=6.2.0", + "url": "/taskmate/taskmate-chore-board-card.js?v=6.3.0", "type": "module" }, { @@ -339,11 +339,6 @@ "url": "/hacsfiles/Filament-Card/filament-card.js?hacstag=1290882685050", "type": "module" }, - { - "id": "147a07760eec4fe69bd509064f81e3cd", - "url": "/album_slideshow_static/album-slideshow-card.js?v=1.14.0", - "type": "module" - }, { "id": "f9c46ac0dce64ee3bca668586145e4f0", "url": "/hacsfiles/lovelace-state-switch/state-switch.js?hacstag=158756598196", @@ -355,7 +350,12 @@ "type": "module" }, { - "id": "0d5a102183b74a2f9edb5b90dd664dcb", + "id": "a1a494805ac84a78a6dba82b528ba900", + "url": "/album_slideshow_static/album-slideshow-card.js?v=1.15.0", + "type": "module" + }, + { + "id": "ec621063df614ae38f26b7c0c97d16fd", "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "type": "module" } diff --git a/custom_components/album_slideshow/__init__.py b/custom_components/album_slideshow/__init__.py index 30b6bb3b..89795476 100644 --- a/custom_components/album_slideshow/__init__.py +++ b/custom_components/album_slideshow/__init__.py @@ -481,7 +481,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return cam = data.get("camera") if cam: - await cam.async_force_next() + await cam.async_force_next(_displayed_frame_id(call)) async def _previous_slide(call) -> None: entry_id = call.data.get(ATTR_ENTRY_ID) @@ -492,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return cam = data.get("camera") if cam: - await cam.async_force_prev() + await cam.async_force_prev(_displayed_frame_id(call)) async def _refresh_album(call) -> None: entry_id = call.data.get(ATTR_ENTRY_ID) @@ -529,6 +529,12 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non await hass.config_entries.async_reload(entry.entry_id) +def _displayed_frame_id(call) -> int | None: + """Optional frame a card is showing, so navigation starts from it.""" + frame_id = call.data.get("frame_id") + return frame_id if type(frame_id) is int and frame_id >= 0 else None + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/custom_components/album_slideshow/camera.py b/custom_components/album_slideshow/camera.py index c58d16a8..11251be5 100644 --- a/custom_components/album_slideshow/camera.py +++ b/custom_components/album_slideshow/camera.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio from collections import OrderedDict, deque -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from datetime import datetime, timezone +import itertools import logging import math import random @@ -64,6 +65,31 @@ _MAX_RENDER_ATTEMPTS = 10 # a small percentage rounds down to zero. _PAIR_GAP_FLOOR = 2 +# Recent frame IDs remembered so navigation can start from a card's displayed slide. +_FRAME_ID_MEMORY = 64 + +# Store settings that change how slides look or which photos play. Changing any +# of them discards rendered slides; other settings (interval, pause, buffer +# size, cache size) keep the navigation history. +_RENDER_SETTINGS = ( + "fill_mode", + "portrait_mode", + "order_mode", + "shuffle_age_bias", + "aspect_ratio", + "pair_divider_px", + "pair_divider_color", + "pair_min_gap_percent", + "max_resolution", + "date_filter", + "custom_lookback_days", + "missing_date_mode", + "face_debug", + "hidden_revision", +) + +_next_frame_serial = itertools.count(1).__next__ + def _pair_exclusion_radius(n: int, min_gap_percent: float) -> int: """Items to exclude on each side of the current index for pairing. @@ -100,6 +126,7 @@ class _RenderedFrame: data: bytes cursor: _NavigationCursor meta: dict + serial: int = field(default_factory=_next_frame_serial, compare=False) def _ts_to_iso(ts_ms: int | None) -> str | None: @@ -125,6 +152,18 @@ def _camera_metadata_attributes(item: MediaItem | None) -> dict[str, Any]: } +def _caption_frame(item: MediaItem | None) -> dict[str, Any]: + """Caption metadata for one photo of a slide.""" + return { + "captured_at": _ts_to_iso(getattr(item, "captured_at", None)), + "location": getattr(item, "location", None), + "latitude": getattr(item, "latitude", None), + "longitude": getattr(item, "longitude", None), + "description": getattr(item, "description", None), + **_camera_metadata_attributes(item), + } + + def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None: """Return the validated face boxes stored on a media item. @@ -225,9 +264,6 @@ class AlbumSlideshowCamera(Camera): ) self._recent_urls: list[str] = [] self._last_is_portrait: bool | None = None - # When the current frame is a paired image, this is [taken_a, taken_b] - # ISO strings (top/left first); None for single frames. - self._last_captured_at_pair: list[str | None] | None = None # Full per-half caption metadata for a paired frame: a list of two # dicts (top/left first) each carrying captured_at / location / # latitude / longitude. None for single frames. Lets the Lovelace @@ -247,6 +283,8 @@ class AlbumSlideshowCamera(Camera): # has an unambiguous "new frame ready" signal even when other # attributes happen not to change between slides. self._frame_id: int = 0 + # Recent frame IDs mapped to frame serials, oldest first. + self._frame_serials: dict[int, int] = {} self._interrupt_event: asyncio.Event = asyncio.Event() self._slide_deadline: float | None = None @@ -276,18 +314,31 @@ class AlbumSlideshowCamera(Camera): self._last_nav_error: str | None = None self._consecutive_failures: int = 0 self._render_task: asyncio.Task | None = None + self._render_settings = self._current_render_settings() def _on_coordinator_update() -> None: + previous_items = self._effective_cache[1] if self._effective_cache else None self._effective_cache = None - self._invalidate_timeline() + if self._keep_timeline(previous_items): + self.async_write_ha_state() + else: + self._invalidate_timeline() coordinator.async_add_listener(_on_coordinator_update) def _on_store_change() -> None: self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024) - self._effective_cache = None self._slide_deadline = None - self._invalidate_timeline() + settings = self._current_render_settings() + if settings != self._render_settings: + self._render_settings = settings + self._effective_cache = None + self._invalidate_timeline() + return + # Interval, pause and buffer changes keep the rendered slides. + self._schedule_preload() + self._interrupt_event.set() + self.async_write_ha_state() store.add_listener(_on_store_change) @@ -344,7 +395,11 @@ class AlbumSlideshowCamera(Camera): items: list[MediaItem] = self._effective_items() cur = items[self._index] if items and 0 <= self._index < len(items) else None captured_at = _ts_to_iso(getattr(cur, "captured_at", None)) - captured_at_pair = self._last_captured_at_pair + caption_frames = self._caption_frames(cur) + captured_at_pair = ( + [frame["captured_at"] for frame in caption_frames] + if self._last_pair_frames else None + ) return { "entry_id": self.entry.entry_id, "displayed_photo_ids": ( @@ -389,7 +444,7 @@ class AlbumSlideshowCamera(Camera): # so the card can overlay an accurate date/location on each half. # ``pair_orientation`` tells the card how the two halves are laid # out: ``horizontal`` (left/right) or ``vertical`` (top/bottom). - "caption_frames": self._caption_frames(cur, captured_at), + "caption_frames": caption_frames, "pair_orientation": self._last_pair_orientation, "slide_interval": int(self.store.slide_interval), "fill_mode": self.store.fill_mode, @@ -422,7 +477,7 @@ class AlbumSlideshowCamera(Camera): "pagination_debug": data.get("pagination_debug"), } - def _caption_frames(self, cur, captured_at: str | None) -> list[dict]: + def _caption_frames(self, cur) -> list[dict]: """Per-image caption metadata for the current slide. Returns a list with one dict for a normal slide, or two (top/left @@ -430,18 +485,21 @@ class AlbumSlideshowCamera(Camera): string or ``None``), ``location`` (human label or ``None``), and ``latitude`` / ``longitude``. The card reads this to overlay an accurate caption on each image, including each half of a pair. + Paired captions are read from the live items so metadata found after + the slide was rendered still shows. """ - if self._last_pair_frames: + if not self._last_pair_frames: + return [_caption_frame(cur)] + photo_ids = self._current_frame.meta.get("photo_ids", []) if self._current_frame else [] + if len(photo_ids) != len(self._last_pair_frames): return self._last_pair_frames + live = { + item.photo_id: item for item in self._effective_items() + if item.photo_id and item.photo_id in photo_ids + } return [ - { - "captured_at": captured_at, - "location": getattr(cur, "location", None), - "latitude": getattr(cur, "latitude", None), - "longitude": getattr(cur, "longitude", None), - "description": getattr(cur, "description", None), - **_camera_metadata_attributes(cur), - } + _caption_frame(live[photo_id]) if photo_id in live else frame + for photo_id, frame in zip(photo_ids, self._last_pair_frames) ] @property @@ -496,15 +554,19 @@ class AlbumSlideshowCamera(Camera): self._effective_cache = (hash(cache_key), ordered) return ordered - async def async_force_next(self) -> None: - """Display the next buffered slide immediately.""" - await self._async_navigate(1) + async def async_force_next(self, frame_id: int | None = None) -> None: + """Display the next buffered slide immediately. - async def async_force_prev(self) -> None: + ``frame_id`` is the frame a card is showing; when it is still retained, + the step starts from that slide rather than the camera's latest one. + """ + await self._async_navigate(1, frame_id) + + async def async_force_prev(self, frame_id: int | None = None) -> None: """Display the previous retained slide immediately.""" - await self._async_navigate(-1) + await self._async_navigate(-1, frame_id) - async def _async_navigate(self, direction: int) -> None: + async def _async_navigate(self, direction: int, frame_id: int | None = None) -> None: """Serialise a manual navigation request and execute it directly.""" direction_name = "next" if direction > 0 else "previous" self._navigation_pending += 1 @@ -522,11 +584,15 @@ class AlbumSlideshowCamera(Camera): self._last_nav_started_at = _utc_now_iso() if self._timeline_dirty: await self._rebuild_current_frame() + rewound = self._rewind_to_displayed(frame_id) changed = ( await self._show_next_frame() if direction > 0 else await self._show_previous_frame() ) + if not changed and rewound: + # Nothing older than the displayed slide: stay on it. + self._apply_frame(self._current_frame) if changed: self._last_nav_committed_at = _utc_now_iso() self._last_nav_outcome = "displayed" @@ -678,6 +744,7 @@ class AlbumSlideshowCamera(Camera): "captured_at_pair": None, "pair_frames": None, "pair_orientation": None, + "faces": faces[half:half + 1] if (faces := frame.meta.get("faces")) else None, } self._timeline_dirty = False self._apply_frame(_RenderedFrame(encoded, cursor, meta)) @@ -693,7 +760,6 @@ class AlbumSlideshowCamera(Camera): self._framebuffer = None self.store.last_frame = None self._last_is_portrait = None - self._last_captured_at_pair = None self._last_pair_frames = None self._last_pair_orientation = None @@ -809,6 +875,8 @@ class AlbumSlideshowCamera(Camera): composed, meta = await renderer._compose_for_index(items) meta = dict(meta or {}) meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)]) + # Faces used for the crop, so later face data can be detected. + meta.setdefault("faces", (_item_faces(items[renderer._index]),)) cursor = self._capture_cursor(renderer) if composed is None: raise RuntimeError("Image composition returned no frame") @@ -845,10 +913,12 @@ class AlbumSlideshowCamera(Camera): meta = frame.meta self._last_is_portrait = meta.get("is_portrait") - self._last_captured_at_pair = meta.get("captured_at_pair") self._last_pair_frames = meta.get("pair_frames") self._last_pair_orientation = meta.get("pair_orientation") self._frame_id += 1 + self._frame_serials[self._frame_id] = frame.serial + while len(self._frame_serials) > _FRAME_ID_MEMORY: + del self._frame_serials[next(iter(self._frame_serials))] _LOGGER.debug( "Album Slideshow %s: displayed buffered frame_id=%d index=%d " @@ -886,6 +956,84 @@ class AlbumSlideshowCamera(Camera): self._interrupt_event.set() self.async_write_ha_state() + def _current_render_settings(self) -> tuple: + return tuple(getattr(self.store, name) for name in _RENDER_SETTINGS) + + def _keep_timeline(self, previous_items: list[MediaItem] | None) -> bool: + """Keep rendered slides that are still valid after a coordinator update. + + Metadata-only updates (dates, places, captions) keep every slide. If the + playlist itself changed, slides already shown are kept and re-anchored + to their new positions, and upcoming slides are rendered again. Returns + False when the timeline has to be rebuilt instead. + """ + current = self._current_frame + if current is None or self._timeline_dirty or self._navigation_lock.locked(): + return False + items = self._effective_items() + found = {item.photo_id: (index, item) for index, item in enumerate(items) if item.photo_id} + + def faces_changed(frame: _RenderedFrame) -> bool: + recorded = frame.meta.get("faces") + photo_ids = frame.meta.get("photo_ids") or [] + return ( + recorded is not None + and all(photo_id in found for photo_id in photo_ids) + and recorded != tuple(_item_faces(found[photo_id][1]) for photo_id in photo_ids) + ) + + if previous_items is not None and [ + item.photo_id or item.url for item in previous_items + ] == [item.photo_id or item.url for item in items]: + return not any( + faces_changed(frame) + for frame in (*self._previous_frames, current, *self._next_frames) + ) + + def remap(frame: _RenderedFrame) -> _RenderedFrame | None: + photo_ids = frame.meta.get("photo_ids") or [] + if not photo_ids or any(photo_id not in found for photo_id in photo_ids) or faces_changed(frame): + return None + return replace(frame, cursor=replace(frame.cursor, index=found[photo_ids[0]][0])) + + current = remap(current) + if current is None: + return False + previous = [frame for frame in map(remap, self._previous_frames) if frame is not None] + self._timeline_generation += 1 + self._cancel_preload() + self._previous_frames.clear() + self._previous_frames.extend(previous) + self._next_frames.clear() + self._current_frame = current + self._index = current.cursor.index + self._schedule_preload() + return True + + def _rewind_to_displayed(self, frame_id: int | None) -> bool: + """Make the retained slide a card is still showing the current one. + + A card holds its photo while its controls are open, so the camera may + already be further ahead. Navigation then starts from what is on screen. + """ + serial = self._frame_serials.get(frame_id) if frame_id is not None else None + current = self._current_frame + if serial is None or current is None or current.serial == serial: + return False + timeline = [*self._previous_frames, current, *self._next_frames] + position = next( + (index for index, frame in enumerate(timeline) if frame.serial == serial), + None, + ) + if position is None: + return False + self._previous_frames.clear() + self._previous_frames.extend(timeline[:position]) + self._next_frames.clear() + self._next_frames.extend(timeline[position + 1:]) + self._current_frame = timeline[position] + return True + def _schedule_preload(self) -> None: """Start the single per-camera worker that fills future frames.""" self._trim_timeline() @@ -1255,24 +1403,7 @@ class AlbumSlideshowCamera(Camera): is_portrait_canvas, divider, divider_fill, transparent_divider, self._crop_hints(cur), self._crop_hints(other_item), ) - pair_frames = [ - { - "captured_at": _ts_to_iso(getattr(cur, "captured_at", None)), - "location": getattr(cur, "location", None), - "latitude": getattr(cur, "latitude", None), - "longitude": getattr(cur, "longitude", None), - "description": getattr(cur, "description", None), - **_camera_metadata_attributes(cur), - }, - { - "captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)), - "location": getattr(other_item, "location", None), - "latitude": getattr(other_item, "latitude", None), - "longitude": getattr(other_item, "longitude", None), - "description": getattr(other_item, "description", None), - **_camera_metadata_attributes(other_item), - }, - ] + pair_frames = [_caption_frame(cur), _caption_frame(other_item)] pair_meta = [f["captured_at"] for f in pair_frames] else: composed = await self._async_image_job( @@ -1285,6 +1416,10 @@ class AlbumSlideshowCamera(Camera): [getattr(cur, "photo_id", None), getattr(other_item, "photo_id", None)] if pair_frames else [getattr(cur, "photo_id", None)] ), + "faces": ( + (_item_faces(cur), _item_faces(other_item)) + if pair_frames else (_item_faces(cur),) + ), "is_portrait": cur_is_portrait, "captured_at_pair": pair_meta, "pair_frames": pair_frames, diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index bef1eec3..ffc799be 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -29,6 +29,8 @@ from .const import ( CONF_MEDIA_CONTENT_ID, CONF_RECURSIVE, CONF_REVERSE_GEOCODE, + CONF_HIDE_HOME_COUNTRY, + DEFAULT_HIDE_HOME_COUNTRY, CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE, @@ -1509,6 +1511,7 @@ class GoogleOptionsFlow(config_entries.OptionsFlow): CONF_GOOGLE_METADATA: DEFAULT_GOOGLE_METADATA, CONF_GOOGLE_LOCATION: DEFAULT_GOOGLE_LOCATION, CONF_REVERSE_GEOCODE: DEFAULT_GOOGLE_REVERSE_GEOCODE, + CONF_HIDE_HOME_COUNTRY: DEFAULT_HIDE_HOME_COUNTRY, } if user_input is not None: options = { @@ -1531,9 +1534,9 @@ class GoogleOptionsFlow(config_entries.OptionsFlow): class LocalFolderOptionsFlow(config_entries.OptionsFlow): """Options for local-folder entries. - Currently exposes a single toggle: ``reverse_geocode``. Users with + Exposes the ``reverse_geocode`` toggle and ``hide_home_country``. Users with privacy concerns about handing EXIF GPS coordinates to an external - OSM endpoint can turn this off; the GPS coordinates remain available + OSM endpoint can turn geocoding off; the GPS coordinates remain available as ``latitude``/``longitude`` attributes regardless. ``self.config_entry`` is provided by ``OptionsFlow`` as a managed @@ -1550,11 +1553,17 @@ class LocalFolderOptionsFlow(config_entries.OptionsFlow): current = self.config_entry.options.get( CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE ) + hide_home = self.config_entry.options.get( + CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY + ) schema = vol.Schema( { vol.Required( CONF_REVERSE_GEOCODE, default=bool(current) ): bool, + vol.Required( + CONF_HIDE_HOME_COUNTRY, default=bool(hide_home) + ): bool, } ) return self.async_show_form(step_id="init", data_schema=schema) @@ -1684,6 +1693,10 @@ class ImmichOptionsFlow(config_entries.OptionsFlow): CONF_REVERSE_GEOCODE, entry.options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE), ), + CONF_HIDE_HOME_COUNTRY: user_input.get( + CONF_HIDE_HOME_COUNTRY, + entry.options.get(CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY), + ), } self.hass.config_entries.async_update_entry( entry, data=new_data, title=fields[CONF_ALBUM_NAME], options=options @@ -1705,6 +1718,9 @@ class ImmichOptionsFlow(config_entries.OptionsFlow): vol.Required(CONF_REVERSE_GEOCODE, default=bool(entry.options.get( CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE, ))): bool, + vol.Required(CONF_HIDE_HOME_COUNTRY, default=bool(entry.options.get( + CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY, + ))): bool, }) return self.async_show_form( step_id="immich_select", diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py index c910d394..b63aaba8 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -65,6 +65,10 @@ DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW # integration's options dialog. CONF_REVERSE_GEOCODE = "reverse_geocode" DEFAULT_REVERSE_GEOCODE = True +# Leave the country out of OpenStreetMap place names when it matches Home +# Assistant's configured country. +CONF_HIDE_HOME_COUNTRY = "hide_home_country" +DEFAULT_HIDE_HOME_COUNTRY = False PROVIDER_GOOGLE_SHARED = "google_shared" PROVIDER_LOCAL_FOLDER = "local_folder" diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index f659bff5..e9d877da 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -34,6 +34,8 @@ from .const import ( CONF_MEDIA_CONTENT_ID, CONF_RECURSIVE, CONF_REVERSE_GEOCODE, + CONF_HIDE_HOME_COUNTRY, + DEFAULT_HIDE_HOME_COUNTRY, CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE, @@ -157,6 +159,9 @@ class MediaItem: face_scanned: bool = False camera_metadata: dict[str, Any] | None = None google_location_scanned: bool = False + # True when ``location`` came from our OpenStreetMap lookup rather than + # the provider, so it can be re-formatted from the place cache. + location_geocoded: bool = False def __post_init__(self) -> None: if self.photo_id is None: @@ -483,6 +488,22 @@ _HEIF_EXIF_RE = re.compile(rb"Exif\x00\x00(?:MM\x00\x2a|II\x2a\x00)") _NOMINATIM_ENDPOINT = "https://nominatim.openstreetmap.org/reverse" _NOMINATIM_MIN_INTERVAL_S = 1.1 _NOMINATIM_TIMEOUT_S = 20 +# Next free request time, shared so several albums together stay within the limit. +_nominatim_next_slot = 0.0 + +# Nominatim address parts used for place labels, most specific first. +_LOCALITY_KEYS = ( + "city", "town", "village", "hamlet", "city_district", "suburb", "municipality", +) +_REGION_KEYS = ("state", "province", "region") +_ADDRESS_KEYS = (*_LOCALITY_KEYS, *_REGION_KEYS, "country", "country_code") +_PLACE_KEYS = (*_ADDRESS_KEYS, "display_name") +# Administrative prefixes some areas carry in OpenStreetMap, longest first. +_NAME_PREFIXES = ( + "regional municipality of ", "rural municipality of ", "municipal district of ", + "municipality of ", "township of ", "district of ", "borough of ", + "village of ", "town of ", "city of ", +) # How many EXIF reads to perform between cache flushes. Bigger batches # mean fewer Store writes but more rework if HA is killed mid-scan. @@ -882,41 +903,68 @@ def _jpeg_exif_segment(data: bytes) -> bytes | None: raise ValueError("JPEG header ends before image data") -def _format_nominatim_location(payload: dict[str, Any]) -> str | None: - """Turn a Nominatim reverse-geocode response into a short label. +def _place_name(value: Any) -> str | None: + """A town or city name without prefixes like "City of"; parishes don't count.""" + if not isinstance(value, str) or not value.strip(): + return None + name = value.strip() + folded = name.casefold() + if folded.endswith(" parish") or folded.startswith("parish of "): + return None + for prefix in _NAME_PREFIXES: + if folded.startswith(prefix) and len(name) > len(prefix): + return name[len(prefix):].strip() + return name - Prefers ``city`` > ``town`` > ``village`` > ``municipality`` > - ``county`` for the locality portion, plus ``country`` when present. - Falls back to Nominatim's pre-formatted ``display_name`` (truncated - to the first two comma-separated parts) when no recognisable - locality fields are present. + +def _nominatim_place(payload: Any) -> dict[str, str] | None: + """Keep the parts of a Nominatim reverse-geocode response that labels use. + + Caching these instead of a finished label lets label options change + without new lookups. """ if not isinstance(payload, dict): return None address = payload.get("address") if isinstance(payload.get("address"), dict) else {} - locality = None - for key in ("city", "town", "village", "hamlet", "municipality", "county", "state"): - val = address.get(key) - if isinstance(val, str) and val.strip(): - locality = val.strip() - break - country = address.get("country") if isinstance(address.get("country"), str) else None - - if locality and country: - return f"{locality}, {country}" - if locality: - return locality - if isinstance(country, str) and country.strip(): - return country.strip() - + place = { + key: value.strip() + for key in _ADDRESS_KEYS + if isinstance(value := address.get(key), str) and value.strip() + } display = payload.get("display_name") - if isinstance(display, str) and display.strip(): - parts = [p.strip() for p in display.split(",") if p.strip()] - if len(parts) >= 2: - return ", ".join(parts[:2]) - if parts: - return parts[0] - return None + if not place and isinstance(display, str) and display.strip(): + place["display_name"] = display.strip() + return place or None + + +def _format_place(place: dict[str, str], home_country: str | None = None) -> str | None: + """Short label such as ``"Lisbon, Portugal"`` from cached address parts. + + Uses the town or city, else the province/state, then the country, which + is left out when its code matches ``home_country``. + """ + area = next( + (name for key in _LOCALITY_KEYS if (name := _place_name(place.get(key)))), None + ) or next((place[key] for key in _REGION_KEYS if place.get(key)), None) + country = place.get("country") + if home_country and place.get("country_code", "").upper() == home_country.upper(): + country = None + parts = [part for part in (area, country) if part] + if len(parts) == 2 and parts[0].casefold() == parts[1].casefold(): + parts.pop() + if parts: + return ", ".join(parts) + shown = [part.strip() for part in place.get("display_name", "").split(",") if part.strip()] + return ", ".join(shown[:2]) or None + + +async def _wait_for_nominatim_slot() -> None: + global _nominatim_next_slot + now = asyncio.get_running_loop().time() + slot = max(now, _nominatim_next_slot) + _nominatim_next_slot = slot + _NOMINATIM_MIN_INTERVAL_S + if slot > now: + await asyncio.sleep(slot - now) async def _nominatim_lookup( @@ -924,18 +972,21 @@ async def _nominatim_lookup( lat: float, lon: float, user_agent: str, -) -> str | None: +) -> dict[str, str] | None: """Reverse-geocode a coordinate pair via Nominatim. - Returns a human-readable label or ``None`` if the lookup failed or - the response had no useful address. Never raises - geocoding is - best-effort and a failure must never stop the slideshow. + Returns the cached address parts (see :func:`_nominatim_place`) or + ``None`` if the lookup failed or the response had no useful address. + Never raises - geocoding is best-effort and a failure must never stop + the slideshow. """ params = { "format": "jsonv2", "lat": f"{lat:.5f}", "lon": f"{lon:.5f}", - "zoom": "10", # Roughly city-level - we don't need street precision. + # Neighbourhood level: coarser levels can return an administrative + # area such as a parish instead of the town. + "zoom": "14", "addressdetails": "1", } headers = { @@ -969,7 +1020,7 @@ async def _nominatim_lookup( _LOGGER.debug("Nominatim lookup failed (%s, %s): %s", lat, lon, err) return None - return _format_nominatim_location(payload) + return _nominatim_place(payload) def _read_manifest_version(integration_dir: Path) -> str: @@ -1061,6 +1112,7 @@ def _merge_prior_enrichment( item.longitude = prev.longitude if prev.location and not item.location: item.location = prev.location + item.location_geocoded = prev.location_geocoded if prev.description and not item.description: item.description = prev.description if prev.faces is not None and item.faces is None: @@ -1126,15 +1178,19 @@ class AlbumCoordinator(DataUpdateCoordinator): self._items_cache_loaded: bool = False # Reverse-geocode cache. Coordinates rounded to ~100 m via - # ``_geocode_cache_key``; mapping value -> label string. Shared - # across all items in the album, persisted independently of the - # items cache so changes to one don't invalidate the other. + # ``_geocode_cache_key``; mapping value -> Nominatim address parts + # (see ``_nominatim_place``). Shared across all items in the album, + # persisted independently of the items cache so changes to one don't + # invalidate the other. self._geocode_cache_store: Store = Store( hass, self._GEOCODE_CACHE_VERSION, f"{DOMAIN}.{entry.entry_id}.geocode", ) - self._geocode_cache: dict[str, str] = {} + self._geocode_cache: dict[str, dict[str, str]] = {} + # Finished labels from the older label-only cache, kept until each + # coordinate has been looked up again. + self._geocode_legacy: dict[str, str] = {} self._geocode_cache_loaded: bool = False # Per-coordinator User-Agent so OSM operators can track us if we # ever misbehave. Resolved lazily because __init__ is sync. @@ -1224,16 +1280,28 @@ class AlbumCoordinator(DataUpdateCoordinator): item.latitude = previous.latitude item.longitude = previous.longitude item.location = previous.location + item.location_geocoded = previous.location_geocoded item.google_location_scanned = True self._apply_google_location_privacy(items) + await self._relabel_from_place_cache(items) self._schedule_enrichment(data) elif self.provider in ENRICHING_PROVIDERS and items: # Carry forward EXIF/geocode metadata for items we've already - # scanned this session; new items get filled in by the - # background worker below. - prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None + # scanned, this session or (after a restart) in the items cache + # on disk; new items get filled in by the background worker + # below. Session items come last so they win over the cache. + prior_items = list((self.data or {}).get("items") or []) if isinstance(self.data, dict) else [] + if not self._items_cache_loaded: + cached = await self._load_cached_items() + prior_items = (cached or {}).get("items", []) + prior_items + self._items_cache_loaded = True if prior_items: _merge_prior_enrichment(items, prior_items) + _LOGGER.debug( + "Album cache: %d of %d items already have their details", + sum(1 for it in items if not self._needs_enrichment(it)), len(items), + ) + await self._relabel_from_place_cache(items) self._schedule_enrichment(data) if items: @@ -1294,9 +1362,71 @@ class AlbumCoordinator(DataUpdateCoordinator): item.latitude = None item.longitude = None item.location = None + item.location_geocoded = False item.google_location_scanned = False elif not self.google_reverse_geocode_enabled: item.location = None + item.location_geocoded = False + + @property + def reverse_geocode_enabled(self) -> bool: + if getattr(self, "provider", None) == PROVIDER_GOOGLE_SHARED: + return self.google_reverse_geocode_enabled + options = getattr(getattr(self, "entry", None), "options", None) or {} + return bool(options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE)) + + @property + def _hidden_home_country(self) -> str | None: + """Country code to leave out of place labels, if the user asked for that.""" + options = getattr(getattr(self, "entry", None), "options", None) or {} + if not options.get(CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY): + return None + return getattr(getattr(self.hass, "config", None), "country", None) or None + + def _needs_geocode_lookup(self, item: MediaItem) -> bool: + """True for items with GPS whose place isn't cached yet.""" + if item.latitude is None or item.longitude is None: + return False + if item.location and not item.location_geocoded: + return False # The provider's own label. + return _geocode_cache_key(item.latitude, item.longitude) not in self._geocode_cache + + def _label_items_from_geocode_cache(self, items: list[MediaItem]) -> int: + """Set OpenStreetMap labels from cached places; return how many changed. + + Labels from the older label-only cache are adopted as ours, so they + stay visible until a new lookup replaces them. + """ + home = self._hidden_home_country + changed = 0 + for item in items: + if item.latitude is None or item.longitude is None: + continue + key = _geocode_cache_key(item.latitude, item.longitude) + legacy = self._geocode_legacy.get(key) + if item.location and not item.location_geocoded: + if item.location != legacy: + continue + item.location_geocoded = True + place = self._geocode_cache.get(key) + if place is None: + if legacy and not item.location: + item.location, item.location_geocoded = legacy, True + changed += 1 + continue + label = _format_place(place, home) + if label != item.location or not item.location_geocoded: + item.location, item.location_geocoded = label, True + changed += 1 + return changed + + async def _relabel_from_place_cache(self, items: list[MediaItem]) -> None: + """Apply current label options to known places without new lookups.""" + if self.reverse_geocode_enabled and any( + it.latitude is not None and it.longitude is not None for it in items + ): + await self._ensure_geocode_cache_loaded() + self._label_items_from_geocode_cache(items) def _needs_enrichment(self, item: MediaItem) -> bool: if self.provider == PROVIDER_GOOGLE_SHARED: @@ -1313,11 +1443,8 @@ class AlbumCoordinator(DataUpdateCoordinator): unscanned = [it for it in items if self._needs_enrichment(it)] # Providers that decrypt/return GPS inline (Ente) have nothing to scan # but still need the coordinates turned into a place label. - needs_geocode = ( - self.provider != PROVIDER_GOOGLE_SHARED or self.google_reverse_geocode_enabled - ) and any( - it.latitude is not None and it.longitude is not None and not it.location - for it in items + needs_geocode = self.reverse_geocode_enabled and any( + self._needs_geocode_lookup(it) for it in items ) if not unscanned and not needs_geocode: # Even with nothing to do, mark the phase as ``done`` so the @@ -1330,7 +1457,12 @@ class AlbumCoordinator(DataUpdateCoordinator): "geocode_total": 0, "geocode_done": 0, } + _LOGGER.debug("Album enrichment: all %d items have their details", len(items)) return + _LOGGER.debug( + "Album enrichment: starting background pass for %d of %d items%s", + len(unscanned), len(items), " plus place names" if needs_geocode else "", + ) self._enrich_progress = { "phase": "exif", "exif_total": len(items), @@ -1351,14 +1483,17 @@ class AlbumCoordinator(DataUpdateCoordinator): _LOGGER.debug("Album cache: failed to load (%s)", err) return None if not isinstance(payload, dict): + _LOGGER.debug("Album cache: nothing saved yet") return None cached_source = payload.get("immich_source") if cached_source is not None and cached_source != getattr(self, "_immich_cache_source", None): + _LOGGER.debug("Album cache: ignoring saved items, the Immich source changed") return None raw_items = payload.get("items") if not isinstance(raw_items, list): + _LOGGER.debug("Album cache: ignoring saved data without an item list") return None items: list[MediaItem] = [] @@ -1390,10 +1525,15 @@ class AlbumCoordinator(DataUpdateCoordinator): if key in CAMERA_METADATA_FIELDS } if isinstance(raw.get("camera_metadata"), dict) else None, google_location_scanned=bool(raw.get("google_location_scanned", False)), + location_geocoded=bool(raw.get("location_geocoded", False)), )) except Exception: continue + _LOGGER.debug( + "Album cache: loaded %d saved items (%d skipped as unreadable)", + len(items), len(raw_items) - len(items), + ) self._apply_google_location_privacy(items) return { "title": payload.get("title"), @@ -1429,6 +1569,7 @@ class AlbumCoordinator(DataUpdateCoordinator): "face_scanned": it.face_scanned, "camera_metadata": it.camera_metadata, "google_location_scanned": it.google_location_scanned, + "location_geocoded": it.location_geocoded, } for it in items ], @@ -1439,6 +1580,8 @@ class AlbumCoordinator(DataUpdateCoordinator): await self._items_cache_store.async_save(payload) except Exception as err: # pragma: no cover - storage layer _LOGGER.debug("Album cache: failed to save (%s)", err) + else: + _LOGGER.debug("Album cache: saved %d items", len(items)) async def _update_local_folder(self) -> dict[str, Any]: if not self.local_path: @@ -2496,6 +2639,7 @@ class AlbumCoordinator(DataUpdateCoordinator): item.longitude = info["longitude"] if "location" in info: item.location = info["location"] + item.location_geocoded = False if "description" in info: item.description = info["description"] item.exif_scanned = True @@ -2577,6 +2721,7 @@ class AlbumCoordinator(DataUpdateCoordinator): else: item.latitude = item.longitude = None item.location = None + item.location_geocoded = False item.google_location_scanned = True else: if keys is None: @@ -2650,6 +2795,7 @@ class AlbumCoordinator(DataUpdateCoordinator): item.longitude = meta["longitude"] if "location" in meta: item.location = meta["location"] + item.location_geocoded = False item.exif_scanned = True async def _enrich_items_background(self, data: dict[str, Any]) -> None: @@ -2786,74 +2932,57 @@ class AlbumCoordinator(DataUpdateCoordinator): self._enrich_progress["phase"] = "done" async def _geocode_items_background(self, data: dict[str, Any]) -> None: - """Reverse-geocode items with EXIF GPS but no location label yet. + """Label items with GPS from the place cache, looking up new places. Honours the ``reverse_geocode`` option (default on) so privacy- conscious users can disable the Nominatim calls without losing - the GPS coordinates themselves. Successful labels are written - back into the items in-place and persisted via the items cache. + the GPS coordinates themselves. Labels are written back into the + items in-place and persisted via the items cache. """ - if getattr(self, "provider", None) == PROVIDER_GOOGLE_SHARED and not self.google_reverse_geocode_enabled: - return - if not bool( - self.entry.options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE) - if hasattr(self.entry, "options") and self.entry.options is not None - else DEFAULT_REVERSE_GEOCODE - ): + if not self.reverse_geocode_enabled: _LOGGER.debug("Reverse geocode disabled via options; skipping") return items: list[MediaItem] = data.get("items") or [] - candidates: list[MediaItem] = [ - it - for it in items - if it.latitude is not None - and it.longitude is not None - and not it.location - ] - if not candidates: + if not any(it.latitude is not None and it.longitude is not None for it in items): return await self._ensure_geocode_cache_loaded() + if self._label_items_from_geocode_cache(items): + self.async_set_updated_data(data) + + pending: dict[str, list[MediaItem]] = {} + for item in items: + if self._needs_geocode_lookup(item): + key = _geocode_cache_key(item.latitude, item.longitude) + pending.setdefault(key, []).append(item) + if not pending: + return self._enrich_progress["phase"] = "geocoding" - self._enrich_progress["geocode_total"] = len(candidates) + self._enrich_progress["geocode_total"] = sum(len(group) for group in pending.values()) self._enrich_progress["geocode_done"] = 0 session = async_get_clientsession(self.hass) user_agent = await self._async_user_agent() - - # Last network call wall-time; used to throttle Nominatim to the - # 1 req/sec policy without sleeping for cached lookups. - last_call: float = 0.0 - loop = asyncio.get_event_loop() + home = self._hidden_home_country unsaved = 0 try: - for item in candidates: - key = _geocode_cache_key(item.latitude, item.longitude) - cached_label = self._geocode_cache.get(key) - if cached_label: - item.location = cached_label - self._enrich_progress["geocode_done"] += 1 - continue - - # Respect the 1 req/sec Nominatim usage policy. - elapsed = loop.time() - last_call - if elapsed < _NOMINATIM_MIN_INTERVAL_S: - await asyncio.sleep(_NOMINATIM_MIN_INTERVAL_S - elapsed) - - label = await _nominatim_lookup( - session, item.latitude, item.longitude, user_agent + for key, group in pending.items(): + await _wait_for_nominatim_slot() + place = await _nominatim_lookup( + session, group[0].latitude, group[0].longitude, user_agent ) - last_call = loop.time() - if label: - self._geocode_cache[key] = label - item.location = label + if place: + self._geocode_cache[key] = place + label = _format_place(place, home) + for item in group: + item.location, item.location_geocoded = label, True unsaved += 1 - self._enrich_progress["geocode_done"] += 1 + self._enrich_progress["geocode_done"] += len(group) if unsaved >= _GEOCODE_BATCH_SAVE: unsaved = 0 @@ -2883,18 +3012,28 @@ class AlbumCoordinator(DataUpdateCoordinator): if isinstance(payload, dict): entries = payload.get("entries") if isinstance(entries, dict): - self._geocode_cache = { - str(k): str(v) - for k, v in entries.items() - if isinstance(v, str) and v - } + for key, value in entries.items(): + if isinstance(value, dict): + place = { + k: v for k, v in value.items() + if k in _PLACE_KEYS and isinstance(v, str) and v + } + if place: + self._geocode_cache[str(key)] = place + elif isinstance(value, str) and value: + self._geocode_legacy[str(key)] = value self._geocode_cache_loaded = True async def _save_geocode_cache(self) -> None: + # Old labels stay until their coordinate is looked up again, so an + # unfinished upgrade still has them after a restart. + entries: dict[str, Any] = { + key: label for key, label in self._geocode_legacy.items() + if key not in self._geocode_cache + } + entries.update(self._geocode_cache) try: - await self._geocode_cache_store.async_save( - {"entries": self._geocode_cache} - ) + await self._geocode_cache_store.async_save({"entries": entries}) except Exception as err: # pragma: no cover - storage layer _LOGGER.debug("Geocode cache: failed to save (%s)", err) diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index 3e3a3ab2..dab734e4 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "requirements": ["PyNaCl>=1.5.0"], - "version": "1.14.0" + "version": "1.15.0" } diff --git a/custom_components/album_slideshow/services.yaml b/custom_components/album_slideshow/services.yaml index c2f1fc4d..85f640a5 100644 --- a/custom_components/album_slideshow/services.yaml +++ b/custom_components/album_slideshow/services.yaml @@ -99,6 +99,13 @@ next_slide: required: true selector: text: + frame_id: + name: Displayed frame ID + description: Optional frame_id of the slide on screen. When it is still in the navigation buffer, the step starts from that slide. + selector: + number: + min: 0 + mode: box previous_slide: name: Previous slide @@ -110,6 +117,13 @@ previous_slide: required: true selector: text: + frame_id: + name: Displayed frame ID + description: Optional frame_id of the slide on screen. When it is still in the navigation buffer, the step starts from that slide. + selector: + number: + min: 0 + mode: box refresh_album: name: Refresh album diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index 97b319dc..492900dd 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -203,14 +203,16 @@ "data": { "google_metadata": "Fetch photo metadata (experimental)", "google_location": "Read original photo GPS (opt-in)", - "reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)" + "reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)", + "hide_home_country": "Hide the country for places in your home country" } }, "init": { "title": "Location & privacy options", "description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.", "data": { - "reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap" + "reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap", + "hide_home_country": "Hide the country for places in your home country" } }, "immich_select": { @@ -223,7 +225,8 @@ "favorites": "Include favorites", "immich_filter": "Extra search filter (JSON, optional)", "immich_image_size": "Image quality", - "reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap" + "reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap", + "hide_home_country": "Hide the country for places in your home country" } }, "connection": { diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index 97b319dc..492900dd 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -203,14 +203,16 @@ "data": { "google_metadata": "Fetch photo metadata (experimental)", "google_location": "Read original photo GPS (opt-in)", - "reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)" + "reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)", + "hide_home_country": "Hide the country for places in your home country" } }, "init": { "title": "Location & privacy options", "description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.", "data": { - "reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap" + "reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap", + "hide_home_country": "Hide the country for places in your home country" } }, "immich_select": { @@ -223,7 +225,8 @@ "favorites": "Include favorites", "immich_filter": "Extra search filter (JSON, optional)", "immich_image_size": "Image quality", - "reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap" + "reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap", + "hide_home_country": "Hide the country for places in your home country" } }, "connection": { diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index e94b4169..0bdf2473 100644 --- a/custom_components/album_slideshow/www/album-slideshow-card.js +++ b/custom_components/album_slideshow/www/album-slideshow-card.js @@ -26,7 +26,7 @@ * tap_action: none # none | more-info */ -const VERSION = "1.14.0"; +const VERSION = "1.15.0"; const ANIMATED_TRANSITIONS = [ "fade", @@ -87,6 +87,49 @@ const CAPTION_WEIGHT_MAP = { semibold: 600, bold: 700, }; +const WEATHER_DISPLAYS = ["condition_temperature", "icon_temperature", "temperature"]; +const WEATHER_ICON_STYLES = ["yr", "meteocons", "home_assistant"]; +// Icon files in www/weather (MIT, see the LICENSE there) per HA weather condition. Yr has +// no wind or warning symbol, so those come from Meteocons. +const WEATHER_ICON_SETS = { + yr: { + "clear-night": "yr/clearsky_night", + cloudy: "yr/cloudy", + exceptional: "meteocons/code-red", + fog: "yr/fog", + hail: "yr/heavysleet", + lightning: "yr/lightrainandthunder", + "lightning-rainy": "yr/rainandthunder", + partlycloudy: "yr/partlycloudy_day", + "partlycloudy-night": "yr/partlycloudy_night", + pouring: "yr/heavyrain", + rainy: "yr/rain", + snowy: "yr/snow", + "snowy-rainy": "yr/sleet", + sunny: "yr/clearsky_day", + windy: "meteocons/wind", + "windy-variant": "meteocons/wind", + }, + meteocons: { + "clear-night": "meteocons/clear-night", + cloudy: "meteocons/cloudy", + exceptional: "meteocons/code-red", + fog: "meteocons/fog", + hail: "meteocons/hail", + lightning: "meteocons/thunderstorms", + "lightning-rainy": "meteocons/thunderstorms-rain", + partlycloudy: "meteocons/partly-cloudy-day", + "partlycloudy-night": "meteocons/partly-cloudy-night", + pouring: "meteocons/extreme-rain", + rainy: "meteocons/rain", + snowy: "meteocons/snow", + "snowy-rainy": "meteocons/sleet", + sunny: "meteocons/clear-day", + windy: "meteocons/wind", + "windy-variant": "meteocons/wind", + }, +}; +const WEATHER_ICON_PATH = "/album_slideshow_static/weather"; const DATE_FORMAT_PRESETS = { full: { year: "numeric", month: "long", day: "numeric" }, long: { year: "numeric", month: "long", day: "numeric" }, @@ -216,8 +259,10 @@ class PhotoControls { async _navigate(service) { if (this._busy || !this._state.entryId) return false; const entryId = this._state.entryId; + // Step from the slide on screen, which may be held behind the camera. + const data = Number.isInteger(this._state.frameId) ? { frame_id: this._state.frameId } : {}; this._onNavigate?.("start", entryId); - const success = await this._run(service, {}, entryId); + const success = await this._run(service, data, entryId); this._onNavigate?.(success ? "complete" : "failed", entryId); return success; } @@ -684,6 +729,8 @@ function createAlbumSlideshowCardClass(Base) { time_seconds: raw.time_seconds === true, weather_entity: typeof raw.weather_entity === "string" && /^(weather|sensor)\.[a-z0-9_]+$/.test(raw.weather_entity.trim()) ? raw.weather_entity.trim() : null, + weather_display: WEATHER_DISPLAYS.includes(raw.weather_display) ? raw.weather_display : "condition_temperature", + weather_icons: WEATHER_ICON_STYLES.includes(raw.weather_icons) ? raw.weather_icons : "yr", color, font_size: fontSize, font_weight: fontWeight, @@ -727,7 +774,9 @@ function createAlbumSlideshowCardClass(Base) { if (!this._rendered) return; this._maybeSwap(); if (this._captionData && this._captionConfigs().some((cap) => cap.show.includes("weather") && cap.weather_entity && - (previous?.states?.[cap.weather_entity] !== hass.states?.[cap.weather_entity] || previous?.locale !== hass.locale))) { + (previous?.states?.[cap.weather_entity] !== hass.states?.[cap.weather_entity] || previous?.locale !== hass.locale || + (cap.weather_display === "icon_temperature" && + previous?.states?.["sun.sun"]?.state !== hass.states?.["sun.sun"]?.state)))) { this._renderCaptions(this._captionData, false); } } @@ -868,6 +917,20 @@ function createAlbumSlideshowCardClass(Base) { 0 1px 2px rgba(0, 0, 0, 0.9), 0 1px 6px rgba(0, 0, 0, 0.55); } + .cap-icon { display: inline-block; width: 1.8em; height: 1.8em; margin: 0 0.12em 0 -0.1em; vertical-align: middle; } + .cap-icon svg { display: block; width: 100%; height: 100%; } + .cap-shadow .cap-icon { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6)); } + .cap-icon .sun { fill: var(--weather-icon-sun-color, #fdd93c); } + .cap-icon .moon { fill: var(--weather-icon-moon-color, #fcf497); } + .cap-icon .cloud-back { fill: var(--weather-icon-cloud-back-color, #d4d4d4); } + .cap-icon .cloud-front { fill: var(--weather-icon-cloud-front-color, #f9f9f9); } + .cap-icon .rain { fill: var(--weather-icon-rain-color, #30b3ff); } + .cap-icon .snow { + fill: var(--weather-icon-snow-color, #f9f9f9); + stroke: var(--weather-icon-snow-stroke-color, #d4d4d4); + stroke-width: 1; + paint-order: stroke; + } ${this._transitionStyles()} @@ -1112,6 +1175,7 @@ function createAlbumSlideshowCardClass(Base) { this._photoControls?.update({ entryId: attrs.entry_id, photoIds: this._displayedPhotoIds, + frameId: this._displayedFrameId, orientation: this._photoOrientation, hiddenCount: attrs.hidden_photo_count, canUndo: attrs.undo_hide_available, @@ -1427,12 +1491,27 @@ function createAlbumSlideshowCardClass(Base) { ? this._hass.formatEntityState(entity) : isWeather ? humanizeOption(entity.state.replace("partlycloudy", "partly cloudy")) : [entity.state, attributes.unit_of_measurement].filter(Boolean).join(" "); - if (!isWeather || typeof attributes.temperature !== "number" || !Number.isFinite(attributes.temperature)) return state; - const temperature = this._hass.formatEntityAttributeValue - ? this._hass.formatEntityAttributeValue(entity, "temperature") - : [new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(attributes.temperature), - attributes.temperature_unit || this._hass.config?.unit_system?.temperature].filter(Boolean).join(" "); - return `${state}, ${temperature}`; + if (!isWeather) return state; + let temperature = ""; + if (typeof attributes.temperature === "number" && Number.isFinite(attributes.temperature)) { + temperature = this._hass.formatEntityAttributeValue + ? this._hass.formatEntityAttributeValue(entity, "temperature") + : [new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(attributes.temperature), + attributes.temperature_unit || this._hass.config?.unit_system?.temperature].filter(Boolean).join(" "); + } + if (cap.weather_display === "temperature") return temperature; + if (cap.weather_display === "icon_temperature") { + const night = entity.state === "partlycloudy" && this._hass.states["sun.sun"]?.state === "below_horizon"; + if (cap.weather_icons === "home_assistant") { + const svg = homeAssistantWeatherSvg(entity.state, night); + if (svg) return { svg, label: state, text: temperature }; + } else { + const icons = WEATHER_ICON_SETS[cap.weather_icons] || WEATHER_ICON_SETS.yr; + const icon = icons[night ? "partlycloudy-night" : entity.state]; + if (icon) return { icon, label: state, text: temperature }; + } + } + return temperature ? `${state}, ${temperature}` : state; } /** Build one positioned caption block. ``orientation`` is ``null`` for a @@ -1497,7 +1576,19 @@ function createAlbumSlideshowCardClass(Base) { for (const line of lines) { const el = document.createElement("div"); el.className = "cap-line"; - el.textContent = line; + if (typeof line === "string") { + el.textContent = line; + } else { + const icon = document.createElement(line.svg ? "span" : "img"); + icon.className = "cap-icon"; + if (line.svg) { + icon.innerHTML = line.svg; + } else { + icon.alt = line.label; + icon.src = `${WEATHER_ICON_PATH}/${line.icon}.svg?v=${VERSION}`; + } + el.append(icon, line.text); + } box.appendChild(el); } region._captionStack.appendChild(box); @@ -1739,6 +1830,18 @@ const CAPTION_WEIGHT_OPTIONS = [ { value: "bold", label: "Bold" }, ]; +const WEATHER_DISPLAY_OPTIONS = [ + { value: "condition_temperature", label: "Condition and temperature" }, + { value: "icon_temperature", label: "Icon and temperature" }, + { value: "temperature", label: "Temperature only" }, +]; + +const WEATHER_ICON_OPTIONS = [ + { value: "yr", label: "Yr" }, + { value: "meteocons", label: "Meteocons" }, + { value: "home_assistant", label: "Home Assistant" }, +]; + const CAPTION_DATE_FORMAT_OPTIONS = [ { value: "medium", label: "Medium (Aug 16, 2014)" }, { value: "full", label: "Full (August 16, 2014)" }, @@ -1772,6 +1875,8 @@ const CAPTION_DEFAULTS = { date_format: "medium", time_format: "auto", time_seconds: false, + weather_display: "condition_temperature", + weather_icons: "yr", color: "#ffffff", font_size: "14px", font_weight: "medium", @@ -2197,6 +2302,8 @@ function createAlbumSlideshowCardEditorClass(Base) { caption_time_format: c.time_format || CAPTION_DEFAULTS.time_format, caption_time_seconds: c.time_seconds === true, caption_weather_entity: c.weather_entity || "", + caption_weather_display: c.weather_display || CAPTION_DEFAULTS.weather_display, + caption_weather_icons: c.weather_icons || CAPTION_DEFAULTS.weather_icons, caption_color: c.color || CAPTION_DEFAULTS.color, caption_font_size: c.font_size || CAPTION_DEFAULTS.font_size, caption_font_weight: c.font_weight || CAPTION_DEFAULTS.font_weight, @@ -2221,6 +2328,16 @@ function createAlbumSlideshowCardEditorClass(Base) { if (data.caption_show.includes("weather")) { schema.push({ name: "caption_weather_entity", required: true, selector: { entity: { filter: [{ domain: ["weather", "sensor"] }] } } }); + if (data.caption_weather_entity.startsWith("weather.")) { + schema.push({ name: "caption_weather_display", selector: { select: { + mode: "dropdown", options: WEATHER_DISPLAY_OPTIONS, + } } }); + if (data.caption_weather_display === "icon_temperature") { + schema.push({ name: "caption_weather_icons", selector: { select: { + mode: "dropdown", options: WEATHER_ICON_OPTIONS, + } } }); + } + } } if (data.caption_show.includes("date")) { schema.push({ name: "caption_date_format", selector: { select: { @@ -2305,7 +2422,7 @@ function createAlbumSlideshowCardEditorClass(Base) { if (!Array.isArray(show)) show = typeof show === "string" ? show.split(/[,\s]+/) : []; const caption = { show: [...new Set(show.filter((field) => CAPTION_FIELDS.includes(field)))] }; if (data.caption_enabled === false) caption.enabled = false; - for (const field of ["position", "date_format", "time_format", "color", "font_size", "font_weight"]) { + for (const field of ["position", "date_format", "time_format", "weather_display", "weather_icons", "color", "font_size", "font_weight"]) { const value = String(data[`caption_${field}`] || CAPTION_DEFAULTS[field]).trim(); if (value && value !== CAPTION_DEFAULTS[field]) caption[field] = value; } @@ -2561,6 +2678,8 @@ function createAlbumSlideshowCardEditorClass(Base) { caption_time_format: "Time format", caption_time_seconds: "Show seconds", caption_weather_entity: "Weather source", + caption_weather_display: "Weather display", + caption_weather_icons: "Weather icons", caption_color: "Text color", caption_font_size: "Font size", caption_font_weight: "Font weight", @@ -2883,6 +3002,58 @@ function createAlbumSlideshowCardEditorClass(Base) { }; } +// Home Assistant's own weather icon shapes, adapted from the frontend's src/data/weather.ts +// (Apache-2.0, see www/weather/LICENSE). Colors follow the --weather-icon-* theme variables. +const HA_WEATHER_PATHS = { + sun: "m 14.39303,8.4033507 c 0,3.3114723 -2.684145,5.9956173 -5.9956169,5.9956173 -3.3114716,0 -5.9956168,-2.684145 -5.9956168,-5.9956173 0,-3.311471 2.6841452,-5.995617 5.9956168,-5.995617 3.3114719,0 5.9956169,2.684146 5.9956169,5.995617", + moon: "m 13.502891,11.382935 c -1.011285,1.859223 -2.976664,3.121381 -5.2405751,3.121381 -3.289929,0 -5.953329,-2.663833 -5.953329,-5.9537625 0,-2.263911 1.261724,-4.228856 3.120948,-5.240575 -0.452782,0.842738 -0.712753,1.806363 -0.712753,2.832381 0,3.289928 2.663833,5.9533275 5.9533291,5.9533275 1.026017,0 1.989641,-0.259969 2.83238,-0.712752", + partly: "m14.981 4.2112c0 1.9244-1.56 3.4844-3.484 3.4844-1.9244 0-3.4844-1.56-3.4844-3.4844s1.56-3.484 3.4844-3.484c1.924 0 3.484 1.5596 3.484 3.484", + cloudBack: "m3.8863 5.035c-0.54892 0.16898-1.04 0.46637-1.4372 0.8636-0.63077 0.63041-1.0206 1.4933-1.0206 2.455 0 1.9251 1.5589 3.4682 3.4837 3.4682h6.9688c1.9251 0 3.484-1.5981 3.484-3.5232 0-1.9251-1.5589-3.5232-3.484-3.5232h-1.0834c-0.25294-1.6916-1.6986-2.9083-3.4463-2.9083-1.7995 0-3.2805 1.4153-3.465 3.1679", + cloudFront: "m4.1996 7.6995c-0.33902 0.10407-0.64276 0.28787-0.88794 0.5334-0.39017 0.38982-0.63147 0.92322-0.63147 1.5176 0 1.1896 0.96414 2.1431 2.1537 2.1431h4.3071c1.1896 0 2.153-0.98742 2.153-2.1777 0-1.1896-0.96344-2.1777-2.153-2.1777h-0.66992c-0.15593-1.0449-1.0499-1.7974-2.1297-1.7974-1.112 0-2.0274 0.87524-2.1417 1.9586", + rain: [ + "m5.2852 14.734c-0.22401 0.24765-0.57115 0.2988-0.77505 0.11395-0.20391-0.1845-0.18732-0.53481 0.036689-0.78281 0.14817-0.16298 0.59126-0.32914 0.87559-0.42369 0.12453-0.04092 0.22684 0.05186 0.19791 0.17956-0.065617 0.2921-0.18732 0.74965-0.33514 0.91299", + "m11.257 14.163c-0.22437 0.24765-0.57115 0.2988-0.77505 0.11395-0.2039-0.1845-0.18768-0.53481 0.03669-0.78281 0.14817-0.16298 0.59126-0.32914 0.8756-0.42369 0.12453-0.04092 0.22684 0.05186 0.19791 0.17956-0.06562 0.2921-0.18732 0.74965-0.33514 0.91299", + "m8.432 15.878c-0.15452 0.17039-0.3937 0.20567-0.53446 0.07867-0.14041-0.12735-0.12876-0.36865 0.025753-0.53975 0.10195-0.11218 0.40711-0.22684 0.60325-0.29175 0.085725-0.02858 0.15628 0.03563 0.13652 0.12382-0.045508 0.20108-0.12912 0.51647-0.23107 0.629", + "m7.9991 14.118c-0.19226 0.21237-0.49001 0.25612-0.66499 0.09737-0.17462-0.15804-0.16051-0.45861 0.03175-0.67098 0.12665-0.14005 0.50729-0.28293 0.75071-0.36336 0.10689-0.03563 0.19473 0.0441 0.17004 0.15346-0.056092 0.25082-0.16051 0.64347-0.28751 0.78352", + ], + pouring: [ + "m10.648 16.448c-0.19226 0.21449-0.49001 0.25894-0.66499 0.09878-0.17498-0.16016-0.16087-0.4639 0.03175-0.67874 0.12665-0.14146 0.50694-0.2854 0.75071-0.36724 0.10689-0.03563 0.19473 0.0448 0.17004 0.15558-0.05645 0.25365-0.16051 0.65017-0.28751 0.79163", + "m5.9383 16.658c-0.22437 0.25012-0.5715 0.30162-0.77505 0.11501-0.20391-0.18627-0.18768-0.54046 0.036689-0.79093 0.14817-0.1651 0.59126-0.33267 0.87559-0.42827 0.12418-0.04127 0.22648 0.05221 0.19791 0.18168-0.065617 0.29528-0.18732 0.75741-0.33514 0.92251", + ], + wind: [ + "m 13.59616,15.30968 c 0,0 -0.09137,-0.0071 -0.250472,-0.0187 -0.158045,-0.01235 -0.381353,-0.02893 -0.64382,-0.05715 -0.262466,-0.02716 -0.564444,-0.06385 -0.877358,-0.124531 -0.156986,-0.03034 -0.315383,-0.06844 -0.473781,-0.111478 -0.157691,-0.04551 -0.313266,-0.09842 -0.463902,-0.161219 l -0.267406,-0.0949 c -0.09984,-0.02646 -0.205669,-0.04904 -0.305153,-0.06738 -0.193322,-0.02716 -0.3838218,-0.03316 -0.5640912,-0.02011 -0.3626556,0.02611 -0.6847417,0.119239 -0.94615,0.226483 -0.2617611,0.108656 -0.4642556,0.230364 -0.600075,0.324203 -0.1358195,0.09419 -0.2049639,0.160514 -0.2049639,0.160514 0,0 0.089958,-0.01623 0.24765,-0.04445 0.1559278,-0.02575 0.3764139,-0.06174 0.6367639,-0.08714 0.2596444,-0.02646 0.5591527,-0.0441 0.8678333,-0.02328 0.076905,0.0035 0.1538111,0.01658 0.2321278,0.02293 0.077611,0.01058 0.1534581,0.02893 0.2314221,0.04022 0.07267,0.01834 0.1397,0.03986 0.213078,0.05644 l 0.238125,0.08925 c 0.09207,0.03281 0.183444,0.07055 0.275872,0.09878 0.09243,0.0261 0.185208,0.05327 0.277636,0.07161 0.184856,0.0388 0.367947,0.06174 0.543983,0.0702 0.353131,0.01905 0.678745,-0.01341 0.951442,-0.06456 0.27305,-0.05292 0.494595,-0.123119 0.646642,-0.181681 0.152047,-0.05785 0.234597,-0.104069 0.234597,-0.104069", + "m 4.7519154,13.905801 c 0,0 0.091369,-0.0032 0.2511778,-0.0092 0.1580444,-0.0064 0.3820583,-0.01446 0.6455833,-0.03281 0.2631722,-0.01729 0.5662083,-0.04269 0.8812389,-0.09137 0.1576916,-0.02434 0.3175,-0.05609 0.4776611,-0.09384 0.1591027,-0.03951 0.3167944,-0.08643 0.4699,-0.14358 l 0.2702277,-0.08467 c 0.1008945,-0.02222 0.2074334,-0.04127 0.3072695,-0.05574 0.1943805,-0.01976 0.3848805,-0.0187 0.5651499,0.0014 0.3608917,0.03951 0.67945,0.144639 0.936625,0.261761 0.2575278,0.118534 0.4554364,0.247297 0.5873754,0.346781 0.132291,0.09913 0.198966,0.168275 0.198966,0.168275 0,0 -0.08925,-0.01976 -0.245886,-0.05397 C 9.9423347,14.087088 9.7232597,14.042988 9.4639681,14.00736 9.2057347,13.97173 8.9072848,13.94245 8.5978986,13.95162 c -0.077258,7.06e-4 -0.1541638,0.01058 -0.2328333,0.01411 -0.077964,0.0078 -0.1545166,0.02328 -0.2331861,0.03175 -0.073025,0.01588 -0.1404055,0.03422 -0.2141361,0.04798 l -0.2420055,0.08008 c -0.093486,0.02963 -0.1859139,0.06421 -0.2794,0.0889 C 7.3028516,14.23666 7.2093653,14.2603 7.116232,14.27512 6.9303181,14.30722 6.7465209,14.3231 6.5697792,14.32486 6.2166487,14.33046 5.8924459,14.28605 5.6218654,14.224318 5.3505793,14.161565 5.1318571,14.082895 4.9822793,14.01869 4.8327015,13.95519 4.7519154,13.905801 4.7519154,13.905801", + ], + snow: [ + "m 8.4319893,15.348341 c 0,0.257881 -0.209197,0.467079 -0.467078,0.467079 -0.258586,0 -0.46743,-0.209198 -0.46743,-0.467079 0,-0.258233 0.208844,-0.467431 0.46743,-0.467431 0.257881,0 0.467078,0.209198 0.467078,0.467431", + "m 11.263878,14.358553 c 0,0.364067 -0.295275,0.659694 -0.659695,0.659694 -0.364419,0 -0.6596937,-0.295627 -0.6596937,-0.659694 0,-0.364419 0.2952747,-0.659694 0.6596937,-0.659694 0.36442,0 0.659695,0.295275 0.659695,0.659694", + "m 5.3252173,13.69847 c 0,0.364419 -0.295275,0.660047 -0.659695,0.660047 -0.364067,0 -0.659694,-0.295628 -0.659694,-0.660047 0,-0.364067 0.295627,-0.659694 0.659694,-0.659694 0.36442,0 0.659695,0.295627 0.659695,0.659694", + ], + lightning: "m 9.9252695,10.935875 -1.6483986,2.341014 1.1170184,0.05929 -1.2169864,2.02141 3.0450261,-2.616159 H 9.8864918 L 10.97937,11.294651 10.700323,10.79794 h -0.508706 l -0.2663475,0.137936", +}; +const HA_CLOUDY_STATES = new Set([ + "partlycloudy", "cloudy", "fog", "windy", "windy-variant", "hail", "rainy", "snowy", + "snowy-rainy", "pouring", "lightning", "lightning-rainy", +]); +const HA_RAIN_STATES = new Set(["hail", "rainy", "pouring", "lightning-rainy"]); + +function homeAssistantWeatherSvg(state, night) { + const p = HA_WEATHER_PATHS; + const paths = []; + if (state === "sunny") paths.push(["sun", p.sun]); + if (state === "clear-night") paths.push(["moon", p.moon]); + if (state === "partlycloudy") paths.push([night ? "moon" : "sun", p.partly]); + if (HA_CLOUDY_STATES.has(state)) paths.push(["cloud-back", p.cloudBack], ["cloud-front", p.cloudFront]); + if (HA_RAIN_STATES.has(state)) paths.push(...p.rain.map((d) => ["rain", d])); + if (state === "pouring") paths.push(...p.pouring.map((d) => ["rain", d])); + if (state === "windy" || state === "windy-variant") paths.push(...p.wind.map((d) => ["cloud-back", d])); + if (state === "snowy" || state === "snowy-rainy") paths.push(...p.snow.map((d) => ["snow", d])); + if (state === "lightning" || state === "lightning-rainy") paths.push(["sun", p.lightning]); + if (!paths.length) return null; + return `${ + paths.map(([cls, d]) => ``).join("")}`; +} + /** * Register both elements so they survive the * ``@webcomponents/scoped-custom-element-registry`` polyfill that diff --git a/custom_components/album_slideshow/www/weather/LICENSE-home-assistant.md b/custom_components/album_slideshow/www/weather/LICENSE-home-assistant.md new file mode 100644 index 00000000..b62a9b5f --- /dev/null +++ b/custom_components/album_slideshow/www/weather/LICENSE-home-assistant.md @@ -0,0 +1,194 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/custom_components/ha_mcp_tools/__init__.py b/custom_components/ha_mcp_tools/__init__.py index 46280c19..3d7bdbc0 100644 --- a/custom_components/ha_mcp_tools/__init__.py +++ b/custom_components/ha_mcp_tools/__init__.py @@ -1274,7 +1274,7 @@ def _mask_secrets_content(content: str) -> str: return "\n".join(f'{key}: "[MASKED]"' for key in parsed) except YAMLError: return "# secrets.yaml could not be parsed — content withheld to avoid leaking secrets" - except Exception: + except Exception: # noqa: BLE001 return "# secrets.yaml could not be masked — content withheld to avoid leaking secrets" @@ -1572,7 +1572,7 @@ async def _run_config_check(hass: HomeAssistant, rel_path: str) -> dict[str, Any """ try: errors = await async_check_ha_config_file(hass) - except Exception as check_err: + except Exception as check_err: # noqa: BLE001 _LOGGER.warning( "Config check unavailable after editing %s: %s", rel_path, check_err ) @@ -2149,7 +2149,7 @@ async def _resolve_post_action( "post_action": "reload_performed", "reload_service": "frontend.reload_themes", } - except Exception as reload_err: + except Exception as reload_err: # noqa: BLE001 post_info = { "post_action": "reload_available", "reload_service": "frontend.reload_themes", @@ -3170,7 +3170,7 @@ def _build_get_caller_token_handler( # an absent one deserve the same answer. raise ValueError("the manifest carries no version") version = str(integration.version) - except Exception as exc: + except Exception as exc: # noqa: BLE001 _LOGGER.warning( "Could not read ha_mcp_tools manifest version for " "get_caller_token response: %s", @@ -3463,7 +3463,7 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b moved, failed = await hass.async_add_executor_job( _migrate_legacy_backup_dir, config_dir ) - except Exception as err: + except Exception as err: # noqa: BLE001 # Defensive: a migration failure must not block setup_entry, since # the integration's normal value (file ops, edit_yaml_config) is # unaffected by whether old backups got relocated. @@ -3506,7 +3506,7 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b title="HA MCP Tools — credential exposure (GHSA-g39v-cvjh-8fpf)", notification_id="ha_mcp_tools_ghsa_g39v_cvjh_8fpf", ) - except Exception as err: + except Exception as err: # noqa: BLE001 # Defensive: log line above is the source of truth; the # notification is best-effort UX and must not block setup. _LOGGER.warning( @@ -3516,115 +3516,60 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b err, ) - handle_list_files = _build_list_files_handler(hass) - handle_read_file = _build_read_file_handler(hass) - handle_write_file = _build_write_file_handler(hass) - handle_delete_file = _build_delete_file_handler(hass) - handle_edit_yaml_config = _build_edit_yaml_config_handler(hass) - handle_get_caller_token = _build_get_caller_token_handler(hass) - handle_get_allowed_paths = _build_get_allowed_paths_handler(hass) - handle_set_allowed_paths = _build_set_allowed_paths_handler(hass) - handle_get_extra_yaml_keys = _build_get_extra_yaml_keys_handler(hass) - handle_set_extra_yaml_keys = _build_set_extra_yaml_keys_handler(hass) - handle_list_legacy_backups = _build_list_legacy_backups_handler(hass) - handle_read_legacy_backup = _build_read_legacy_backup_handler(hass) - # Register all services with response support - hass.services.async_register( - DOMAIN, - SERVICE_EDIT_YAML_CONFIG, - handle_edit_yaml_config, - schema=SERVICE_EDIT_YAML_CONFIG_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_LIST_FILES, - handle_list_files, - schema=SERVICE_LIST_FILES_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_READ_FILE, - handle_read_file, - schema=SERVICE_READ_FILE_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_WRITE_FILE, - handle_write_file, - schema=SERVICE_WRITE_FILE_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_DELETE_FILE, - handle_delete_file, - schema=SERVICE_DELETE_FILE_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_GET_CALLER_TOKEN, - handle_get_caller_token, - schema=SERVICE_GET_CALLER_TOKEN_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_GET_ALLOWED_PATHS, - handle_get_allowed_paths, - schema=SERVICE_GET_ALLOWED_PATHS_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_ALLOWED_PATHS, - handle_set_allowed_paths, - schema=SERVICE_SET_ALLOWED_PATHS_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_GET_EXTRA_YAML_KEYS, - handle_get_extra_yaml_keys, - schema=SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_EXTRA_YAML_KEYS, - handle_set_extra_yaml_keys, - schema=SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_LIST_LEGACY_BACKUPS, - handle_list_legacy_backups, - schema=SERVICE_LIST_LEGACY_BACKUPS_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_READ_LEGACY_BACKUP, - handle_read_legacy_backup, - schema=SERVICE_READ_LEGACY_BACKUP_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + for service, schema, build_handler in ( + ( + SERVICE_EDIT_YAML_CONFIG, + SERVICE_EDIT_YAML_CONFIG_SCHEMA, + _build_edit_yaml_config_handler, + ), + (SERVICE_LIST_FILES, SERVICE_LIST_FILES_SCHEMA, _build_list_files_handler), + (SERVICE_READ_FILE, SERVICE_READ_FILE_SCHEMA, _build_read_file_handler), + (SERVICE_WRITE_FILE, SERVICE_WRITE_FILE_SCHEMA, _build_write_file_handler), + (SERVICE_DELETE_FILE, SERVICE_DELETE_FILE_SCHEMA, _build_delete_file_handler), + ( + SERVICE_GET_CALLER_TOKEN, + SERVICE_GET_CALLER_TOKEN_SCHEMA, + _build_get_caller_token_handler, + ), + ( + SERVICE_GET_ALLOWED_PATHS, + SERVICE_GET_ALLOWED_PATHS_SCHEMA, + _build_get_allowed_paths_handler, + ), + ( + SERVICE_SET_ALLOWED_PATHS, + SERVICE_SET_ALLOWED_PATHS_SCHEMA, + _build_set_allowed_paths_handler, + ), + ( + SERVICE_GET_EXTRA_YAML_KEYS, + SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA, + _build_get_extra_yaml_keys_handler, + ), + ( + SERVICE_SET_EXTRA_YAML_KEYS, + SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA, + _build_set_extra_yaml_keys_handler, + ), + ( + SERVICE_LIST_LEGACY_BACKUPS, + SERVICE_LIST_LEGACY_BACKUPS_SCHEMA, + _build_list_legacy_backups_handler, + ), + ( + SERVICE_READ_LEGACY_BACKUP, + SERVICE_READ_LEGACY_BACKUP_SCHEMA, + _build_read_legacy_backup_handler, + ), + ): + hass.services.async_register( + DOMAIN, + service, + build_handler(hass), + schema=schema, + supports_response=SupportsResponse.ONLY, + ) # Register the in-process ha_mcp_tools/* WebSocket commands (info + search) # the server calls behind a capability gate. Idempotent, and independent of @@ -3660,7 +3605,7 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b # and ``str()`` would put the literal "None" on the device. raise ValueError("the manifest carries no version") component_version = str(integration.version) - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.debug( "Could not read the component version for the tools device, using " "the compiled-in %s: %s", diff --git a/custom_components/ha_mcp_tools/config_flow.py b/custom_components/ha_mcp_tools/config_flow.py index b349afa0..e48d4ddf 100644 --- a/custom_components/ha_mcp_tools/config_flow.py +++ b/custom_components/ha_mcp_tools/config_flow.py @@ -183,7 +183,7 @@ def _fill(common: dict[str, str], key: str, /, **values: str) -> str: """ try: return common[key].format(**values) - except Exception as err: + except Exception as err: # noqa: BLE001 # Names both causes: a catalog string this caller cannot fill, or a # caller passing values the template never declared. The second is our # bug and crashes on the English constant below, so the log line has to @@ -243,14 +243,14 @@ async def _fetch_common_translations( ) -> dict[str, str]: """core ``async_get_translations(hass, language, "common")``; test seam. - Mirrors the seam in ``websocket_api`` so the lookup can be replaced in + Mirrors the seam in ``websocket_api.services`` so the lookup can be replaced in tests without reaching into Home Assistant's translation machinery. """ from homeassistant.helpers.translation import async_get_translations result = await async_get_translations(hass, language, "common", {DOMAIN}) # Any Mapping, not just dict: core returns a plain dict today, but the - # mirrored seam in ``websocket_api`` accepts a Mapping, and narrowing it + # mirrored seam in ``websocket_api.services`` accepts a Mapping, and narrowing it # here would silently discard a whole catalog on a core-internal change. if isinstance(result, Mapping): return dict(result) @@ -297,7 +297,7 @@ async def _common_strings(hass: HomeAssistant | None) -> tuple[dict[str, str], s # broad ``except`` also covers an ImportError from the function-local # core import — a permanent defect nobody would ever notice at debug. # ``exc_info`` because the traceback is the only way to tell the two - # apart. Same level the ``websocket_api`` seam this mirrors uses. + # apart. Same level the ``websocket_api.services`` seam this mirrors uses. _LOGGER.warning( "Could not load the %s options-form translations, falling back to " "English: %s", @@ -848,7 +848,7 @@ class HaMcpServerOptionsFlow(OptionsFlow): "shows the unknown-version wording", DOMAIN, ) - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.warning( "Could not read the component version for the options hint, " "showing the unknown-version wording: %s", @@ -864,7 +864,7 @@ class HaMcpServerOptionsFlow(OptionsFlow): else _installed_server_version() ) server_version = raw_version or common["version_not_installed"] - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.warning( "Could not read the server version for the options hint, showing " "the not-installed wording: %s", @@ -905,7 +905,7 @@ class HaMcpServerOptionsFlow(OptionsFlow): loaded = any( entry.state is ConfigEntryState.LOADED for entry in tools_entries ) - except Exception as err: + except Exception as err: # noqa: BLE001 # Warning, like the two version reads above: this drops the whole # tools-module paragraph from the form, which is a larger visible # loss than either of those fallbacks. @@ -953,7 +953,7 @@ class HaMcpServerOptionsFlow(OptionsFlow): if urls: listed = "\n".join(f"- {u}" for u in urls) return f"{common['connect_urls_label']}\n{listed}" - except Exception as err: + except Exception as err: # noqa: BLE001 # The hint is auxiliary display data: a resolution bug must not # take down the whole options form, but the degradation should # be visible by default - hence warning, not debug. diff --git a/custom_components/ha_mcp_tools/const.py b/custom_components/ha_mcp_tools/const.py index 99e7f8f5..434c96d5 100644 --- a/custom_components/ha_mcp_tools/const.py +++ b/custom_components/ha_mcp_tools/const.py @@ -24,8 +24,8 @@ DOMAIN = "ha_mcp_tools" # together so a manifest bump that forgets this constant (or vice-versa) fails # in CI. The # capability negotiation — not this version — gates each WS command (see -# ``websocket_api.CAPABILITIES``). -COMPONENT_VERSION = "2.2.1" +# ``websocket_api.constants.CAPABILITIES``). +COMPONENT_VERSION = "2.2.2" # Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value # means "tools" so the pre-existing services entry keeps working across the @@ -351,9 +351,9 @@ PYPI_JSON_URL = "https://pypi.org/pypi/{dist}/json" # server update until HACS delivers the component (issues #1783/#1785). # Tag-timing caveat: stable ``vX.Y.Z`` tags exist before the PyPI publish # (semantic-release pushes the tag first), but a dev ``vX.Y.Z.devN`` tag is -# only created when its draft GitHub release is published — AFTER the binary -# builds, minutes after PyPI already has the version. During that dev window -# this URL 404s and the gate deliberately fails open (the registry's +# created by publish-dev.yml's pre-release job, which runs in parallel with +# the PyPI publish, so it can briefly trail PyPI. During that dev window this +# URL 404s and the gate deliberately fails open (the registry's # skip-on-failure is the backstop on that channel). COMPONENT_MANIFEST_AT_TAG_URL = ( "https://raw.githubusercontent.com/homeassistant-ai/ha-mcp/" @@ -598,3 +598,8 @@ ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source" # nor unbind an HTTP view without a full Home Assistant restart, so both # transitions need one; see oauth_legacy.bind_legacy_views. ISSUE_LEGACY_OAUTH_RESTART = "legacy_oauth_restart" + +# How long the in-process server's listener keeps an idle keep-alive connection +# (uvicorn's default). The webhook relay drops its pooled connections sooner, or +# a request sent as the listener closes one fails with a reset (mcp_webhook). +SERVER_KEEPALIVE_SECONDS = 5 diff --git a/custom_components/ha_mcp_tools/embedded_server.py b/custom_components/ha_mcp_tools/embedded_server.py index 7b704617..45490d23 100644 --- a/custom_components/ha_mcp_tools/embedded_server.py +++ b/custom_components/ha_mcp_tools/embedded_server.py @@ -49,7 +49,6 @@ from homeassistant.core import HomeAssistant from homeassistant.requirements import ( RequirementsNotFound, async_process_requirements, - pip_kwargs, ) from homeassistant.util.package import is_virtual_env from packaging.requirements import InvalidRequirement, Requirement @@ -83,6 +82,7 @@ from .const import ( OPT_SERVER_PORT, OPT_SERVER_URL, SERVER_CONFIG_SUBDIR, + SERVER_KEEPALIVE_SECONDS, SERVER_TOKEN_CLIENT_NAME, SERVER_USER_NAME, dist_for_channel, @@ -96,6 +96,7 @@ from .dependency_diagnostics import ( requirement_forces_conflict, root_import_failure, ) +from .pip_compat import pip_kwargs if TYPE_CHECKING: from collections.abc import Callable @@ -282,7 +283,7 @@ def _install_log_filters_if_available() -> None: return try: install_sdk_log_filters() - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.warning( "Could not install MCP SDK log-noise filters; continuing without them: %s", err, @@ -1643,7 +1644,7 @@ class EmbeddedServerManager: _IMPORTING_WORKERS.discard(threading.current_thread()) _teardown_worker_loop(loop) - async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None: + async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None: # noqa: PLR0915 """Build the ha-mcp server and run it until a stop is signaled. Mirrors the CLI HTTP runner in ``ha_mcp.__main__`` without importing it @@ -1798,17 +1799,16 @@ class EmbeddedServerManager: lifespan="on", # HTTP-ONLY listener, so no WebSocket protocol is loaded. uvicorn # resolves its ``ws`` class EAGERLY in Config.load(), and - # "websockets-sansio" imports the SHARED websockets package — - # the unowned, tearable copy ha-mcp vendors its own copy to stay - # clear of (#2135/#2146). With that setting a torn shared install - # crashed this server at listener startup no matter what the - # client imports. "none" resolves to None and imports nothing; - # the MCP app serves Streamable HTTP and registers no WebSocket - # route. Pinned by tests/src/unit/test_vendored_websockets.py. + # "websockets-sansio" imports the SHARED websockets package — the + # unowned, tearable copy ha-mcp vendors its own to stay clear of + # (#2135/#2146); a torn shared install crashed this server at + # startup. "none" imports nothing; the MCP app serves Streamable + # HTTP only. Pinned by tests/src/unit/test_vendored_websockets.py. ws="none", # Leave Home Assistant's logging untouched — do not let uvicorn # reconfigure the root logger from this thread. log_config=None, + timeout_keep_alive=SERVER_KEEPALIVE_SECONDS, ) uv_server = uvicorn.Server(config) @@ -1986,7 +1986,7 @@ async def _shutdown_server_resources(server: Any) -> None: await stop_websocket_listener() except ImportError: _LOGGER.debug("WebSocket listener module not available") - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.warning("WebSocket listener cleanup failed: %s", err) try: @@ -1995,12 +1995,12 @@ async def _shutdown_server_resources(server: Any) -> None: await websocket_manager.disconnect() except ImportError: _LOGGER.debug("WebSocket manager module not available") - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.warning("WebSocket manager cleanup failed: %s", err) try: await server.close() - except Exception as err: + except Exception as err: # noqa: BLE001 _LOGGER.warning("Server cleanup failed: %s", err) diff --git a/custom_components/ha_mcp_tools/embedded_setup.py b/custom_components/ha_mcp_tools/embedded_setup.py index 0520352b..6162a3e0 100644 --- a/custom_components/ha_mcp_tools/embedded_setup.py +++ b/custom_components/ha_mcp_tools/embedded_setup.py @@ -776,9 +776,9 @@ async def _async_fetch_shipped_component_version( """Return the component version shipped at server release ``vX.Y.Z``. Reads the component manifest as committed at the release's git tag (raw - GitHub URL). Stable tags exist before the PyPI publish; a dev tag only - appears after its binary builds finish, so a fresh dev version can 404 - here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None + GitHub URL). Stable tags exist before the PyPI publish; a dev tag is + created in parallel with the PyPI publish, so a fresh dev version can + briefly 404 here — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None on any failure; the caller treats that as "nothing to hold on" (fail-open). """ diff --git a/custom_components/ha_mcp_tools/llm_api.py b/custom_components/ha_mcp_tools/llm_api.py index c49070de..cccd1b6c 100644 --- a/custom_components/ha_mcp_tools/llm_api.py +++ b/custom_components/ha_mcp_tools/llm_api.py @@ -13,7 +13,8 @@ registered; default is tool-search only): * **tool search** — the agent gets a tiny catalog: the server's pinned tools mirrored directly, plus two meta-tools synthesized here: ``ha_search_tools`` - (find tools by task) and ``ha_call_tool`` (execute a discovered tool). This + (find tools by task, then fetch one's full schema by name; see + :mod:`llm_api_search`) and ``ha_call_tool`` (execute a discovered tool). This keeps per-turn context small — the shape context-limited models need. * **full** — every exposed tool is mirrored directly into the agent's tool list, one schema each. @@ -53,7 +54,7 @@ import logging import math import sys from collections import Counter -from collections.abc import AsyncIterator, Callable, Iterable +from collections.abc import AsyncIterator, Callable from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field from functools import cache @@ -73,6 +74,11 @@ from .const import ( EXPOSURE_TOOL_SEARCH, OPT_LLM_API_EXPOSURE, ) +from .llm_api_search import CALL_TOOL_NAME as _CALL_TOOL_NAME +from .llm_api_search import SEARCH_TOOL_NAME as _SEARCH_TOOL_NAME +from .llm_api_search import HaMcpSearchTool +from .llm_tool_exposure import META_PARAMS_KEY, _tool_meta_namespace, partition_tools +from .llm_tool_metadata import declare_metadata, tool_hints, tool_result, tool_title if TYPE_CHECKING: import httpx @@ -93,45 +99,6 @@ _LIST_TOOLS_TIMEOUT_SECONDS = 10.0 # for the duration, so err generous rather than kill a legitimate slow tool. _CALL_TOOL_TIMEOUT_SECONDS = 300.0 -# The server-side stamp this module filters on (mirrors -# src/ha_mcp/llm_exposure.py — keep the names in sync). -_META_NAMESPACE = "ha_mcp" -_META_EXPOSED_KEY = "llm_api_exposed" -_META_PINNED_KEY = "pinned" - -# Fallback exposure policy for servers that predate the stamp: hide the -# operational-hazard names and the known beta/developer tools. Imperfect by -# construction (a newer beta tool on an old server can't be known here) but -# strictly safer than exposing everything, and logged once per instance -# build. The real policy lives server-side. -_FALLBACK_DENY_PREFIXES = ("ha_dev_",) -_FALLBACK_DENY_TOOLS = frozenset( - { - "ha_restart", - "ha_reload_core", - "ha_manage_backup", - # Beta-tagged tools as of the stamp's introduction (server-side the - # gate is tag-based and future-proof; this list is only the legacy - # fallback). - "ha_config_set_yaml", - "ha_manage_custom_tool", - "ha_get_dashboard_screenshot", - "ha_install_mcp_tools", - "ha_list_files", - "ha_read_file", - "ha_write_file", - "ha_delete_file", - } -) - -# Names of the meta-tools synthesized for the tool-search mode. ha_search_tools -# deliberately matches the server's own tool-search terminology; if the server -# itself runs ENABLE_TOOL_SEARCH its identically-named tool is excluded from -# mirroring/search results to avoid duplicates. -_SEARCH_TOOL_NAME = "ha_search_tools" -_CALL_TOOL_NAME = "ha_call_tool" -_SEARCH_RESULT_LIMIT = 8 - @cache def _schema_converter() -> Callable[[Any], Any]: @@ -377,10 +344,14 @@ _TOOL_SEARCH_PROMPT = ( "This assistant uses search-based tool discovery: most tools are NOT " "listed directly.\n" f"1. Call {_SEARCH_TOOL_NAME}(query=...) to find tools for the task; " - "results include each tool's name, description, and input schema.\n" - f"2. Execute a discovered tool with {_CALL_TOOL_NAME}(name=..., " - "arguments={...}) — discovered tools are NOT directly callable here.\n" - "3. The few tools listed directly can be called as usual.\n" + "results are compact: each tool's name, one-line description, and params.\n" + f"2. Call {_SEARCH_TOOL_NAME}(tools=[...]) for the full description and " + "input schema of the tool you will call — required before calling: the " + "compact hit omits usage guidance, nested fields, defaults, and parameter " + "descriptions.\n" + f"3. Execute it with {_CALL_TOOL_NAME}(name=..., arguments={{...}}) — " + "discovered tools are NOT directly callable here.\n" + "4. The few tools listed directly can be called as usual.\n" "Search once per task, not per call — tool names stay valid all " "conversation." ) @@ -598,50 +569,6 @@ async def _mcp_session( yield session, init_result -def _tool_meta_namespace(tool: Any) -> dict[str, Any] | None: - """Return the tool's ``_meta.ha_mcp`` namespace, or None when absent.""" - meta = getattr(tool, "meta", None) - if not isinstance(meta, dict): - return None - namespace = meta.get(_META_NAMESPACE) - return namespace if isinstance(namespace, dict) else None - - -def _fallback_exposed(name: str) -> bool: - """Legacy exposure policy for servers that predate the meta stamp.""" - if name.startswith(_FALLBACK_DENY_PREFIXES): - return False - return name not in _FALLBACK_DENY_TOOLS - - -def _partition_tools(tools: Iterable[Any]) -> tuple[list[Any], set[str], bool]: - """Split a raw tools/list into (exposed tools, pinned names, stamped). - - ``stamped`` is False when NO tool carried the server's exposure stamp — - an older server package — in which case the conservative component-side - fallback policy was applied instead. - """ - stamped = False - exposed: list[Any] = [] - pinned: set[str] = set() - for tool in tools: - namespace = _tool_meta_namespace(tool) - if namespace is not None and _META_EXPOSED_KEY in namespace: - stamped = True - if namespace.get(_META_PINNED_KEY): - pinned.add(tool.name) - if namespace.get(_META_EXPOSED_KEY): - exposed.append(tool) - elif _fallback_exposed(tool.name): - exposed.append(tool) - if not stamped: - # The fallback path already filtered; recompute pinned as empty (an - # unstamped server gives no pinned signal — the tool-search mode then - # simply mirrors nothing directly). - pinned = set() - return exposed, pinned, stamped - - class HaMcpTool(llm.Tool): """One ha-mcp tool, called over loopback MCP.""" @@ -651,23 +578,28 @@ class HaMcpTool(llm.Tool): description: str | None, parameters: vol.Schema, server_url: str, + *, + title: str | None = None, + hints: dict[str, Any] | None = None, ) -> None: - """Store the converted schema and the loopback endpoint.""" + """Store the converted schema, the server's metadata and the endpoint.""" self.name = name self.description = description self.parameters = parameters self._server_url = server_url + declare_metadata(self, title=title, hints=hints) async def async_call( self, hass: HomeAssistant, tool_input: llm.ToolInput, llm_context: llm.LLMContext, - ) -> JsonObjectType: + ) -> Any: """Call the tool on the in-process server and return its result.""" - return await _forward_tool_call( + dumped = await _forward_tool_call( hass, self._server_url, self.name, tool_input.tool_args ) + return tool_result(dumped, error=dumped.get("isError") is True) async def _forward_tool_call( @@ -715,68 +647,6 @@ def _tool_input_schema(tool: Any) -> Any: return schema -def _search_score(query_words: list[str], name: str, description: str) -> int: - """Score a tool against the query (simple word overlap + substring).""" - haystack = f"{name} {description}".lower() - name_lower = name.lower() - score = 0 - for word in query_words: - if word in name_lower: - score += 3 - elif word in haystack: - score += 1 - return score - - -class HaMcpSearchTool(llm.Tool): - """Meta-tool: find ha-mcp tools relevant to a task (tool-search mode). - - Searches only the EXPOSED catalog snapshot taken at instance build, so a - hidden tool can never appear in results. - """ - - name = _SEARCH_TOOL_NAME - description = ( - "Search the Home Assistant MCP toolset for tools relevant to a task. " - "Returns each match's name, description, and input schema. Execute " - f"matches with {_CALL_TOOL_NAME}." - ) - parameters = vol.Schema({vol.Required("query"): str}) - - def __init__(self, catalog: list[dict[str, Any]]) -> None: - """Hold the exposed-catalog snapshot (name/description/schema dicts).""" - self._catalog = catalog - - async def async_call( - self, - hass: HomeAssistant, - tool_input: llm.ToolInput, - llm_context: llm.LLMContext, - ) -> JsonObjectType: - """Return the top-scoring exposed tools for the query.""" - query_words = [ - w for w in str(tool_input.tool_args.get("query", "")).lower().split() if w - ] - scored = sorted( - ( - (_search_score(query_words, t["name"], t["description"]), t) - for t in self._catalog - ), - key=lambda pair: pair[0], - reverse=True, - ) - results = [t for score, t in scored[:_SEARCH_RESULT_LIMIT] if score > 0] - if not results: - return { - "results": [], - "message": ( - "No matching tools. Try different task words (e.g. " - "'automation', 'light', 'history', 'dashboard')." - ), - } - return {"results": results} - - class HaMcpCallTool(llm.Tool): """Meta-tool: execute a tool discovered via search (tool-search mode). @@ -803,6 +673,8 @@ class HaMcpCallTool(llm.Tool): """Hold the loopback endpoint and the exposed-name allowlist.""" self._server_url = server_url self._exposed_names = exposed_names + # Dispatches any exposed tool, so no hints: Core's least-safe defaults. + declare_metadata(self, title="Call HA-MCP Tool", hints=None) async def async_call( self, @@ -814,11 +686,17 @@ class HaMcpCallTool(llm.Tool): name = str(tool_input.tool_args.get("name", "")) arguments = tool_input.tool_args.get("arguments") or {} if name not in self._exposed_names: - return { - "error": f"Unknown tool '{name}'.", - "suggestion": (f"Use {_SEARCH_TOOL_NAME} to discover available tools."), - } - return await _forward_tool_call(hass, self._server_url, name, arguments) + return tool_result( + { + "error": f"Unknown tool '{name}'.", + "suggestion": ( + f"Use {_SEARCH_TOOL_NAME} to discover available tools." + ), + }, + error=True, + ) + dumped = await _forward_tool_call(hass, self._server_url, name, arguments) + return tool_result(dumped, error=dumped.get("isError") is True) @dataclass(kw_only=True) @@ -858,7 +736,7 @@ class HaMcpLlmApi(llm.API): f"Could not reach the in-process HA-MCP server: {err}" ) from err - exposed, pinned, stamped = _partition_tools(list_result.tools) + exposed, pinned, stamped = partition_tools(list_result.tools) # Never mirror or search a server-side tool that shares a synthesized # meta-tool's name (the server's own tool-search mode registers an # ha_search_tools) — one name, one behavior. @@ -902,6 +780,17 @@ class HaMcpLlmApi(llm.API): ) return None + def _mirror(self, tool: Any, parameters: vol.Schema) -> HaMcpTool: + """Mirror one server tool with its title and safety hints.""" + return HaMcpTool( + tool.name, + tool.description, + parameters, + self.server_url, + title=tool_title(tool), + hints=tool_hints(tool), + ) + def _build_full_tools(self, exposed: list[Any]) -> list[llm.Tool]: """Mirror every exposed tool directly (full-catalog mode).""" tools: list[llm.Tool] = [] @@ -910,9 +799,7 @@ class HaMcpLlmApi(llm.API): parameters = self._convert_parameters(tool, schema) if parameters is None: continue - tools.append( - HaMcpTool(tool.name, tool.description, parameters, self.server_url) - ) + tools.append(self._mirror(tool, parameters)) return tools def _build_tool_search_tools( @@ -921,6 +808,7 @@ class HaMcpLlmApi(llm.API): """Build the compact catalog: mirrored pinned tools + meta-tools.""" tools: list[llm.Tool] = [] exposed_names: set[str] = set() + mirrored: set[str] = set() catalog: list[dict[str, Any]] = [] for tool in exposed: exposed_names.add(tool.name) @@ -935,22 +823,26 @@ class HaMcpLlmApi(llm.API): # converter that wrote back would corrupt the catalog entry, so # re-check this before pointing the component at a third one. schema = _normalise_schema(_tool_input_schema(tool), tool.name) - catalog.append( - { - "name": tool.name, - "description": tool.description or "", - "input_schema": schema, - } - ) + entry = { + "name": tool.name, + "description": tool.description or "", + "input_schema": schema, + } + # The server renders the compact params line (#2633); a server + # that predates the stamp leaves the hit without one. + params = (_tool_meta_namespace(tool) or {}).get(META_PARAMS_KEY) + if isinstance(params, str): + entry["params"] = params + catalog.append(entry) if tool.name in pinned: parameters = self._convert_parameters(tool, schema) if parameters is not None: - tools.append( - HaMcpTool( - tool.name, tool.description, parameters, self.server_url - ) - ) - tools.append(HaMcpSearchTool(catalog)) + tools.append(self._mirror(tool, parameters)) + mirrored.add(tool.name) + # Only a tool actually mirrored is in the agent's list; a pinned one + # whose schema failed to convert stays a full search entry, reachable + # through ha_call_tool. + tools.append(HaMcpSearchTool(catalog, mirrored)) tools.append(HaMcpCallTool(self.server_url, exposed_names)) return tools diff --git a/custom_components/ha_mcp_tools/manifest.json b/custom_components/ha_mcp_tools/manifest.json index c8b6f8fc..19e6c4b6 100644 --- a/custom_components/ha_mcp_tools/manifest.json +++ b/custom_components/ha_mcp_tools/manifest.json @@ -4,10 +4,12 @@ "after_dependencies": [ "backup", "cloud", + "energy", "frontend", "http", "lovelace", - "network" + "network", + "recorder" ], "codeowners": [ "@homeassistant-ai" @@ -24,5 +26,5 @@ "ruamel.yaml>=0.18.0", "voluptuous-openapi>=0.4.1" ], - "version": "2.2.1" + "version": "2.2.2" } diff --git a/custom_components/ha_mcp_tools/mcp_webhook.py b/custom_components/ha_mcp_tools/mcp_webhook.py index f69b7749..4b234c38 100644 --- a/custom_components/ha_mcp_tools/mcp_webhook.py +++ b/custom_components/ha_mcp_tools/mcp_webhook.py @@ -47,7 +47,9 @@ from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import async_register, async_unregister from homeassistant.core import HomeAssistant +from homeassistant.util.aiohttp import MockRequest +from .cloudhook import OAUTH_UNAVAILABLE, buffered_response from .const import ( DATA_WEBHOOK, DATA_WEBHOOK_ID, @@ -124,6 +126,13 @@ _ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain") # long-lived streams fails a new request in 30 s instead of hanging it forever. _CLIENT_TIMEOUT = aiohttp.ClientTimeout(connect=30, sock_connect=10, sock_read=300) + +# The in-process server closes an idle keep-alive connection after 5 s +# (``const.SERVER_KEEPALIVE_SECONDS``); aiohttp pools one for 15 s. A +# request sent as the server closes a pooled connection fails with a connection +# reset (502), so idle relay connections are dropped first. +_RELAY_KEEPALIVE_SECONDS = 3 + # Anonymous CIMD lookups get a separate, deliberately small connection pool. # The relay session may hold long-lived SSE connections; public metadata fetches # must never consume that authenticated forwarding capacity. @@ -154,7 +163,9 @@ def _build_base_url(request: web.Request) -> str: hardening. """ host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "") - scheme = request.headers.get("X-Forwarded-Proto", request.scheme) + scheme = request.headers.get( + "X-Forwarded-Proto", getattr(request, "scheme", "https") + ) return f"{scheme}://{host}" @@ -429,8 +440,7 @@ class _AuthorizationServerMetadataView(HomeAssistantView): class _WellKnownProtectedResourceView(HomeAssistantView): - """RFC 9728 §3.1 path-scoped Protected Resource Metadata — the ONLY - protected-resource document this integration serves. + """RFC 9728 §3.1 metadata for the webhook and its read-only alias. Served at the well-known location derived from the webhook resource URL (``/.well-known/oauth-protected-resource/api/webhook/``), which is also @@ -460,15 +470,17 @@ class _WellKnownProtectedResourceView(HomeAssistantView): def __init__(self, hass: HomeAssistant) -> None: """Bind the view to the HA instance; liveness is resolved per request.""" self._hass = hass + self.extra_urls = [f"{self.url}/readonly"] async def get(self, request: web.Request, webhook_id: str) -> web.Response: """Serve the document only for the CURRENT entry's webhook id.""" active_id = _active_webhook_id(self._hass) if active_id is None or webhook_id != active_id: return _json_not_found() - return web.json_response( - _protected_resource_document(active_id, _build_base_url(request)) - ) + document = _protected_resource_document(active_id, _build_base_url(request)) + if request.path == f"{self.url.format(webhook_id=webhook_id)}/readonly": + document["resource"] += "/readonly" + return web.json_response(document) class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView): @@ -542,13 +554,20 @@ def _register_metadata_views(hass: HomeAssistant) -> None: hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True -def _build_unauthorized_response(request: web.Request, webhook_id: str) -> web.Response: +def _build_unauthorized_response( + request: web.Request, cfg: dict[str, Any] +) -> web.Response: """Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover. Per RFC 9728 §5.1 / MCP 2026-07-28 Authorization Server Discovery, the ``resource_metadata`` parameter points to the protected-resource metadata - URL where the client finds the authorization server. + URL where the client finds the authorization server. Home Assistant Cloud + relays only ``Content-Type`` back from a cloudhook, so the challenge can + never reach that client and sign-in cannot start: say so instead (#2696). """ + webhook_id = cfg["webhook_id"] + if isinstance(request, MockRequest): + return web.Response(status=400, text=OAUTH_UNAVAILABLE) base = _build_base_url(request) # RFC 9728 §3.1 path-scoped location. The pointer names the id, but this # 401 is only produced on a request TO /api/webhook/, so the caller @@ -557,6 +576,8 @@ def _build_unauthorized_response(request: web.Request, webhook_id: str) -> web.R metadata_url = ( f"{base}/.well-known/oauth-protected-resource/api/webhook/{webhook_id}" ) + if getattr(request, "path", "") == f"/api/webhook/{webhook_id}/readonly": + metadata_url += "/readonly" return web.Response( status=401, text="Unauthorized", @@ -589,10 +610,10 @@ async def _check_webhook_auth( if resource_server is not None and not await resource_server.validate_request( request ): - return _build_unauthorized_response(request, cfg["webhook_id"]) + return _build_unauthorized_response(request, cfg) oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider") if oauth_provider is not None and not oauth_provider.validate_bearer(request): - return _build_unauthorized_response(request, cfg["webhook_id"]) + return _build_unauthorized_response(request, cfg) return None @@ -618,7 +639,10 @@ async def _async_handle_webhook( ) session: aiohttp.ClientSession = cfg["session"] - body = await request.read() + # A cloudhook (#2696) arrives as ``MockRequest``: no ``read()``, no transport, + # and the relay returns only ``response.body`` — so buffer, never stream. + cloudhook = isinstance(request, MockRequest) + body = await (request.content.read() if cloudhook else request.read()) forward_headers = { key: value @@ -643,7 +667,7 @@ async def _async_handle_webhook( if mcp_session: resp_headers["Mcp-Session-Id"] = mcp_session - if "text/event-stream" in content_type: + if "text/event-stream" in content_type and not cloudhook: # SSE streaming: prevent HA's compression middleware from # buffering/breaking the stream (supervisor#6470). resp_headers["Content-Type"] = "text/event-stream" @@ -677,10 +701,7 @@ async def _async_handle_webhook( if not any(ct in content_type for ct in _ALLOWED_CONTENT_TYPES): content_type = "application/json" resp_headers["Content-Type"] = content_type - resp_body = await upstream_resp.read() - return web.Response( - status=upstream_resp.status, body=resp_body, headers=resp_headers - ) + return await buffered_response(cloudhook, upstream_resp, resp_headers) except aiohttp.ClientError as err: _LOGGER.error("MCP webhook: upstream request failed: %s", err) return web.Response(status=502, text="MCP server unavailable") @@ -844,7 +865,10 @@ async def async_register_webhook( # Runs before the session opens so a raise here cannot leak it. _unregister_webhook(hass, webhook_id) target_url = f"http://127.0.0.1:{port}{secret_path}" - session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT) + session = aiohttp.ClientSession( + timeout=_CLIENT_TIMEOUT, + connector=aiohttp.TCPConnector(keepalive_timeout=_RELAY_KEEPALIVE_SECONDS), + ) cimd_session: aiohttp.ClientSession | None = None if register_endpoint and auth_mode == WEBHOOK_AUTH_HA: try: diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index 5ef9c165..2cb6f34a 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -480,6 +480,13 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: """ hass.data.setdefault(DOMAIN, {}) + # Which version this run imported: an update HACS installs later changes + # the files (and HA's integration cache) but not the code in memory, and + # the panel asks for a restart until they agree again. + from .helpers.update_state import async_remember_running_version + + await async_remember_running_version(hass) + # The Lovelace card / calendar card / strategy modules FIRST (#184): HA # builds the list of extra module scripts into index.html at the moment a # page is served, and the frontend does not load modules registered later @@ -974,6 +981,10 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: return nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) + # Whose phones carry the reminder: read BEFORE completing, which + # hands a rotation on to the next person. + task_cfg = (config_entry.data.get(CONF_TASKS) or {}).get(task_id) or {} if config_entry is not None else {} + reminded_user_ids = task_user_ids(task_cfg) try: if action_type == "complete": @@ -1017,9 +1028,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: # Action succeeded — dismiss notification and clear NM rate-limit state if nm is not None: - cfg_entry = hass.config_entries.async_get_entry(entry_id) - task_cfg = ((cfg_entry.data.get(CONF_TASKS) or {}).get(task_id) or {}) if cfg_entry is not None else {} - await nm.async_dismiss_task_notification(task_id, user_ids=task_user_ids(task_cfg)) + await nm.async_dismiss_task_notification(task_id, user_ids=reminded_user_ids) nm.clear_task_state(entry_id, task_id) unsub_notification = hass.bus.async_listen("mobile_app_notification_action", _handle_notification_action) diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index 899af2d3..5ec40938 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -973,7 +973,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): bundling_enabled = global_option(self.hass, CONF_NOTIFICATION_BUNDLING_ENABLED) bundle_threshold = int(global_option(self.hass, CONF_NOTIFICATION_BUNDLE_THRESHOLD)) - if bundling_enabled and len(notifiable) >= bundle_threshold: + # No household target: a bundle would reach nobody, so the tasks go + # out one by one to their people's phones (D#110). + if bundling_enabled and nm.can_bundle and len(notifiable) >= bundle_threshold: await nm.async_send_bundled( entry_id=self.entry.entry_id, object_name=obj_name, diff --git a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts index a2112e96..e889b800 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts @@ -11,6 +11,12 @@ * under another name starts unticked with that task's name; a summary says * per object how many tasks it has before and after. Typing another object * asks the server again (problem_sensors/preview). + * + * #211 (54 problem sensors of 18 devices, on a phone): the summary grew a + * line per object and squeezed the list, the only part that could shrink, + * to nothing. The dialog now sits in the shared modal frame: the title and + * the buttons stay, everything between them scrolls as one, and the summary + * folds to its totals once it names more than three objects. */ import { css, html, LitElement, nothing } from "lit"; @@ -18,6 +24,7 @@ import { property, state } from "lit/decorators.js"; import { t, ensureLocale, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; +import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell"; import { TRIGGER_FOR_MINUTES_RANGE } from "../helpers/setting-ranges"; import { UserService } from "../user-service"; import type { HAUser, HomeAssistant } from "../types"; @@ -52,6 +59,16 @@ interface AdoptResponse { errors?: string[]; } +/** One object of the summary: the tasks it gains (entry null = a new object). */ +interface SummaryGroup { + name: string; + entry: string | null; + adding: number; +} + +/** Up to this many objects the summary opens with its lines shown. */ +const SUMMARY_LINES_OPEN = 3; + export class MaintenanceAdoptProblemSensorsDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -74,6 +91,9 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { // #136: minutes the problem must persist before the created task triggers // (one field applied to every selection). "0" = react to the first flicker. @state() private _forMinutes = "0"; + /** #211: the summary's lines shown; decided when the list arrives (folded + * beyond three objects), then the user's. */ + @state() private _summaryOpen = true; private _localeReady = false; private _userService: UserService | null = null; @@ -87,6 +107,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { this._localeReady = true; ensureLocale(this._lang).then(() => this.requestUpdate()); } + if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot); } public async open(): Promise { @@ -125,6 +146,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { // What the target probably already watches starts unticked. this._selected = new Set(this._sensors.filter((s) => !s.covered_by).map((s) => s.entity_id)); this._users = users; + this._summaryOpen = this._summaryGroups().length <= SUMMARY_LINES_OPEN; } /** The default object: the one linked to the device, else the one the @@ -169,9 +191,9 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { if (resp?.covered) this._covered = { ...this._covered, ...resp.covered }; } - /** Per target object: tasks before → after (only ticked rows count). */ - private _summary(L: string) { - const groups = new Map(); + /** The objects the ticked rows go to, each with the tasks it gains. */ + private _summaryGroups(): SummaryGroup[] { + const groups = new Map(); for (const s of this._sensors) { if (!this._selected.has(s.entity_id)) continue; const entry = this._existingEntryFor(s); @@ -181,11 +203,30 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { g.adding += 1; groups.set(key, g); } - if (groups.size === 0) return nothing; + return [...groups.values()]; + } + + /** Per target object: tasks before → after (only ticked rows count), under + * a line with the totals that stays when the lines are folded (#211). */ + private _summary(L: string) { + const groups = this._summaryGroups(); + if (groups.length === 0) return nothing; + const created = groups.filter((g) => !g.entry).length; + const totals = t(created ? "adopt_summary_totals_new" : "adopt_summary_totals", L) + .replace("{tasks}", String(groups.reduce((n, g) => n + g.adding, 0))) + .replace("{objects}", String(groups.length)) + .replace("{new}", String(created)); return html` -
-
${t("adopt_summary_title", L)}
- ${[...groups.values()].map((g) => { +
{ this._summaryOpen = (e.target as HTMLDetailsElement).open; }} + > + + ${t("adopt_summary_title", L)} + ${totals} + + ${groups.map((g) => { if (!g.entry) { return html`
${t("setups_new_object_count", L).replace("{name}", g.name).replace("{count}", String(g.adding))} @@ -199,7 +240,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { .replace("{after}", String(before + g.adding))}
`; })} -
+ `; } @@ -269,13 +310,12 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { const allSelected = this._sensors.length > 0 && this._selected.size === this._sensors.length; - return html` -
-
e.stopPropagation()}> -
${t("adopt_problem_title", L)}
-
${t("adopt_problem_hint", L)}
- ${this._error ? html`
${this._error}
` : nothing} + return renderModalShell(() => this._close(), html` +
${t("adopt_problem_title", L)}
+ ${this._error ? html`
${this._error}
` : nothing} +
+
${t("adopt_problem_hint", L)}
${this._loading ? html`
…
` : this._sensors.length === 0 @@ -392,47 +432,28 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { ` : nothing} - -
- - ${t("cancel", L)} - - - ${t("adopt_problem_adopt", L)} - -
-
- `; + +
+ + ${t("cancel", L)} + + + ${t("adopt_problem_adopt", L)} + +
+ `); } - static styles = css` - .overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - } - .card { - background: var(--card-background-color, #fff); - color: var(--primary-text-color); - border-radius: 12px; - padding: 20px; - display: flex; - flex-direction: column; - gap: 12px; - min-width: min(360px, calc(100vw - 24px)); - max-width: 560px; - width: 90vw; - max-height: 80vh; - overflow: hidden; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + static styles = [modalShellStyles, css` + :host { + --ms-modal-max-width: 560px; + --ms-modal-max-height: 90dvh; + --ms-modal-gap: 12px; + --ms-modal-z: 1000; } .title { font-size: 18px; @@ -452,6 +473,9 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { font-size: 14px; padding: 12px 0; } + /* Stays at the top of the scrolling middle: the count while ticking. + top: -1px + 1px more padding: on a 3x screen a fractional offset let a + line of the text scrolled under it show above it. */ .select-all { display: flex; align-items: center; @@ -459,6 +483,11 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { font-size: 12px; color: var(--secondary-text-color); cursor: pointer; + position: sticky; + top: -1px; + z-index: 1; + padding: 5px 0 4px; + background: var(--card-background-color, var(--ha-card-background, #1c1c1c)); } .select-all input { cursor: pointer; @@ -467,8 +496,6 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { display: flex; flex-direction: column; gap: 6px; - overflow-y: auto; - max-height: 50vh; } .row { display: flex; @@ -536,9 +563,15 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { border-top: 1px dashed var(--divider-color); padding-top: 6px; } + .summary > summary { + cursor: pointer; + margin-bottom: 2px; + } .summary-title { font-weight: 500; - margin-bottom: 2px; + } + .summary-totals { + color: var(--secondary-text-color); } .summary-line { color: var(--primary-color); @@ -594,7 +627,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { gap: 8px; padding-top: 8px; } - `; + `]; } if (!customElements.get("maintenance-adopt-problem-sensors-dialog")) { diff --git a/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts index c71021cf..e3b18724 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts @@ -7,6 +7,9 @@ * * Uses a native for the name — is not registered in the * custom-panel context, so it renders without its field (the documented trap). + * + * In the shared modal frame (#211): the list of views scrolls, the name + * field and the Close button stay. */ import { css, html, LitElement, nothing } from "lit"; @@ -14,6 +17,7 @@ import { property, state } from "lit/decorators.js"; import { t, ensureLocale, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; +import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell"; import type { HomeAssistant, SavedView, SavedViewFilters } from "../types"; interface ViewsResponse { @@ -41,6 +45,7 @@ export class MaintenanceSavedViewsDialog extends LitElement { this._localeReady = true; ensureLocale(this._lang).then(() => this.requestUpdate()); } + if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot); } public async open(currentFilters: SavedViewFilters, views: SavedView[]): Promise { @@ -90,81 +95,58 @@ export class MaintenanceSavedViewsDialog extends LitElement { render() { if (!this._open) return html``; const L = this._lang; - return html` -
-
e.stopPropagation()}> -
${t("views_dialog_title", L)}
-
${t("views_dialog_hint", L)}
- ${this._error ? html`
${this._error}
` : nothing} + return renderModalShell(() => this._close(), html` +
${t("views_dialog_title", L)}
+
${t("views_dialog_hint", L)}
+ ${this._error ? html`
${this._error}
` : nothing} -
- (this._name = (e.target as HTMLInputElement).value)} - @keydown=${(e: KeyboardEvent) => { - if (e.key === "Enter") this._save(); - }} - /> - - ${t("views_save_current", L)} - -
- - ${this._views.length === 0 - ? html`
${t("views_none_yet", L)}
` - : html` -
- ${this._views.map( - (v) => html` -
- ${v.name} - this._delete(v.id)} - > -
- `, - )} -
- `} - -
- ${t("close", L)} -
+
+ (this._name = (e.target as HTMLInputElement).value)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter") this._save(); + }} + /> + + ${t("views_save_current", L)} +
-
- `; + + ${this._views.length === 0 + ? html`
${t("views_none_yet", L)}
` + : html` +
+ ${this._views.map( + (v) => html` +
+ ${v.name} + this._delete(v.id)} + > +
+ `, + )} +
+ `} + +
+ ${t("close", L)} +
+ `); } - static styles = css` - .overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - } - .card { - background: var(--card-background-color, #fff); - color: var(--primary-text-color); - border-radius: 12px; - padding: 20px; - display: flex; - flex-direction: column; - gap: 12px; - min-width: min(360px, calc(100vw - 24px)); - max-width: 480px; - width: 90vw; - max-height: 80vh; - overflow: hidden; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + static styles = [modalShellStyles, css` + :host { + --ms-modal-max-height: 90dvh; + --ms-modal-gap: 12px; + --ms-modal-z: 1000; } .title { font-size: 18px; @@ -198,11 +180,7 @@ export class MaintenanceSavedViewsDialog extends LitElement { padding: 8px 0; } .list { - display: flex; - flex-direction: column; gap: 6px; - overflow-y: auto; - max-height: 50vh; } .row { display: flex; @@ -227,7 +205,7 @@ export class MaintenanceSavedViewsDialog extends LitElement { gap: 8px; padding-top: 8px; } - `; + `]; } if (!customElements.get("maintenance-saved-views-dialog")) { diff --git a/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts index 21c0279e..3ff5f286 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts @@ -19,6 +19,9 @@ * reset button completing it will press; tasks adopted before (or wired by * hand) are offered the same in their own section, with a before/after line * (integration_setups/reset_offers + wire_resets). + * + * #211: in the shared modal frame like the adopt dialog: the title and the + * buttons stay, the offers and the devices scroll as one. */ import { css, html, LitElement, nothing } from "lit"; @@ -26,6 +29,7 @@ import { property, state } from "lit/decorators.js"; import { t, ensureLocale, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; +import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell"; import type { HomeAssistant } from "../types"; interface SetupTask { @@ -112,6 +116,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement { this._localeReady = true; ensureLocale(this._lang).then(() => this.requestUpdate()); } + if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot); } /** Ticked by default: everything the target does not probably have yet. */ @@ -336,13 +341,12 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement { if (!this._open) return html``; const L = this._lang; - return html` -
-
e.stopPropagation()}> -
${t("setups_title", L)}
-
${t("setups_hint", L)}
- ${this._error ? html`
${this._error}
` : nothing} + return renderModalShell(() => this._close(), html` +
${t("setups_title", L)}
+ ${this._error ? html`
${this._error}
` : nothing} +
+
${t("setups_hint", L)}
${this._renderOffers(L)} ${this._loading ? html`
…
` @@ -428,50 +432,31 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement { })}
`} - -
- - ${t("cancel", L)} - - - ${t("setups_adopt", L)} - -
-
- `; + +
+ + ${t("cancel", L)} + + + ${t("setups_adopt", L)} + +
+ `); } - static styles = css` - .overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - } - .card { - background: var(--card-background-color, #fff); - color: var(--primary-text-color); - border-radius: 12px; - padding: 20px; - display: flex; - flex-direction: column; - gap: 12px; - min-width: min(360px, calc(100vw - 24px)); - max-width: 560px; - width: 90vw; - max-height: 80vh; - overflow: hidden; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + static styles = [modalShellStyles, css` + :host { + --ms-modal-max-width: 560px; + --ms-modal-max-height: 90dvh; + --ms-modal-gap: 12px; + --ms-modal-z: 1000; } .title { font-size: 18px; font-weight: 500; } .hint { color: var(--secondary-text-color); font-size: 13px; } .error { color: var(--error-color, #f44336); font-size: 13px; } .loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; } - .list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 55vh; } + .list { display: flex; flex-direction: column; gap: 6px; } .row { display: flex; align-items: flex-start; gap: 10px; padding: 8px; border: 1px solid var(--divider-color); border-radius: 6px; @@ -497,7 +482,6 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement { .offers { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--primary-color); border-radius: 6px; - overflow-y: auto; max-height: 40vh; flex-shrink: 0; } .offers-title { font-weight: 500; font-size: 14px; } .offer { display: flex; align-items: flex-start; gap: 6px; font-size: 13px; cursor: pointer; } @@ -520,7 +504,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement { color: var(--primary-text-color); } .actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; } - `; + `]; } if (!customElements.get("maintenance-suggested-setups-dialog")) { diff --git a/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts index 4fc20ab1..b2b70a69 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts @@ -3072,6 +3072,7 @@ export class MaintenanceTaskDialog extends LitElement { +
${t("interval_anchor_hint", L)}
` : nothing} diff --git a/custom_components/maintenance_supporter/frontend-src/locales/cs.json b/custom_components/maintenance_supporter/frontend-src/locales/cs.json index 8c0365f8..e0fc00fb 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/cs.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/cs.json @@ -250,6 +250,7 @@ "interval_anchor": "Ukotvení intervalu", "anchor_completion": "Od data dokončení", "anchor_planned": "Od plánovaného data (bez posunu)", + "interval_anchor_hint": "Od data dokončení: další termín se počítá ode dne, kdy je úkol označen jako hotový, takže dřívější nebo pozdější splnění posune plán. Od plánovaného data: plán si ponechá své termíny, například když je faktura zaplacena týdny před splatností.", "edit_object": "Upravit objekt", "name": "Název", "manufacturer_optional": "Výrobce (volitelné)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Dejte několika senzorům stejný název objektu a stanou se jedním objektem; zadejte název existujícího objektu a přidají se tam.", "adopt_summary_title": "Co převzetí změní", "adopt_summary_existing": "„{name}“: úkoly {before} → {after}", + "adopt_summary_totals": "Úkoly: {tasks} · objekty: {objects}", + "adopt_summary_totals_new": "Úkoly: {tasks} · objekty: {objects} (z toho nové: {new})", "adopt_problem_adopt": "Převzít vybrané", "adopt_problem_done": "Převzato {tasks} senzorů problémů", "adopt_problem_done_one": "Převzat 1 senzor problémů", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.", "update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.", "update_reload": "Načíst znovu", + "restart_pending_banner": "Maintenance Supporter byl aktualizován na verzi {version}. Restartujte Home Assistant, aby se aktualizace dokončila.", + "restart_pending_error": "Nepodařilo se: Maintenance Supporter byl aktualizován na verzi {version}, ale Home Assistant od té doby nebyl restartován. Restartujte Home Assistant a zkuste to znovu.", "row_actions_banner": "Dokončit a Přeskočit jsou v řádcích úkolů nyní tlačítka. Dáváte přednost starým ikonám?", "row_actions_keep": "Ponechat tlačítka", "row_actions_back": "Zpět na ikony", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/da.json b/custom_components/maintenance_supporter/frontend-src/locales/da.json index 7a0b8cf9..c5b332f8 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/da.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/da.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervalanker", "anchor_completion": "Fra fuldførelsesdato", "anchor_planned": "Fra planlagt dato (ingen drift)", + "interval_anchor_hint": "Fra fuldførelsesdato: næste forfaldsdato regnes fra den dag, opgaven markeres som udført, så tidlig eller sen udførelse flytter planen. Fra planlagt dato: planen beholder sine datoer, for eksempel når en regning betales uger før forfald.", "edit_object": "Rediger objekt", "name": "Navn", "manufacturer_optional": "Producent (valgfrit)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Giv flere sensorer samme objektnavn, så bliver de ét objekt; skriv et eksisterende objekts navn for at tilføje dem der.", "adopt_summary_title": "Hvad overtagelsen ændrer", "adopt_summary_existing": "\"{name}\": opgaver {before} → {after}", + "adopt_summary_totals": "Opgaver: {tasks} · objekter: {objects}", + "adopt_summary_totals_new": "Opgaver: {tasks} · objekter: {objects} (heraf nye: {new})", "adopt_problem_adopt": "Adopter valgte", "adopt_problem_done": "Adopterede {tasks} problemsensor(er)", "adopt_problem_done_one": "Adopterede 1 problemsensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.", "update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.", "update_reload": "Genindlæs", + "restart_pending_banner": "Maintenance Supporter er blevet opdateret til {version}. Genstart Home Assistant for at fuldføre opdateringen.", + "restart_pending_error": "Det lykkedes ikke: Maintenance Supporter er blevet opdateret til {version}, men Home Assistant er ikke blevet genstartet siden. Genstart Home Assistant, og prøv igen.", "row_actions_banner": "Fuldfør og Spring over er nu knapper i opgaverækkerne. Foretrækker du de gamle ikoner?", "row_actions_keep": "Behold knapper", "row_actions_back": "Tilbage til ikoner", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/de.json b/custom_components/maintenance_supporter/frontend-src/locales/de.json index 3db9c433..482993da 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/de.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/de.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervall-Anker", "anchor_completion": "Ab Erledigung", "anchor_planned": "Ab geplantem Datum (kein Drift)", + "interval_anchor_hint": "Ab Erledigung: Die nächste Fälligkeit zählt ab dem Tag, an dem die Aufgabe erledigt wird; früher oder später erledigt verschiebt also den Plan. Ab geplantem Datum: Der Plan behält seine Termine, zum Beispiel wenn eine Rechnung Wochen vor der Fälligkeit bezahlt wird.", "edit_object": "Objekt bearbeiten", "name": "Name", "manufacturer_optional": "Hersteller (optional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Gib mehreren Sensoren denselben Objektnamen, dann werden sie ein Objekt; der Name eines vorhandenen Objekts ordnet sie dort zu.", "adopt_summary_title": "Was die Übernahme ändert", "adopt_summary_existing": "„{name}“: Aufgaben {before} → {after}", + "adopt_summary_totals": "Aufgaben: {tasks} · Objekte: {objects}", + "adopt_summary_totals_new": "Aufgaben: {tasks} · Objekte: {objects} (davon neu: {new})", "adopt_problem_adopt": "Auswahl übernehmen", "adopt_problem_done": "{tasks} Problem-Sensor(en) übernommen", "adopt_problem_done_one": "1 Problem-Sensor übernommen", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.", "update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.", "update_reload": "Neu laden", + "restart_pending_banner": "Maintenance Supporter wurde auf {version} aktualisiert. Starte Home Assistant neu, um das Update abzuschließen.", + "restart_pending_error": "Das hat nicht geklappt: Maintenance Supporter wurde auf {version} aktualisiert, Home Assistant aber seitdem nicht neu gestartet. Starte Home Assistant neu und versuche es dann noch einmal.", "row_actions_banner": "Erledigt und Überspringen sind in den Aufgabenzeilen jetzt Buttons. Lieber die alten Symbole?", "row_actions_keep": "Buttons behalten", "row_actions_back": "Zurück zu Symbolen", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/en.json b/custom_components/maintenance_supporter/frontend-src/locales/en.json index 14e58e17..6c116366 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/en.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/en.json @@ -252,6 +252,7 @@ "interval_anchor": "Interval anchor", "anchor_completion": "From completion date", "anchor_planned": "From planned date (no drift)", + "interval_anchor_hint": "From completion date: the next due date counts from the day the task is marked done, so doing it early or late moves the schedule. From planned date: the schedule keeps its dates, for example when a bill is paid weeks before it is due.", "edit_object": "Edit Object", "name": "Name", "manufacturer_optional": "Manufacturer (optional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Give several sensors the same object name and they become one object; type an existing object's name to add them there.", "adopt_summary_title": "What adopting changes", "adopt_summary_existing": "\"{name}\": tasks {before} → {after}", + "adopt_summary_totals": "Tasks: {tasks} · objects: {objects}", + "adopt_summary_totals_new": "Tasks: {tasks} · objects: {objects} ({new} new)", "adopt_problem_adopt": "Adopt selected", "adopt_problem_done": "Adopted {tasks} problem sensor(s)", "adopt_problem_done_one": "Adopted 1 problem sensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.", "update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.", "update_reload": "Reload", + "restart_pending_banner": "Maintenance Supporter was updated to {version}. Restart Home Assistant to finish the update.", + "restart_pending_error": "That did not work: Maintenance Supporter was updated to {version}, but Home Assistant has not been restarted since. Restart Home Assistant, then try again.", "row_actions_banner": "Complete and Skip in task rows are now buttons. Prefer the old icons?", "row_actions_keep": "Keep buttons", "row_actions_back": "Back to icons", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/es.json b/custom_components/maintenance_supporter/frontend-src/locales/es.json index 027410f5..cf310719 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/es.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/es.json @@ -250,6 +250,7 @@ "interval_anchor": "Anclaje del intervalo", "anchor_completion": "Desde la fecha de finalización", "anchor_planned": "Desde la fecha planificada (sin desviación)", + "interval_anchor_hint": "Desde la fecha de finalización: el próximo vencimiento se cuenta desde el día en que se marca la tarea como hecha, así que hacerla antes o después mueve el calendario. Desde la fecha planificada: el calendario mantiene sus fechas, por ejemplo cuando una factura se paga semanas antes de su vencimiento.", "edit_object": "Editar objeto", "name": "Nombre", "manufacturer_optional": "Fabricante (opcional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Da a varios sensores el mismo nombre de objeto y se convierten en un solo objeto; escribe el nombre de un objeto existente para añadirlos allí.", "adopt_summary_title": "Qué cambia al adoptar", "adopt_summary_existing": "«{name}»: tareas {before} → {after}", + "adopt_summary_totals": "Tareas: {tasks} · objetos: {objects}", + "adopt_summary_totals_new": "Tareas: {tasks} · objetos: {objects} (nuevos: {new})", "adopt_problem_adopt": "Adoptar seleccionados", "adopt_problem_done": "{tasks} sensor(es) de problema adoptado(s)", "adopt_problem_done_one": "1 sensor de problema adoptado", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.", "update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.", "update_reload": "Recargar", + "restart_pending_banner": "Maintenance Supporter se actualizó a la versión {version}. Reinicia Home Assistant para completar la actualización.", + "restart_pending_error": "No ha funcionado: Maintenance Supporter se actualizó a la versión {version}, pero Home Assistant no se ha reiniciado desde entonces. Reinicia Home Assistant y vuelve a intentarlo.", "row_actions_banner": "Completar y Omitir ahora son botones en las filas de tareas. ¿Prefieres los iconos antiguos?", "row_actions_keep": "Mantener botones", "row_actions_back": "Volver a los iconos", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/fi.json b/custom_components/maintenance_supporter/frontend-src/locales/fi.json index 24eec475..55c0f1db 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/fi.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/fi.json @@ -252,6 +252,7 @@ "interval_anchor": "Välin ankkuri", "anchor_completion": "Suorituspäivästä", "anchor_planned": "Suunnitellusta päivästä (ei poikkeamaa)", + "interval_anchor_hint": "Suorituspäivästä: seuraava eräpäivä lasketaan päivästä, jolloin tehtävä merkitään tehdyksi, joten aikainen tai myöhäinen suoritus siirtää aikataulua. Suunnitellusta päivästä: aikataulu pitää päivänsä, esimerkiksi kun lasku maksetaan viikkoja ennen eräpäivää.", "edit_object": "Muokkaa kohdetta", "name": "Nimi", "manufacturer_optional": "Valmistaja (valinnainen)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Anna useille antureille sama kohteen nimi, niin niistä tulee yksi kohde; kirjoita olemassa olevan kohteen nimi lisätäksesi ne sinne.", "adopt_summary_title": "Mitä käyttöönotto muuttaa", "adopt_summary_existing": "\"{name}\": tehtävät {before} → {after}", + "adopt_summary_totals": "Tehtäviä: {tasks} · kohteita: {objects}", + "adopt_summary_totals_new": "Tehtäviä: {tasks} · kohteita: {objects} (uusia: {new})", "adopt_problem_adopt": "Ota valitut käyttöön", "adopt_problem_done": "Otettiin käyttöön {tasks} ongelma-anturia", "adopt_problem_done_one": "Otettiin käyttöön 1 ongelma-anturi", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.", "update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.", "update_reload": "Lataa uudelleen", + "restart_pending_banner": "Maintenance Supporter on päivitetty versioon {version}. Käynnistä Home Assistant uudelleen viimeistelläksesi päivityksen.", + "restart_pending_error": "Se ei onnistunut: Maintenance Supporter on päivitetty versioon {version}, mutta Home Assistantia ei ole sen jälkeen käynnistetty uudelleen. Käynnistä Home Assistant uudelleen ja yritä sitten uudelleen.", "row_actions_banner": "Valmis ja Ohita ovat tehtäväriveillä nyt painikkeita. Haluatko mieluummin vanhat kuvakkeet?", "row_actions_keep": "Pidä painikkeet", "row_actions_back": "Takaisin kuvakkeisiin", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/fr.json b/custom_components/maintenance_supporter/frontend-src/locales/fr.json index 1ed05282..0bb01d7f 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/fr.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/fr.json @@ -250,6 +250,7 @@ "interval_anchor": "Ancrage de l'intervalle", "anchor_completion": "Depuis la date de réalisation", "anchor_planned": "Depuis la date prévue (sans dérive)", + "interval_anchor_hint": "Depuis la date de réalisation : la prochaine échéance compte à partir du jour où la tâche est marquée comme faite, donc la faire en avance ou en retard décale le planning. Depuis la date prévue : le planning garde ses dates, par exemple quand une facture est payée des semaines avant son échéance.", "edit_object": "Modifier l'objet", "name": "Nom", "manufacturer_optional": "Fabricant (optionnel)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Donnez le même nom d'objet à plusieurs capteurs et ils forment un seul objet ; saisissez le nom d'un objet existant pour les y ajouter.", "adopt_summary_title": "Ce que l'adoption modifie", "adopt_summary_existing": "« {name} » : tâches {before} → {after}", + "adopt_summary_totals": "Tâches : {tasks} · objets : {objects}", + "adopt_summary_totals_new": "Tâches : {tasks} · objets : {objects} (dont nouveaux : {new})", "adopt_problem_adopt": "Adopter la sélection", "adopt_problem_done": "{tasks} capteur(s) de problème adopté(s)", "adopt_problem_done_one": "1 capteur de problème adopté", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.", "update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.", "update_reload": "Recharger", + "restart_pending_banner": "Maintenance Supporter a été mis à jour vers la version {version}. Redémarrez Home Assistant pour terminer la mise à jour.", + "restart_pending_error": "Cela n'a pas fonctionné : Maintenance Supporter a été mis à jour vers la version {version}, mais Home Assistant n'a pas été redémarré depuis. Redémarrez Home Assistant, puis réessayez.", "row_actions_banner": "Terminer et Ignorer sont désormais des boutons dans les lignes de tâches. Vous préférez les anciennes icônes ?", "row_actions_keep": "Garder les boutons", "row_actions_back": "Revenir aux icônes", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/hi.json b/custom_components/maintenance_supporter/frontend-src/locales/hi.json index 450bc667..4b505a4d 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/hi.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/hi.json @@ -252,6 +252,7 @@ "interval_anchor": "अंतराल आधार", "anchor_completion": "पूर्ण तिथि से", "anchor_planned": "नियोजित तिथि से (कोई विचलन नहीं)", + "interval_anchor_hint": "पूर्ण तिथि से: अगली देय तिथि उस दिन से गिनी जाती है जिस दिन कार्य पूरा चिह्नित किया जाता है, इसलिए पहले या देर से करने पर समय-सारणी खिसक जाती है। नियोजित तिथि से: समय-सारणी अपनी तिथियाँ बनाए रखती है, उदाहरण के लिए जब कोई बिल देय तिथि से हफ़्तों पहले चुकाया जाता है।", "edit_object": "वस्तु संपादित करें", "name": "नाम", "manufacturer_optional": "निर्माता (वैकल्पिक)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "कई सेंसरों को एक ही ऑब्जेक्ट नाम दें तो वे एक ऑब्जेक्ट बन जाते हैं; मौजूदा ऑब्जेक्ट का नाम लिखें तो वे वहीं जुड़ जाते हैं।", "adopt_summary_title": "अपनाने से क्या बदलेगा", "adopt_summary_existing": "\"{name}\": कार्य {before} → {after}", + "adopt_summary_totals": "कार्य: {tasks} · ऑब्जेक्ट: {objects}", + "adopt_summary_totals_new": "कार्य: {tasks} · ऑब्जेक्ट: {objects} (नए: {new})", "adopt_problem_adopt": "चयनित अपनाएँ", "adopt_problem_done": "{tasks} समस्या सेंसर अपनाए गए", "adopt_problem_done_one": "1 समस्या सेंसर अपनाया गया", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।", "update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।", "update_reload": "पुनः लोड करें", + "restart_pending_banner": "Maintenance Supporter को {version} संस्करण में अपडेट किया गया है। अपडेट पूरा करने के लिए Home Assistant को पुनः आरंभ करें।", + "restart_pending_error": "यह नहीं हो सका: Maintenance Supporter को {version} संस्करण में अपडेट किया गया है, लेकिन उसके बाद से Home Assistant को पुनः आरंभ नहीं किया गया है। Home Assistant को पुनः आरंभ करें, फिर दोबारा प्रयास करें।", "row_actions_banner": "कार्य पंक्तियों में पूर्ण और छोड़ें अब बटन हैं। पुराने आइकन पसंद हैं?", "row_actions_keep": "बटन रखें", "row_actions_back": "आइकन पर वापस जाएँ", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/hu.json b/custom_components/maintenance_supporter/frontend-src/locales/hu.json index a759175c..10461add 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/hu.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/hu.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervallum horgonya", "anchor_completion": "Az elvégzés dátumától", "anchor_planned": "A tervezett dátumtól (nincs csúszás)", + "interval_anchor_hint": "Az elvégzés dátumától: a következő határidő attól a naptól számít, amikor a feladatot elvégzettnek jelölik, így a korábbi vagy későbbi elvégzés eltolja az ütemezést. A tervezett dátumtól: az ütemezés megtartja a dátumait, például ha egy számlát hetekkel a határidő előtt fizetnek be.", "edit_object": "Objektum szerkesztése", "name": "Név", "manufacturer_optional": "Gyártó (opcionális)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Adja több érzékelőnek ugyanazt az objektumnevet, és egy objektummá válnak; meglévő objektum nevét beírva oda kerülnek.", "adopt_summary_title": "Mit változtat az átvétel", "adopt_summary_existing": "„{name}”: feladatok {before} → {after}", + "adopt_summary_totals": "Feladatok: {tasks} · objektumok: {objects}", + "adopt_summary_totals_new": "Feladatok: {tasks} · objektumok: {objects} (ebből új: {new})", "adopt_problem_adopt": "Kijelöltek átvétele", "adopt_problem_done": "{tasks} problémaérzékelő átvéve", "adopt_problem_done_one": "1 problémaérzékelő átvéve", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.", "update_banner": "A Maintenance Supporter újabb verziója érhető el a szerveren — töltse újra az oldalt a panel frissítéséhez.", "update_reload": "Újratöltés", + "restart_pending_banner": "A Maintenance Supporter frissült a(z) {version} verzióra. Indítsa újra a Home Assistantot a frissítés befejezéséhez.", + "restart_pending_error": "Ez nem sikerült: a Maintenance Supporter frissült a(z) {version} verzióra, de a Home Assistant azóta nem indult újra. Indítsa újra a Home Assistantot, majd próbálja újra.", "row_actions_banner": "A Kész és a Kihagyás a feladatsorokban mostantól gombok. Inkább a régi ikonokat szeretné?", "row_actions_keep": "Gombok megtartása", "row_actions_back": "Vissza az ikonokhoz", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/it.json b/custom_components/maintenance_supporter/frontend-src/locales/it.json index e138ef8c..9322b42b 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/it.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/it.json @@ -250,6 +250,7 @@ "interval_anchor": "Ancoraggio intervallo", "anchor_completion": "Dalla data di completamento", "anchor_planned": "Dalla data pianificata (nessuna deriva)", + "interval_anchor_hint": "Dalla data di completamento: la prossima scadenza si conta dal giorno in cui l'attività viene segnata come fatta, quindi farla prima o dopo sposta la pianificazione. Dalla data pianificata: la pianificazione mantiene le sue date, per esempio quando una bolletta viene pagata settimane prima della scadenza.", "edit_object": "Modifica oggetto", "name": "Nome", "manufacturer_optional": "Produttore (opzionale)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Assegna lo stesso nome oggetto a più sensori e diventano un unico oggetto; digita il nome di un oggetto esistente per aggiungerli lì.", "adopt_summary_title": "Cosa cambia l'adozione", "adopt_summary_existing": "«{name}»: attività {before} → {after}", + "adopt_summary_totals": "Attività: {tasks} · oggetti: {objects}", + "adopt_summary_totals_new": "Attività: {tasks} · oggetti: {objects} (di cui nuovi: {new})", "adopt_problem_adopt": "Adotta selezionati", "adopt_problem_done": "{tasks} sensore/i di problema adottato/i", "adopt_problem_done_one": "1 sensore di problema adottato", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.", "update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.", "update_reload": "Ricarica", + "restart_pending_banner": "Maintenance Supporter è stato aggiornato alla versione {version}. Riavvia Home Assistant per completare l'aggiornamento.", + "restart_pending_error": "Non è andato a buon fine: Maintenance Supporter è stato aggiornato alla versione {version}, ma da allora Home Assistant non è stato riavviato. Riavvia Home Assistant e riprova.", "row_actions_banner": "Completa e Salta nelle righe delle attività ora sono pulsanti. Preferisci le vecchie icone?", "row_actions_keep": "Mantieni i pulsanti", "row_actions_back": "Torna alle icone", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/ja.json b/custom_components/maintenance_supporter/frontend-src/locales/ja.json index 189950ec..6c1529fd 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/ja.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/ja.json @@ -252,6 +252,7 @@ "interval_anchor": "間隔の起点", "anchor_completion": "完了日から", "anchor_planned": "予定日から(ずれなし)", + "interval_anchor_hint": "完了日から: 次の期限はタスクを完了にした日から数えるため、早く・遅く完了すると予定がずれます。予定日から: 予定は元の日付を保ちます。たとえば請求書を期限の数週間前に支払った場合です。", "edit_object": "対象を編集", "name": "名前", "manufacturer_optional": "メーカー(任意)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "複数のセンサーに同じオブジェクト名を付けると1つのオブジェクトになります。既存のオブジェクト名を入力すればそこに追加されます。", "adopt_summary_title": "取り込みで変わること", "adopt_summary_existing": "「{name}」: タスク {before} → {after}", + "adopt_summary_totals": "タスク: {tasks} · オブジェクト: {objects}", + "adopt_summary_totals_new": "タスク: {tasks} · オブジェクト: {objects}(うち新規: {new})", "adopt_problem_adopt": "選択を取り込む", "adopt_problem_done": "{tasks} 件の問題センサーを取り込みました", "adopt_problem_done_one": "1 件の問題センサーを取り込みました", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。", "update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。", "update_reload": "再読み込み", + "restart_pending_banner": "Maintenance Supporter はバージョン {version} に更新されました。更新を完了するには Home Assistant を再起動してください。", + "restart_pending_error": "実行できませんでした: Maintenance Supporter はバージョン {version} に更新されましたが、その後 Home Assistant が再起動されていません。Home Assistant を再起動してから、もう一度お試しください。", "row_actions_banner": "タスク行の「完了」と「スキップ」がボタンになりました。以前のアイコンに戻しますか?", "row_actions_keep": "ボタンのままにする", "row_actions_back": "アイコンに戻す", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/ko.json b/custom_components/maintenance_supporter/frontend-src/locales/ko.json index 055e116b..a645881f 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/ko.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/ko.json @@ -252,6 +252,7 @@ "interval_anchor": "주기 기준", "anchor_completion": "완료일 기준", "anchor_planned": "예정일 기준 (밀림 없음)", + "interval_anchor_hint": "완료일 기준: 다음 예정일은 작업을 완료로 표시한 날부터 계산되므로, 일찍 또는 늦게 완료하면 일정이 바뀝니다. 예정일 기준: 일정이 원래 날짜를 유지합니다. 예를 들어 청구서를 기한보다 몇 주 일찍 납부한 경우입니다.", "edit_object": "객체 편집", "name": "이름", "manufacturer_optional": "제조사 (선택)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "여러 센서에 같은 개체 이름을 주면 하나의 개체가 됩니다. 기존 개체 이름을 입력하면 거기에 추가됩니다.", "adopt_summary_title": "가져오기로 바뀌는 점", "adopt_summary_existing": "\"{name}\": 작업 {before} → {after}", + "adopt_summary_totals": "작업: {tasks} · 객체: {objects}", + "adopt_summary_totals_new": "작업: {tasks} · 객체: {objects} (새 객체: {new})", "adopt_problem_adopt": "선택 항목 가져오기", "adopt_problem_done": "문제 센서 {tasks}개를 가져왔습니다", "adopt_problem_done_one": "문제 센서 1개를 가져왔습니다", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.", "update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.", "update_reload": "새로 고침", + "restart_pending_banner": "Maintenance Supporter가 {version} 버전으로 업데이트되었습니다. 업데이트를 완료하려면 Home Assistant를 다시 시작하세요.", + "restart_pending_error": "실패했습니다: Maintenance Supporter가 {version} 버전으로 업데이트되었지만 그 이후 Home Assistant가 다시 시작되지 않았습니다. Home Assistant를 다시 시작한 후 다시 시도하세요.", "row_actions_banner": "작업 행의 완료와 건너뛰기가 이제 버튼으로 표시됩니다. 이전 아이콘이 더 좋으신가요?", "row_actions_keep": "버튼 유지", "row_actions_back": "아이콘으로 되돌리기", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/nb.json b/custom_components/maintenance_supporter/frontend-src/locales/nb.json index 0812410d..d2f52a80 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/nb.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/nb.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervallforankring", "anchor_completion": "Fra fullføringsdato", "anchor_planned": "Fra planlagt dato (ingen avdrift)", + "interval_anchor_hint": "Fra fullføringsdato: neste forfallsdato regnes fra dagen oppgaven markeres som utført, så tidlig eller sen utførelse flytter planen. Fra planlagt dato: planen beholder datoene sine, for eksempel når en regning betales uker før forfall.", "edit_object": "Rediger objekt", "name": "Navn", "manufacturer_optional": "Produsent (valgfritt)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Gi flere sensorer samme objektnavn, så blir de ett objekt; skriv navnet på et eksisterende objekt for å legge dem der.", "adopt_summary_title": "Hva overtakelsen endrer", "adopt_summary_existing": "«{name}»: oppgaver {before} → {after}", + "adopt_summary_totals": "Oppgaver: {tasks} · objekter: {objects}", + "adopt_summary_totals_new": "Oppgaver: {tasks} · objekter: {objects} (herav nye: {new})", "adopt_problem_adopt": "Ta i bruk valgte", "adopt_problem_done": "Tok i bruk {tasks} problemsensor(er)", "adopt_problem_done_one": "Tok i bruk 1 problemsensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.", "update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.", "update_reload": "Last inn på nytt", + "restart_pending_banner": "Maintenance Supporter er oppdatert til {version}. Start Home Assistant på nytt for å fullføre oppdateringen.", + "restart_pending_error": "Det fungerte ikke: Maintenance Supporter er oppdatert til {version}, men Home Assistant er ikke startet på nytt siden. Start Home Assistant på nytt, og prøv igjen.", "row_actions_banner": "Fullfør og Hopp over er nå knapper i oppgaveradene. Foretrekker du de gamle ikonene?", "row_actions_keep": "Behold knapper", "row_actions_back": "Tilbake til ikoner", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/nl.json b/custom_components/maintenance_supporter/frontend-src/locales/nl.json index 9d6188e5..a0c5db30 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/nl.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/nl.json @@ -250,6 +250,7 @@ "interval_anchor": "Interval-anker", "anchor_completion": "Vanaf voltooiing", "anchor_planned": "Vanaf geplande datum (geen drift)", + "interval_anchor_hint": "Vanaf voltooiing: de volgende vervaldatum telt vanaf de dag waarop de taak als gedaan wordt gemarkeerd, dus eerder of later uitvoeren verschuift de planning. Vanaf geplande datum: de planning houdt haar data, bijvoorbeeld wanneer een rekening weken voor de vervaldatum wordt betaald.", "edit_object": "Object bewerken", "name": "Naam", "manufacturer_optional": "Fabrikant (optioneel)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Geef meerdere sensoren dezelfde objectnaam en ze worden één object; typ de naam van een bestaand object om ze daar toe te voegen.", "adopt_summary_title": "Wat overnemen verandert", "adopt_summary_existing": "\"{name}\": taken {before} → {after}", + "adopt_summary_totals": "Taken: {tasks} · objecten: {objects}", + "adopt_summary_totals_new": "Taken: {tasks} · objecten: {objects} (waarvan nieuw: {new})", "adopt_problem_adopt": "Selectie overnemen", "adopt_problem_done": "{tasks} probleemsensor(en) overgenomen", "adopt_problem_done_one": "1 probleemsensor overgenomen", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.", "update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.", "update_reload": "Herladen", + "restart_pending_banner": "Maintenance Supporter is bijgewerkt naar {version}. Start Home Assistant opnieuw op om de update te voltooien.", + "restart_pending_error": "Dat is niet gelukt: Maintenance Supporter is bijgewerkt naar {version}, maar Home Assistant is sindsdien niet opnieuw opgestart. Start Home Assistant opnieuw op en probeer het dan nog eens.", "row_actions_banner": "Voltooien en Overslaan zijn in taakrijen nu knoppen. Liever de oude pictogrammen?", "row_actions_keep": "Knoppen houden", "row_actions_back": "Terug naar pictogrammen", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/pl.json b/custom_components/maintenance_supporter/frontend-src/locales/pl.json index 8cc593a9..121753f3 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/pl.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/pl.json @@ -250,6 +250,7 @@ "interval_anchor": "Punkt zaczepienia interwału", "anchor_completion": "Od daty wykonania", "anchor_planned": "Od daty planowanej (bez przesunięć)", + "interval_anchor_hint": "Od daty wykonania: następny termin liczy się od dnia oznaczenia zadania jako wykonanego, więc wcześniejsze lub późniejsze wykonanie przesuwa harmonogram. Od daty planowanej: harmonogram zachowuje swoje daty, na przykład gdy rachunek zostaje opłacony kilka tygodni przed terminem.", "edit_object": "Edytuj obiekt", "name": "Nazwa", "manufacturer_optional": "Producent (opcjonalne)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Nadaj kilku czujnikom tę samą nazwę obiektu, a staną się jednym obiektem; wpisz nazwę istniejącego obiektu, aby je tam dodać.", "adopt_summary_title": "Co zmieni przejęcie", "adopt_summary_existing": "„{name}”: zadania {before} → {after}", + "adopt_summary_totals": "Zadania: {tasks} · obiekty: {objects}", + "adopt_summary_totals_new": "Zadania: {tasks} · obiekty: {objects} (w tym nowe: {new})", "adopt_problem_adopt": "Przejmij zaznaczone", "adopt_problem_done": "Przejęto {tasks} czujnik(ów) problemów", "adopt_problem_done_one": "Przejęto 1 czujnik problemów", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.", "update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.", "update_reload": "Załaduj ponownie", + "restart_pending_banner": "Maintenance Supporter został zaktualizowany do wersji {version}. Uruchom ponownie Home Assistant, aby dokończyć aktualizację.", + "restart_pending_error": "Nie udało się: Maintenance Supporter został zaktualizowany do wersji {version}, ale od tego czasu Home Assistant nie został uruchomiony ponownie. Uruchom ponownie Home Assistant i spróbuj jeszcze raz.", "row_actions_banner": "Ukończ i Pomiń w wierszach zadań są teraz przyciskami. Wolisz stare ikony?", "row_actions_keep": "Zachowaj przyciski", "row_actions_back": "Wróć do ikon", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json b/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json index 40644d8a..e9a2ce42 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json @@ -252,6 +252,7 @@ "interval_anchor": "Âncora do intervalo", "anchor_completion": "A partir da data de conclusão", "anchor_planned": "A partir da data planejada (sem deriva)", + "interval_anchor_hint": "A partir da data de conclusão: o próximo vencimento conta a partir do dia em que a tarefa é marcada como feita, então fazê-la antes ou depois desloca o cronograma. A partir da data planejada: o cronograma mantém suas datas, por exemplo quando uma conta é paga semanas antes do vencimento.", "edit_object": "Editar objeto", "name": "Nome", "manufacturer_optional": "Fabricante (opcional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Dê a vários sensores o mesmo nome de objeto e eles viram um único objeto; digite o nome de um objeto existente para adicioná-los lá.", "adopt_summary_title": "O que a adoção muda", "adopt_summary_existing": "\"{name}\": tarefas {before} → {after}", + "adopt_summary_totals": "Tarefas: {tasks} · objetos: {objects}", + "adopt_summary_totals_new": "Tarefas: {tasks} · objetos: {objects} (novos: {new})", "adopt_problem_adopt": "Adotar selecionados", "adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)", "adopt_problem_done_one": "1 sensor de problema adotado", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.", "update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.", "update_reload": "Recarregar", + "restart_pending_banner": "O Maintenance Supporter foi atualizado para a versão {version}. Reinicie o Home Assistant para finalizar a atualização.", + "restart_pending_error": "Não deu certo: o Maintenance Supporter foi atualizado para a versão {version}, mas o Home Assistant ainda não foi reiniciado. Reinicie o Home Assistant e tente de novo.", "row_actions_banner": "Concluir e Pular agora são botões nas linhas de tarefas. Prefere os ícones antigos?", "row_actions_keep": "Manter botões", "row_actions_back": "Voltar aos ícones", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/pt.json b/custom_components/maintenance_supporter/frontend-src/locales/pt.json index ba450d3c..92cbe10b 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/pt.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/pt.json @@ -250,6 +250,7 @@ "interval_anchor": "Âncora do intervalo", "anchor_completion": "A partir da data de conclusão", "anchor_planned": "A partir da data planeada (sem desvio)", + "interval_anchor_hint": "A partir da data de conclusão: o próximo vencimento conta a partir do dia em que a tarefa é marcada como feita, por isso fazê-la antes ou depois desloca o calendário. A partir da data planeada: o calendário mantém as suas datas, por exemplo quando uma fatura é paga semanas antes do vencimento.", "edit_object": "Editar objeto", "name": "Nome", "manufacturer_optional": "Fabricante (opcional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Dê a vários sensores o mesmo nome de objeto e tornam-se um só objeto; escreva o nome de um objeto existente para os juntar lá.", "adopt_summary_title": "O que a adoção altera", "adopt_summary_existing": "«{name}»: tarefas {before} → {after}", + "adopt_summary_totals": "Tarefas: {tasks} · objetos: {objects}", + "adopt_summary_totals_new": "Tarefas: {tasks} · objetos: {objects} (novos: {new})", "adopt_problem_adopt": "Adotar selecionados", "adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)", "adopt_problem_done_one": "1 sensor de problema adotado", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.", "update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.", "update_reload": "Recarregar", + "restart_pending_banner": "O Maintenance Supporter foi atualizado para a versão {version}. Reinicie o Home Assistant para concluir a atualização.", + "restart_pending_error": "Não foi possível: o Maintenance Supporter foi atualizado para a versão {version}, mas o Home Assistant ainda não foi reiniciado. Reinicie o Home Assistant e tente novamente.", "row_actions_banner": "Concluir e Ignorar são agora botões nas linhas de tarefas. Prefere os ícones antigos?", "row_actions_keep": "Manter botões", "row_actions_back": "Voltar aos ícones", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/ru.json b/custom_components/maintenance_supporter/frontend-src/locales/ru.json index ee380d4e..0d09c092 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/ru.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/ru.json @@ -249,6 +249,7 @@ "interval_anchor": "Якорь интервала", "anchor_completion": "От даты выполнения", "anchor_planned": "От плановой даты (без смещения)", + "interval_anchor_hint": "От даты выполнения: следующий срок отсчитывается от дня, когда задача отмечена выполненной, поэтому раннее или позднее выполнение сдвигает график. От плановой даты: график сохраняет свои даты, например когда счёт оплачен за несколько недель до срока.", "edit_object": "Изменить объект", "name": "Имя", "manufacturer_optional": "Производитель (опционально)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Дайте нескольким датчикам одно имя объекта — они станут одним объектом; введите имя существующего объекта, чтобы добавить их туда.", "adopt_summary_title": "Что изменит добавление", "adopt_summary_existing": "«{name}»: задачи {before} → {after}", + "adopt_summary_totals": "Задачи: {tasks} · объекты: {objects}", + "adopt_summary_totals_new": "Задачи: {tasks} · объекты: {objects} (из них новых: {new})", "adopt_problem_adopt": "Принять выбранные", "adopt_problem_done": "Принято сенсоров проблем: {tasks}", "adopt_problem_done_one": "Принят 1 сенсор проблем", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.", "update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.", "update_reload": "Перезагрузить", + "restart_pending_banner": "Maintenance Supporter обновлён до версии {version}. Перезапустите Home Assistant, чтобы завершить обновление.", + "restart_pending_error": "Не получилось: Maintenance Supporter обновлён до версии {version}, но Home Assistant с тех пор не перезапускался. Перезапустите Home Assistant и попробуйте ещё раз.", "row_actions_banner": "«Выполнено» и «Пропустить» в строках задач теперь кнопки. Предпочитаете старые значки?", "row_actions_keep": "Оставить кнопки", "row_actions_back": "Вернуть значки", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/sv.json b/custom_components/maintenance_supporter/frontend-src/locales/sv.json index de74e3ac..a5964377 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/sv.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/sv.json @@ -250,6 +250,7 @@ "interval_anchor": "Intervallankare", "anchor_completion": "Från slutförandedatum", "anchor_planned": "Från planerat datum (ingen drift)", + "interval_anchor_hint": "Från slutförandedatum: nästa förfallodatum räknas från dagen då uppgiften markeras som utförd, så att göra den tidigt eller sent flyttar schemat. Från planerat datum: schemat behåller sina datum, till exempel när en räkning betalas veckor före förfallodagen.", "edit_object": "Redigera objekt", "name": "Namn", "manufacturer_optional": "Tillverkare (valfritt)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Ge flera sensorer samma objektnamn så blir de ett objekt; skriv ett befintligt objekts namn för att lägga dem där.", "adopt_summary_title": "Vad övertagandet ändrar", "adopt_summary_existing": "”{name}”: uppgifter {before} → {after}", + "adopt_summary_totals": "Uppgifter: {tasks} · objekt: {objects}", + "adopt_summary_totals_new": "Uppgifter: {tasks} · objekt: {objects} (varav nya: {new})", "adopt_problem_adopt": "Anta valda", "adopt_problem_done": "Antog {tasks} problemsensor(er)", "adopt_problem_done_one": "Antog 1 problemsensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.", "update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.", "update_reload": "Ladda om", + "restart_pending_banner": "Maintenance Supporter har uppdaterats till {version}. Starta om Home Assistant för att slutföra uppdateringen.", + "restart_pending_error": "Det fungerade inte: Maintenance Supporter har uppdaterats till {version}, men Home Assistant har inte startats om sedan dess. Starta om Home Assistant och försök igen.", "row_actions_banner": "Slutför och Hoppa över är nu knappar i uppgiftsraderna. Föredrar du de gamla ikonerna?", "row_actions_keep": "Behåll knappar", "row_actions_back": "Tillbaka till ikoner", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/tr.json b/custom_components/maintenance_supporter/frontend-src/locales/tr.json index ab761230..d1b031bf 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/tr.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/tr.json @@ -252,6 +252,7 @@ "interval_anchor": "Aralık referansı", "anchor_completion": "Tamamlama tarihinden", "anchor_planned": "Planlanan tarihten (kayma olmaz)", + "interval_anchor_hint": "Tamamlama tarihinden: sonraki vade, görevin yapıldı olarak işaretlendiği günden itibaren sayılır; bu yüzden erken veya geç yapmak planı kaydırır. Planlanan tarihten: plan tarihlerini korur; örneğin bir fatura vadesinden haftalar önce ödendiğinde.", "edit_object": "Nesneyi Düzenle", "name": "Ad", "manufacturer_optional": "Üretici (isteğe bağlı)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Birden çok sensöre aynı nesne adını verin, tek nesne olurlar; mevcut bir nesnenin adını yazarsanız oraya eklenirler.", "adopt_summary_title": "Devralmanın değiştirecekleri", "adopt_summary_existing": "\"{name}\": görevler {before} → {after}", + "adopt_summary_totals": "Görevler: {tasks} · nesneler: {objects}", + "adopt_summary_totals_new": "Görevler: {tasks} · nesneler: {objects} (yeni: {new})", "adopt_problem_adopt": "Seçilenleri devral", "adopt_problem_done": "{tasks} sorun sensörü devralındı", "adopt_problem_done_one": "1 sorun sensörü devralındı", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.", "update_banner": "Sunucuda Maintenance Supporter'ın daha yeni bir sürümü var — paneli güncellemek için sayfayı yeniden yükleyin.", "update_reload": "Yeniden yükle", + "restart_pending_banner": "Maintenance Supporter {version} sürümüne güncellendi. Güncellemeyi tamamlamak için Home Assistant'ı yeniden başlatın.", + "restart_pending_error": "Bu işlem başarısız oldu: Maintenance Supporter {version} sürümüne güncellendi, ancak Home Assistant o zamandan beri yeniden başlatılmadı. Home Assistant'ı yeniden başlatın ve tekrar deneyin.", "row_actions_banner": "Görev satırlarındaki Tamamla ve Atla artık düğme. Eski simgeleri mi tercih edersiniz?", "row_actions_keep": "Düğmeleri koru", "row_actions_back": "Simgelere geri dön", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/uk.json b/custom_components/maintenance_supporter/frontend-src/locales/uk.json index 687df054..c7f5ee98 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/uk.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/uk.json @@ -249,6 +249,7 @@ "interval_anchor": "Прив'язка інтервалу", "anchor_completion": "Від дати виконання", "anchor_planned": "Від запланованої дати (без зміщення)", + "interval_anchor_hint": "Від дати виконання: наступний термін відраховується від дня, коли завдання позначено виконаним, тож раннє чи пізнє виконання зсуває графік. Від запланованої дати: графік зберігає свої дати, наприклад коли рахунок сплачено за кілька тижнів до терміну.", "edit_object": "Редагувати об'єкт", "name": "Назва", "manufacturer_optional": "Виробник (необов'язково)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Дайте кільком сенсорам однакову назву об'єкта — вони стануть одним об'єктом; введіть назву наявного об'єкта, щоб додати їх туди.", "adopt_summary_title": "Що змінить додавання", "adopt_summary_existing": "«{name}»: завдання {before} → {after}", + "adopt_summary_totals": "Завдання: {tasks} · об'єкти: {objects}", + "adopt_summary_totals_new": "Завдання: {tasks} · об'єкти: {objects} (з них нових: {new})", "adopt_problem_adopt": "Прийняти вибрані", "adopt_problem_done": "Прийнято сенсорів проблем: {tasks}", "adopt_problem_done_one": "Прийнято 1 сенсор проблем", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.", "update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.", "update_reload": "Перезавантажити", + "restart_pending_banner": "Maintenance Supporter оновлено до версії {version}. Перезапустіть Home Assistant, щоб завершити оновлення.", + "restart_pending_error": "Не вдалося: Maintenance Supporter оновлено до версії {version}, але Home Assistant відтоді не перезапускався. Перезапустіть Home Assistant і спробуйте ще раз.", "row_actions_banner": "«Виконано» та «Пропустити» в рядках завдань тепер кнопки. Бажаєте старі значки?", "row_actions_keep": "Залишити кнопки", "row_actions_back": "Повернути значки", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/zh.json b/custom_components/maintenance_supporter/frontend-src/locales/zh.json index 8df8effd..c6092572 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/zh.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/zh.json @@ -252,6 +252,7 @@ "interval_anchor": "计划锚点", "anchor_completion": "从完成日期起算", "anchor_planned": "从计划日期起算 (无偏差)", + "interval_anchor_hint": "从完成日期起算:下一个到期日从任务标记为完成的那天开始计算,因此提前或推迟完成都会移动计划。从计划日期起算:计划保持原定日期,例如账单在到期前几周就已支付时。", "edit_object": "编辑维护项", "name": "名称", "manufacturer_optional": "制造商 (可选)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "给多个传感器相同的对象名称,它们就会合并为一个对象;输入已有对象的名称即可加入该对象。", "adopt_summary_title": "采用后会发生的变化", "adopt_summary_existing": "“{name}”:任务 {before} → {after}", + "adopt_summary_totals": "任务:{tasks} · 对象:{objects}", + "adopt_summary_totals_new": "任务:{tasks} · 对象:{objects}(其中新建:{new})", "adopt_problem_adopt": "采用所选", "adopt_problem_done": "已采用 {tasks} 个问题传感器", "adopt_problem_done_one": "已采用 1 个问题传感器", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。", "update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。", "update_reload": "重新加载", + "restart_pending_banner": "Maintenance Supporter 已更新到 {version} 版本。请重启 Home Assistant 以完成更新。", + "restart_pending_error": "操作失败:Maintenance Supporter 已更新到 {version} 版本,但 Home Assistant 此后尚未重启。请重启 Home Assistant 后重试。", "row_actions_banner": "任务行中的“完成”和“跳过”现在是按钮。更喜欢旧的图标?", "row_actions_keep": "保留按钮", "row_actions_back": "改回图标", diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts index 7a5e64c0..fa55486f 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts @@ -5,7 +5,7 @@ import { isSafeLink } from "./helpers/url"; import { queryTokens, scoreFields } from "./helpers/search-match"; import { objectRef, parseRef, renderRefChip, taskRef, type HasRef } from "./helpers/reference"; import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge"; -import { isStaleBundle } from "./helpers/bundle-version"; +import { isStaleBundle, pendingRestartVersion, type ServerVersion } from "./helpers/bundle-version"; import { customElement, property, state } from "lit/decorators.js"; import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, formatQty, syncCurrencyDecimals} from "./styles"; import { OVERVIEW_TABS, type OverviewTab } from "./helpers/overview-tabs"; @@ -313,7 +313,11 @@ export class MaintenanceSupporterPanel extends LitElement { // and the fleet isn't set up yet. @state() private _batteryFleetSetupAvailable = false; @state() private _staleBundle = false; + /** The version an update installed while Home Assistant still runs the + * old code (helpers/update_state.py); null when both agree. */ + @state() private _restartPendingVersion: string | null = null; private _staleChecked = false; + private _onReconnect = (): void => this._checkServerVersion(); private _toastTimer: ReturnType | null = null; private _dismissedSuggestions = new Set(); @@ -408,7 +412,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _statsService: StatisticsService | null = null; private _userService: UserService | null = null; private _dataLoaded = false; - private _lastConnection: unknown = null; + private _lastConnection: HomeAssistant["connection"] | null = null; private get _lang(): string { return langOf(this.hass); @@ -553,7 +557,7 @@ export class MaintenanceSupporterPanel extends LitElement { } this._dataLoaded = false; this._initialLoadDone = false; - this._lastConnection = null; + this._watchConnection(null); this._deepLinkHandled = false; // `_presetsApplied` intentionally survives (see its declaration). // Pending timers must not fire into a detached element (bug audit @@ -582,13 +586,13 @@ export class MaintenanceSupporterPanel extends LitElement { if (changedProps.has("hass") && this.hass) { if (!this._dataLoaded) { this._dataLoaded = true; - this._lastConnection = this.hass.connection; + this._watchConnection(this.hass.connection); // Seed initial history state so back button returns to overview history.replaceState({ msp_view: "overview", msp_entry: null, msp_task: null }, ""); this._loadData(); this._subscribe(); } else if (this.hass.connection !== this._lastConnection) { - this._lastConnection = this.hass.connection; + this._watchConnection(this.hass.connection); if (this._unsub) { try { this._unsub(); } catch { /* ignore */ } this._unsub = null; @@ -743,6 +747,31 @@ export class MaintenanceSupporterPanel extends LitElement { } } + /** Follow `conn`'s reconnects for the version check (and stop following + * the previous connection). */ + private _watchConnection(conn: HomeAssistant["connection"] | null): void { + this._lastConnection?.removeEventListener?.("ready", this._onReconnect); + this._lastConnection = conn; + conn?.addEventListener?.("ready", this._onReconnect); + } + + /** Stale-bundle handshake (roadmap guard 2): compare the version esbuild + * stamped into this bundle with the backend's — a mismatch means the + * browser/service worker is still serving an OLD cached frontend, and no + * amount of HA restarts fixes that. Unless an update waits for its + * restart: then that explains any difference, and only the restart helps. */ + private _checkServerVersion(): void { + this.hass.connection + .sendMessagePromise({ type: "maintenance_supporter/version" }) + .then((v) => { + this._restartPendingVersion = pendingRestartVersion(v); + this._staleBundle = !this._restartPendingVersion && isStaleBundle(v?.version); + }) + .catch(() => { + /* backend too old to answer — no banner */ + }); + } + private async _loadData(): Promise { const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = await Promise.all([ this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }).catch(() => null), @@ -780,20 +809,11 @@ export class MaintenanceSupporterPanel extends LitElement { .catch(() => { this._batteryFleetSetupAvailable = false; }); - // Stale-bundle handshake (roadmap guard 2): compare the version esbuild - // stamped into this bundle with the backend's — a mismatch means the - // browser/service worker is still serving an OLD cached frontend, and no - // amount of HA restarts fixes that. Checked once per panel lifetime. + // Checked once per panel lifetime, and again after every reconnect (a + // Home Assistant restart ends the "restart pending" state). if (!this._staleChecked) { this._staleChecked = true; - this.hass.connection - .sendMessagePromise<{ version: string }>({ type: "maintenance_supporter/version" }) - .then((v) => { - this._staleBundle = isStaleBundle(v?.version); - }) - .catch(() => { - /* backend too old to answer — no banner */ - }); + this._checkServerVersion(); } if (statsResult) this._stats = statsResult as StatisticsResponse; if (budgetResult) { this._budget = budgetResult as BudgetStatus; syncCurrencyDecimals(this._budget); } @@ -1272,6 +1292,7 @@ export class MaintenanceSupporterPanel extends LitElement { labels: task.labels ?? [], area_id: obj.object.area_id ?? null, responsible_user_id: task.responsible_user_id ?? null, + assignee_pool: task.assignee_pool ?? null, group_names: groupNames, }); } @@ -1281,8 +1302,11 @@ export class MaintenanceSupporterPanel extends LitElement { const byDue = (a: TaskRow, b: TaskRow) => byStatus(a, b) || byDays(a, b); const areaName = (r: TaskRow) => r.area_id ? this.hass?.areas?.[r.area_id]?.name || "" : ""; - const userName = (r: TaskRow) => - r.responsible_user_id ? this._userService?.getUserName(r.responsible_user_id) || "" : ""; + // Whom the task is meant for, as the person filter reads it (D#110): + // the responsible person, else everyone it is shared with. + const userNames = (r: TaskRow) => + taskUserIds(r).map((id) => this._userService?.getUserName(id) || "").filter(Boolean); + const userName = (r: TaskRow) => userNames(r).sort((a, b) => a.localeCompare(b)).join(", "); const groupName = (r: TaskRow) => r.group_names[0] || ""; const sorts: Record number> = { due_date: byDue, @@ -3172,6 +3196,14 @@ export class MaintenanceSupporterPanel extends LitElement { render() { return html`
+ ${this._restartPendingVersion + ? html` +
+ + ${t("restart_pending_banner", this._lang).replace("{version}", this._restartPendingVersion)} +
+ ` + : nothing} ${this._staleBundle ? html`
@@ -3906,8 +3938,10 @@ export class MaintenanceSupporterPanel extends LitElement { const a = row.area_id ? this.hass?.areas?.[row.area_id]?.name : null; keys = [a || noneLabel]; } else if (this._groupByMode === "user") { - const u = row.responsible_user_id ? this._userService?.getUserName(row.responsible_user_id) : null; - keys = [u || noneLabel]; + // A shared task with nobody responsible is listed under each of its + // people, as the person filter lists it (D#110). + const names = taskUserIds(row).map((id) => this._userService?.getUserName(id)).filter((n): n is string => !!n); + keys = names.length > 0 ? [...new Set(names)] : [noneLabel]; } else if (this._groupByMode === "group") { keys = row.group_names.length > 0 ? row.group_names : [noneLabel]; } else if (this._groupByMode === "object") { diff --git a/custom_components/maintenance_supporter/frontend-src/types.ts b/custom_components/maintenance_supporter/frontend-src/types.ts index 9e9260d3..604b4669 100644 --- a/custom_components/maintenance_supporter/frontend-src/types.ts +++ b/custom_components/maintenance_supporter/frontend-src/types.ts @@ -633,6 +633,8 @@ export interface TaskRow { labels: string[]; area_id: string | null; responsible_user_id: string | null; + /** "Shared with": with nobody responsible, the task is meant for each (D#110). */ + assignee_pool?: string[] | null; group_names: string[]; /** #189: titles of the calendar events behind next_due (calendar kind). */ next_event_titles: string[]; @@ -660,6 +662,10 @@ export interface HomeAssistant { subscribeMsg: Record, options?: Record ): Promise<() => void>; + /** "ready" fires after a reconnect (a Home Assistant restart). Optional: + * test stubs leave it out. */ + addEventListener?(event: "ready", callback: () => void): void; + removeEventListener?(event: "ready", callback: () => void): void; }; callService( domain: string, diff --git a/custom_components/maintenance_supporter/frontend/locales/cs.json b/custom_components/maintenance_supporter/frontend/locales/cs.json index 8c0365f8..e0fc00fb 100644 --- a/custom_components/maintenance_supporter/frontend/locales/cs.json +++ b/custom_components/maintenance_supporter/frontend/locales/cs.json @@ -250,6 +250,7 @@ "interval_anchor": "Ukotvení intervalu", "anchor_completion": "Od data dokončení", "anchor_planned": "Od plánovaného data (bez posunu)", + "interval_anchor_hint": "Od data dokončení: další termín se počítá ode dne, kdy je úkol označen jako hotový, takže dřívější nebo pozdější splnění posune plán. Od plánovaného data: plán si ponechá své termíny, například když je faktura zaplacena týdny před splatností.", "edit_object": "Upravit objekt", "name": "Název", "manufacturer_optional": "Výrobce (volitelné)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Dejte několika senzorům stejný název objektu a stanou se jedním objektem; zadejte název existujícího objektu a přidají se tam.", "adopt_summary_title": "Co převzetí změní", "adopt_summary_existing": "„{name}“: úkoly {before} → {after}", + "adopt_summary_totals": "Úkoly: {tasks} · objekty: {objects}", + "adopt_summary_totals_new": "Úkoly: {tasks} · objekty: {objects} (z toho nové: {new})", "adopt_problem_adopt": "Převzít vybrané", "adopt_problem_done": "Převzato {tasks} senzorů problémů", "adopt_problem_done_one": "Převzat 1 senzor problémů", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.", "update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.", "update_reload": "Načíst znovu", + "restart_pending_banner": "Maintenance Supporter byl aktualizován na verzi {version}. Restartujte Home Assistant, aby se aktualizace dokončila.", + "restart_pending_error": "Nepodařilo se: Maintenance Supporter byl aktualizován na verzi {version}, ale Home Assistant od té doby nebyl restartován. Restartujte Home Assistant a zkuste to znovu.", "row_actions_banner": "Dokončit a Přeskočit jsou v řádcích úkolů nyní tlačítka. Dáváte přednost starým ikonám?", "row_actions_keep": "Ponechat tlačítka", "row_actions_back": "Zpět na ikony", diff --git a/custom_components/maintenance_supporter/frontend/locales/da.json b/custom_components/maintenance_supporter/frontend/locales/da.json index 7a0b8cf9..c5b332f8 100644 --- a/custom_components/maintenance_supporter/frontend/locales/da.json +++ b/custom_components/maintenance_supporter/frontend/locales/da.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervalanker", "anchor_completion": "Fra fuldførelsesdato", "anchor_planned": "Fra planlagt dato (ingen drift)", + "interval_anchor_hint": "Fra fuldførelsesdato: næste forfaldsdato regnes fra den dag, opgaven markeres som udført, så tidlig eller sen udførelse flytter planen. Fra planlagt dato: planen beholder sine datoer, for eksempel når en regning betales uger før forfald.", "edit_object": "Rediger objekt", "name": "Navn", "manufacturer_optional": "Producent (valgfrit)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Giv flere sensorer samme objektnavn, så bliver de ét objekt; skriv et eksisterende objekts navn for at tilføje dem der.", "adopt_summary_title": "Hvad overtagelsen ændrer", "adopt_summary_existing": "\"{name}\": opgaver {before} → {after}", + "adopt_summary_totals": "Opgaver: {tasks} · objekter: {objects}", + "adopt_summary_totals_new": "Opgaver: {tasks} · objekter: {objects} (heraf nye: {new})", "adopt_problem_adopt": "Adopter valgte", "adopt_problem_done": "Adopterede {tasks} problemsensor(er)", "adopt_problem_done_one": "Adopterede 1 problemsensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.", "update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.", "update_reload": "Genindlæs", + "restart_pending_banner": "Maintenance Supporter er blevet opdateret til {version}. Genstart Home Assistant for at fuldføre opdateringen.", + "restart_pending_error": "Det lykkedes ikke: Maintenance Supporter er blevet opdateret til {version}, men Home Assistant er ikke blevet genstartet siden. Genstart Home Assistant, og prøv igen.", "row_actions_banner": "Fuldfør og Spring over er nu knapper i opgaverækkerne. Foretrækker du de gamle ikoner?", "row_actions_keep": "Behold knapper", "row_actions_back": "Tilbage til ikoner", diff --git a/custom_components/maintenance_supporter/frontend/locales/de.json b/custom_components/maintenance_supporter/frontend/locales/de.json index 3db9c433..482993da 100644 --- a/custom_components/maintenance_supporter/frontend/locales/de.json +++ b/custom_components/maintenance_supporter/frontend/locales/de.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervall-Anker", "anchor_completion": "Ab Erledigung", "anchor_planned": "Ab geplantem Datum (kein Drift)", + "interval_anchor_hint": "Ab Erledigung: Die nächste Fälligkeit zählt ab dem Tag, an dem die Aufgabe erledigt wird; früher oder später erledigt verschiebt also den Plan. Ab geplantem Datum: Der Plan behält seine Termine, zum Beispiel wenn eine Rechnung Wochen vor der Fälligkeit bezahlt wird.", "edit_object": "Objekt bearbeiten", "name": "Name", "manufacturer_optional": "Hersteller (optional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Gib mehreren Sensoren denselben Objektnamen, dann werden sie ein Objekt; der Name eines vorhandenen Objekts ordnet sie dort zu.", "adopt_summary_title": "Was die Übernahme ändert", "adopt_summary_existing": "„{name}“: Aufgaben {before} → {after}", + "adopt_summary_totals": "Aufgaben: {tasks} · Objekte: {objects}", + "adopt_summary_totals_new": "Aufgaben: {tasks} · Objekte: {objects} (davon neu: {new})", "adopt_problem_adopt": "Auswahl übernehmen", "adopt_problem_done": "{tasks} Problem-Sensor(en) übernommen", "adopt_problem_done_one": "1 Problem-Sensor übernommen", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.", "update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.", "update_reload": "Neu laden", + "restart_pending_banner": "Maintenance Supporter wurde auf {version} aktualisiert. Starte Home Assistant neu, um das Update abzuschließen.", + "restart_pending_error": "Das hat nicht geklappt: Maintenance Supporter wurde auf {version} aktualisiert, Home Assistant aber seitdem nicht neu gestartet. Starte Home Assistant neu und versuche es dann noch einmal.", "row_actions_banner": "Erledigt und Überspringen sind in den Aufgabenzeilen jetzt Buttons. Lieber die alten Symbole?", "row_actions_keep": "Buttons behalten", "row_actions_back": "Zurück zu Symbolen", diff --git a/custom_components/maintenance_supporter/frontend/locales/en.json b/custom_components/maintenance_supporter/frontend/locales/en.json index 14e58e17..6c116366 100644 --- a/custom_components/maintenance_supporter/frontend/locales/en.json +++ b/custom_components/maintenance_supporter/frontend/locales/en.json @@ -252,6 +252,7 @@ "interval_anchor": "Interval anchor", "anchor_completion": "From completion date", "anchor_planned": "From planned date (no drift)", + "interval_anchor_hint": "From completion date: the next due date counts from the day the task is marked done, so doing it early or late moves the schedule. From planned date: the schedule keeps its dates, for example when a bill is paid weeks before it is due.", "edit_object": "Edit Object", "name": "Name", "manufacturer_optional": "Manufacturer (optional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Give several sensors the same object name and they become one object; type an existing object's name to add them there.", "adopt_summary_title": "What adopting changes", "adopt_summary_existing": "\"{name}\": tasks {before} → {after}", + "adopt_summary_totals": "Tasks: {tasks} · objects: {objects}", + "adopt_summary_totals_new": "Tasks: {tasks} · objects: {objects} ({new} new)", "adopt_problem_adopt": "Adopt selected", "adopt_problem_done": "Adopted {tasks} problem sensor(s)", "adopt_problem_done_one": "Adopted 1 problem sensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.", "update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.", "update_reload": "Reload", + "restart_pending_banner": "Maintenance Supporter was updated to {version}. Restart Home Assistant to finish the update.", + "restart_pending_error": "That did not work: Maintenance Supporter was updated to {version}, but Home Assistant has not been restarted since. Restart Home Assistant, then try again.", "row_actions_banner": "Complete and Skip in task rows are now buttons. Prefer the old icons?", "row_actions_keep": "Keep buttons", "row_actions_back": "Back to icons", diff --git a/custom_components/maintenance_supporter/frontend/locales/es.json b/custom_components/maintenance_supporter/frontend/locales/es.json index 027410f5..cf310719 100644 --- a/custom_components/maintenance_supporter/frontend/locales/es.json +++ b/custom_components/maintenance_supporter/frontend/locales/es.json @@ -250,6 +250,7 @@ "interval_anchor": "Anclaje del intervalo", "anchor_completion": "Desde la fecha de finalización", "anchor_planned": "Desde la fecha planificada (sin desviación)", + "interval_anchor_hint": "Desde la fecha de finalización: el próximo vencimiento se cuenta desde el día en que se marca la tarea como hecha, así que hacerla antes o después mueve el calendario. Desde la fecha planificada: el calendario mantiene sus fechas, por ejemplo cuando una factura se paga semanas antes de su vencimiento.", "edit_object": "Editar objeto", "name": "Nombre", "manufacturer_optional": "Fabricante (opcional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Da a varios sensores el mismo nombre de objeto y se convierten en un solo objeto; escribe el nombre de un objeto existente para añadirlos allí.", "adopt_summary_title": "Qué cambia al adoptar", "adopt_summary_existing": "«{name}»: tareas {before} → {after}", + "adopt_summary_totals": "Tareas: {tasks} · objetos: {objects}", + "adopt_summary_totals_new": "Tareas: {tasks} · objetos: {objects} (nuevos: {new})", "adopt_problem_adopt": "Adoptar seleccionados", "adopt_problem_done": "{tasks} sensor(es) de problema adoptado(s)", "adopt_problem_done_one": "1 sensor de problema adoptado", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.", "update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.", "update_reload": "Recargar", + "restart_pending_banner": "Maintenance Supporter se actualizó a la versión {version}. Reinicia Home Assistant para completar la actualización.", + "restart_pending_error": "No ha funcionado: Maintenance Supporter se actualizó a la versión {version}, pero Home Assistant no se ha reiniciado desde entonces. Reinicia Home Assistant y vuelve a intentarlo.", "row_actions_banner": "Completar y Omitir ahora son botones en las filas de tareas. ¿Prefieres los iconos antiguos?", "row_actions_keep": "Mantener botones", "row_actions_back": "Volver a los iconos", diff --git a/custom_components/maintenance_supporter/frontend/locales/fi.json b/custom_components/maintenance_supporter/frontend/locales/fi.json index 24eec475..55c0f1db 100644 --- a/custom_components/maintenance_supporter/frontend/locales/fi.json +++ b/custom_components/maintenance_supporter/frontend/locales/fi.json @@ -252,6 +252,7 @@ "interval_anchor": "Välin ankkuri", "anchor_completion": "Suorituspäivästä", "anchor_planned": "Suunnitellusta päivästä (ei poikkeamaa)", + "interval_anchor_hint": "Suorituspäivästä: seuraava eräpäivä lasketaan päivästä, jolloin tehtävä merkitään tehdyksi, joten aikainen tai myöhäinen suoritus siirtää aikataulua. Suunnitellusta päivästä: aikataulu pitää päivänsä, esimerkiksi kun lasku maksetaan viikkoja ennen eräpäivää.", "edit_object": "Muokkaa kohdetta", "name": "Nimi", "manufacturer_optional": "Valmistaja (valinnainen)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Anna useille antureille sama kohteen nimi, niin niistä tulee yksi kohde; kirjoita olemassa olevan kohteen nimi lisätäksesi ne sinne.", "adopt_summary_title": "Mitä käyttöönotto muuttaa", "adopt_summary_existing": "\"{name}\": tehtävät {before} → {after}", + "adopt_summary_totals": "Tehtäviä: {tasks} · kohteita: {objects}", + "adopt_summary_totals_new": "Tehtäviä: {tasks} · kohteita: {objects} (uusia: {new})", "adopt_problem_adopt": "Ota valitut käyttöön", "adopt_problem_done": "Otettiin käyttöön {tasks} ongelma-anturia", "adopt_problem_done_one": "Otettiin käyttöön 1 ongelma-anturi", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.", "update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.", "update_reload": "Lataa uudelleen", + "restart_pending_banner": "Maintenance Supporter on päivitetty versioon {version}. Käynnistä Home Assistant uudelleen viimeistelläksesi päivityksen.", + "restart_pending_error": "Se ei onnistunut: Maintenance Supporter on päivitetty versioon {version}, mutta Home Assistantia ei ole sen jälkeen käynnistetty uudelleen. Käynnistä Home Assistant uudelleen ja yritä sitten uudelleen.", "row_actions_banner": "Valmis ja Ohita ovat tehtäväriveillä nyt painikkeita. Haluatko mieluummin vanhat kuvakkeet?", "row_actions_keep": "Pidä painikkeet", "row_actions_back": "Takaisin kuvakkeisiin", diff --git a/custom_components/maintenance_supporter/frontend/locales/fr.json b/custom_components/maintenance_supporter/frontend/locales/fr.json index 1ed05282..0bb01d7f 100644 --- a/custom_components/maintenance_supporter/frontend/locales/fr.json +++ b/custom_components/maintenance_supporter/frontend/locales/fr.json @@ -250,6 +250,7 @@ "interval_anchor": "Ancrage de l'intervalle", "anchor_completion": "Depuis la date de réalisation", "anchor_planned": "Depuis la date prévue (sans dérive)", + "interval_anchor_hint": "Depuis la date de réalisation : la prochaine échéance compte à partir du jour où la tâche est marquée comme faite, donc la faire en avance ou en retard décale le planning. Depuis la date prévue : le planning garde ses dates, par exemple quand une facture est payée des semaines avant son échéance.", "edit_object": "Modifier l'objet", "name": "Nom", "manufacturer_optional": "Fabricant (optionnel)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Donnez le même nom d'objet à plusieurs capteurs et ils forment un seul objet ; saisissez le nom d'un objet existant pour les y ajouter.", "adopt_summary_title": "Ce que l'adoption modifie", "adopt_summary_existing": "« {name} » : tâches {before} → {after}", + "adopt_summary_totals": "Tâches : {tasks} · objets : {objects}", + "adopt_summary_totals_new": "Tâches : {tasks} · objets : {objects} (dont nouveaux : {new})", "adopt_problem_adopt": "Adopter la sélection", "adopt_problem_done": "{tasks} capteur(s) de problème adopté(s)", "adopt_problem_done_one": "1 capteur de problème adopté", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.", "update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.", "update_reload": "Recharger", + "restart_pending_banner": "Maintenance Supporter a été mis à jour vers la version {version}. Redémarrez Home Assistant pour terminer la mise à jour.", + "restart_pending_error": "Cela n'a pas fonctionné : Maintenance Supporter a été mis à jour vers la version {version}, mais Home Assistant n'a pas été redémarré depuis. Redémarrez Home Assistant, puis réessayez.", "row_actions_banner": "Terminer et Ignorer sont désormais des boutons dans les lignes de tâches. Vous préférez les anciennes icônes ?", "row_actions_keep": "Garder les boutons", "row_actions_back": "Revenir aux icônes", diff --git a/custom_components/maintenance_supporter/frontend/locales/hi.json b/custom_components/maintenance_supporter/frontend/locales/hi.json index 450bc667..4b505a4d 100644 --- a/custom_components/maintenance_supporter/frontend/locales/hi.json +++ b/custom_components/maintenance_supporter/frontend/locales/hi.json @@ -252,6 +252,7 @@ "interval_anchor": "अंतराल आधार", "anchor_completion": "पूर्ण तिथि से", "anchor_planned": "नियोजित तिथि से (कोई विचलन नहीं)", + "interval_anchor_hint": "पूर्ण तिथि से: अगली देय तिथि उस दिन से गिनी जाती है जिस दिन कार्य पूरा चिह्नित किया जाता है, इसलिए पहले या देर से करने पर समय-सारणी खिसक जाती है। नियोजित तिथि से: समय-सारणी अपनी तिथियाँ बनाए रखती है, उदाहरण के लिए जब कोई बिल देय तिथि से हफ़्तों पहले चुकाया जाता है।", "edit_object": "वस्तु संपादित करें", "name": "नाम", "manufacturer_optional": "निर्माता (वैकल्पिक)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "कई सेंसरों को एक ही ऑब्जेक्ट नाम दें तो वे एक ऑब्जेक्ट बन जाते हैं; मौजूदा ऑब्जेक्ट का नाम लिखें तो वे वहीं जुड़ जाते हैं।", "adopt_summary_title": "अपनाने से क्या बदलेगा", "adopt_summary_existing": "\"{name}\": कार्य {before} → {after}", + "adopt_summary_totals": "कार्य: {tasks} · ऑब्जेक्ट: {objects}", + "adopt_summary_totals_new": "कार्य: {tasks} · ऑब्जेक्ट: {objects} (नए: {new})", "adopt_problem_adopt": "चयनित अपनाएँ", "adopt_problem_done": "{tasks} समस्या सेंसर अपनाए गए", "adopt_problem_done_one": "1 समस्या सेंसर अपनाया गया", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।", "update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।", "update_reload": "पुनः लोड करें", + "restart_pending_banner": "Maintenance Supporter को {version} संस्करण में अपडेट किया गया है। अपडेट पूरा करने के लिए Home Assistant को पुनः आरंभ करें।", + "restart_pending_error": "यह नहीं हो सका: Maintenance Supporter को {version} संस्करण में अपडेट किया गया है, लेकिन उसके बाद से Home Assistant को पुनः आरंभ नहीं किया गया है। Home Assistant को पुनः आरंभ करें, फिर दोबारा प्रयास करें।", "row_actions_banner": "कार्य पंक्तियों में पूर्ण और छोड़ें अब बटन हैं। पुराने आइकन पसंद हैं?", "row_actions_keep": "बटन रखें", "row_actions_back": "आइकन पर वापस जाएँ", diff --git a/custom_components/maintenance_supporter/frontend/locales/hu.json b/custom_components/maintenance_supporter/frontend/locales/hu.json index a759175c..10461add 100644 --- a/custom_components/maintenance_supporter/frontend/locales/hu.json +++ b/custom_components/maintenance_supporter/frontend/locales/hu.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervallum horgonya", "anchor_completion": "Az elvégzés dátumától", "anchor_planned": "A tervezett dátumtól (nincs csúszás)", + "interval_anchor_hint": "Az elvégzés dátumától: a következő határidő attól a naptól számít, amikor a feladatot elvégzettnek jelölik, így a korábbi vagy későbbi elvégzés eltolja az ütemezést. A tervezett dátumtól: az ütemezés megtartja a dátumait, például ha egy számlát hetekkel a határidő előtt fizetnek be.", "edit_object": "Objektum szerkesztése", "name": "Név", "manufacturer_optional": "Gyártó (opcionális)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Adja több érzékelőnek ugyanazt az objektumnevet, és egy objektummá válnak; meglévő objektum nevét beírva oda kerülnek.", "adopt_summary_title": "Mit változtat az átvétel", "adopt_summary_existing": "„{name}”: feladatok {before} → {after}", + "adopt_summary_totals": "Feladatok: {tasks} · objektumok: {objects}", + "adopt_summary_totals_new": "Feladatok: {tasks} · objektumok: {objects} (ebből új: {new})", "adopt_problem_adopt": "Kijelöltek átvétele", "adopt_problem_done": "{tasks} problémaérzékelő átvéve", "adopt_problem_done_one": "1 problémaérzékelő átvéve", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.", "update_banner": "A Maintenance Supporter újabb verziója érhető el a szerveren — töltse újra az oldalt a panel frissítéséhez.", "update_reload": "Újratöltés", + "restart_pending_banner": "A Maintenance Supporter frissült a(z) {version} verzióra. Indítsa újra a Home Assistantot a frissítés befejezéséhez.", + "restart_pending_error": "Ez nem sikerült: a Maintenance Supporter frissült a(z) {version} verzióra, de a Home Assistant azóta nem indult újra. Indítsa újra a Home Assistantot, majd próbálja újra.", "row_actions_banner": "A Kész és a Kihagyás a feladatsorokban mostantól gombok. Inkább a régi ikonokat szeretné?", "row_actions_keep": "Gombok megtartása", "row_actions_back": "Vissza az ikonokhoz", diff --git a/custom_components/maintenance_supporter/frontend/locales/it.json b/custom_components/maintenance_supporter/frontend/locales/it.json index e138ef8c..9322b42b 100644 --- a/custom_components/maintenance_supporter/frontend/locales/it.json +++ b/custom_components/maintenance_supporter/frontend/locales/it.json @@ -250,6 +250,7 @@ "interval_anchor": "Ancoraggio intervallo", "anchor_completion": "Dalla data di completamento", "anchor_planned": "Dalla data pianificata (nessuna deriva)", + "interval_anchor_hint": "Dalla data di completamento: la prossima scadenza si conta dal giorno in cui l'attività viene segnata come fatta, quindi farla prima o dopo sposta la pianificazione. Dalla data pianificata: la pianificazione mantiene le sue date, per esempio quando una bolletta viene pagata settimane prima della scadenza.", "edit_object": "Modifica oggetto", "name": "Nome", "manufacturer_optional": "Produttore (opzionale)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Assegna lo stesso nome oggetto a più sensori e diventano un unico oggetto; digita il nome di un oggetto esistente per aggiungerli lì.", "adopt_summary_title": "Cosa cambia l'adozione", "adopt_summary_existing": "«{name}»: attività {before} → {after}", + "adopt_summary_totals": "Attività: {tasks} · oggetti: {objects}", + "adopt_summary_totals_new": "Attività: {tasks} · oggetti: {objects} (di cui nuovi: {new})", "adopt_problem_adopt": "Adotta selezionati", "adopt_problem_done": "{tasks} sensore/i di problema adottato/i", "adopt_problem_done_one": "1 sensore di problema adottato", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.", "update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.", "update_reload": "Ricarica", + "restart_pending_banner": "Maintenance Supporter è stato aggiornato alla versione {version}. Riavvia Home Assistant per completare l'aggiornamento.", + "restart_pending_error": "Non è andato a buon fine: Maintenance Supporter è stato aggiornato alla versione {version}, ma da allora Home Assistant non è stato riavviato. Riavvia Home Assistant e riprova.", "row_actions_banner": "Completa e Salta nelle righe delle attività ora sono pulsanti. Preferisci le vecchie icone?", "row_actions_keep": "Mantieni i pulsanti", "row_actions_back": "Torna alle icone", diff --git a/custom_components/maintenance_supporter/frontend/locales/ja.json b/custom_components/maintenance_supporter/frontend/locales/ja.json index 189950ec..6c1529fd 100644 --- a/custom_components/maintenance_supporter/frontend/locales/ja.json +++ b/custom_components/maintenance_supporter/frontend/locales/ja.json @@ -252,6 +252,7 @@ "interval_anchor": "間隔の起点", "anchor_completion": "完了日から", "anchor_planned": "予定日から(ずれなし)", + "interval_anchor_hint": "完了日から: 次の期限はタスクを完了にした日から数えるため、早く・遅く完了すると予定がずれます。予定日から: 予定は元の日付を保ちます。たとえば請求書を期限の数週間前に支払った場合です。", "edit_object": "対象を編集", "name": "名前", "manufacturer_optional": "メーカー(任意)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "複数のセンサーに同じオブジェクト名を付けると1つのオブジェクトになります。既存のオブジェクト名を入力すればそこに追加されます。", "adopt_summary_title": "取り込みで変わること", "adopt_summary_existing": "「{name}」: タスク {before} → {after}", + "adopt_summary_totals": "タスク: {tasks} · オブジェクト: {objects}", + "adopt_summary_totals_new": "タスク: {tasks} · オブジェクト: {objects}(うち新規: {new})", "adopt_problem_adopt": "選択を取り込む", "adopt_problem_done": "{tasks} 件の問題センサーを取り込みました", "adopt_problem_done_one": "1 件の問題センサーを取り込みました", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。", "update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。", "update_reload": "再読み込み", + "restart_pending_banner": "Maintenance Supporter はバージョン {version} に更新されました。更新を完了するには Home Assistant を再起動してください。", + "restart_pending_error": "実行できませんでした: Maintenance Supporter はバージョン {version} に更新されましたが、その後 Home Assistant が再起動されていません。Home Assistant を再起動してから、もう一度お試しください。", "row_actions_banner": "タスク行の「完了」と「スキップ」がボタンになりました。以前のアイコンに戻しますか?", "row_actions_keep": "ボタンのままにする", "row_actions_back": "アイコンに戻す", diff --git a/custom_components/maintenance_supporter/frontend/locales/ko.json b/custom_components/maintenance_supporter/frontend/locales/ko.json index 055e116b..a645881f 100644 --- a/custom_components/maintenance_supporter/frontend/locales/ko.json +++ b/custom_components/maintenance_supporter/frontend/locales/ko.json @@ -252,6 +252,7 @@ "interval_anchor": "주기 기준", "anchor_completion": "완료일 기준", "anchor_planned": "예정일 기준 (밀림 없음)", + "interval_anchor_hint": "완료일 기준: 다음 예정일은 작업을 완료로 표시한 날부터 계산되므로, 일찍 또는 늦게 완료하면 일정이 바뀝니다. 예정일 기준: 일정이 원래 날짜를 유지합니다. 예를 들어 청구서를 기한보다 몇 주 일찍 납부한 경우입니다.", "edit_object": "객체 편집", "name": "이름", "manufacturer_optional": "제조사 (선택)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "여러 센서에 같은 개체 이름을 주면 하나의 개체가 됩니다. 기존 개체 이름을 입력하면 거기에 추가됩니다.", "adopt_summary_title": "가져오기로 바뀌는 점", "adopt_summary_existing": "\"{name}\": 작업 {before} → {after}", + "adopt_summary_totals": "작업: {tasks} · 객체: {objects}", + "adopt_summary_totals_new": "작업: {tasks} · 객체: {objects} (새 객체: {new})", "adopt_problem_adopt": "선택 항목 가져오기", "adopt_problem_done": "문제 센서 {tasks}개를 가져왔습니다", "adopt_problem_done_one": "문제 센서 1개를 가져왔습니다", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.", "update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.", "update_reload": "새로 고침", + "restart_pending_banner": "Maintenance Supporter가 {version} 버전으로 업데이트되었습니다. 업데이트를 완료하려면 Home Assistant를 다시 시작하세요.", + "restart_pending_error": "실패했습니다: Maintenance Supporter가 {version} 버전으로 업데이트되었지만 그 이후 Home Assistant가 다시 시작되지 않았습니다. Home Assistant를 다시 시작한 후 다시 시도하세요.", "row_actions_banner": "작업 행의 완료와 건너뛰기가 이제 버튼으로 표시됩니다. 이전 아이콘이 더 좋으신가요?", "row_actions_keep": "버튼 유지", "row_actions_back": "아이콘으로 되돌리기", diff --git a/custom_components/maintenance_supporter/frontend/locales/nb.json b/custom_components/maintenance_supporter/frontend/locales/nb.json index 0812410d..d2f52a80 100644 --- a/custom_components/maintenance_supporter/frontend/locales/nb.json +++ b/custom_components/maintenance_supporter/frontend/locales/nb.json @@ -252,6 +252,7 @@ "interval_anchor": "Intervallforankring", "anchor_completion": "Fra fullføringsdato", "anchor_planned": "Fra planlagt dato (ingen avdrift)", + "interval_anchor_hint": "Fra fullføringsdato: neste forfallsdato regnes fra dagen oppgaven markeres som utført, så tidlig eller sen utførelse flytter planen. Fra planlagt dato: planen beholder datoene sine, for eksempel når en regning betales uker før forfall.", "edit_object": "Rediger objekt", "name": "Navn", "manufacturer_optional": "Produsent (valgfritt)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Gi flere sensorer samme objektnavn, så blir de ett objekt; skriv navnet på et eksisterende objekt for å legge dem der.", "adopt_summary_title": "Hva overtakelsen endrer", "adopt_summary_existing": "«{name}»: oppgaver {before} → {after}", + "adopt_summary_totals": "Oppgaver: {tasks} · objekter: {objects}", + "adopt_summary_totals_new": "Oppgaver: {tasks} · objekter: {objects} (herav nye: {new})", "adopt_problem_adopt": "Ta i bruk valgte", "adopt_problem_done": "Tok i bruk {tasks} problemsensor(er)", "adopt_problem_done_one": "Tok i bruk 1 problemsensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.", "update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.", "update_reload": "Last inn på nytt", + "restart_pending_banner": "Maintenance Supporter er oppdatert til {version}. Start Home Assistant på nytt for å fullføre oppdateringen.", + "restart_pending_error": "Det fungerte ikke: Maintenance Supporter er oppdatert til {version}, men Home Assistant er ikke startet på nytt siden. Start Home Assistant på nytt, og prøv igjen.", "row_actions_banner": "Fullfør og Hopp over er nå knapper i oppgaveradene. Foretrekker du de gamle ikonene?", "row_actions_keep": "Behold knapper", "row_actions_back": "Tilbake til ikoner", diff --git a/custom_components/maintenance_supporter/frontend/locales/nl.json b/custom_components/maintenance_supporter/frontend/locales/nl.json index 9d6188e5..a0c5db30 100644 --- a/custom_components/maintenance_supporter/frontend/locales/nl.json +++ b/custom_components/maintenance_supporter/frontend/locales/nl.json @@ -250,6 +250,7 @@ "interval_anchor": "Interval-anker", "anchor_completion": "Vanaf voltooiing", "anchor_planned": "Vanaf geplande datum (geen drift)", + "interval_anchor_hint": "Vanaf voltooiing: de volgende vervaldatum telt vanaf de dag waarop de taak als gedaan wordt gemarkeerd, dus eerder of later uitvoeren verschuift de planning. Vanaf geplande datum: de planning houdt haar data, bijvoorbeeld wanneer een rekening weken voor de vervaldatum wordt betaald.", "edit_object": "Object bewerken", "name": "Naam", "manufacturer_optional": "Fabrikant (optioneel)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Geef meerdere sensoren dezelfde objectnaam en ze worden één object; typ de naam van een bestaand object om ze daar toe te voegen.", "adopt_summary_title": "Wat overnemen verandert", "adopt_summary_existing": "\"{name}\": taken {before} → {after}", + "adopt_summary_totals": "Taken: {tasks} · objecten: {objects}", + "adopt_summary_totals_new": "Taken: {tasks} · objecten: {objects} (waarvan nieuw: {new})", "adopt_problem_adopt": "Selectie overnemen", "adopt_problem_done": "{tasks} probleemsensor(en) overgenomen", "adopt_problem_done_one": "1 probleemsensor overgenomen", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.", "update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.", "update_reload": "Herladen", + "restart_pending_banner": "Maintenance Supporter is bijgewerkt naar {version}. Start Home Assistant opnieuw op om de update te voltooien.", + "restart_pending_error": "Dat is niet gelukt: Maintenance Supporter is bijgewerkt naar {version}, maar Home Assistant is sindsdien niet opnieuw opgestart. Start Home Assistant opnieuw op en probeer het dan nog eens.", "row_actions_banner": "Voltooien en Overslaan zijn in taakrijen nu knoppen. Liever de oude pictogrammen?", "row_actions_keep": "Knoppen houden", "row_actions_back": "Terug naar pictogrammen", diff --git a/custom_components/maintenance_supporter/frontend/locales/pl.json b/custom_components/maintenance_supporter/frontend/locales/pl.json index 8cc593a9..121753f3 100644 --- a/custom_components/maintenance_supporter/frontend/locales/pl.json +++ b/custom_components/maintenance_supporter/frontend/locales/pl.json @@ -250,6 +250,7 @@ "interval_anchor": "Punkt zaczepienia interwału", "anchor_completion": "Od daty wykonania", "anchor_planned": "Od daty planowanej (bez przesunięć)", + "interval_anchor_hint": "Od daty wykonania: następny termin liczy się od dnia oznaczenia zadania jako wykonanego, więc wcześniejsze lub późniejsze wykonanie przesuwa harmonogram. Od daty planowanej: harmonogram zachowuje swoje daty, na przykład gdy rachunek zostaje opłacony kilka tygodni przed terminem.", "edit_object": "Edytuj obiekt", "name": "Nazwa", "manufacturer_optional": "Producent (opcjonalne)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Nadaj kilku czujnikom tę samą nazwę obiektu, a staną się jednym obiektem; wpisz nazwę istniejącego obiektu, aby je tam dodać.", "adopt_summary_title": "Co zmieni przejęcie", "adopt_summary_existing": "„{name}”: zadania {before} → {after}", + "adopt_summary_totals": "Zadania: {tasks} · obiekty: {objects}", + "adopt_summary_totals_new": "Zadania: {tasks} · obiekty: {objects} (w tym nowe: {new})", "adopt_problem_adopt": "Przejmij zaznaczone", "adopt_problem_done": "Przejęto {tasks} czujnik(ów) problemów", "adopt_problem_done_one": "Przejęto 1 czujnik problemów", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.", "update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.", "update_reload": "Załaduj ponownie", + "restart_pending_banner": "Maintenance Supporter został zaktualizowany do wersji {version}. Uruchom ponownie Home Assistant, aby dokończyć aktualizację.", + "restart_pending_error": "Nie udało się: Maintenance Supporter został zaktualizowany do wersji {version}, ale od tego czasu Home Assistant nie został uruchomiony ponownie. Uruchom ponownie Home Assistant i spróbuj jeszcze raz.", "row_actions_banner": "Ukończ i Pomiń w wierszach zadań są teraz przyciskami. Wolisz stare ikony?", "row_actions_keep": "Zachowaj przyciski", "row_actions_back": "Wróć do ikon", diff --git a/custom_components/maintenance_supporter/frontend/locales/pt-br.json b/custom_components/maintenance_supporter/frontend/locales/pt-br.json index 40644d8a..e9a2ce42 100644 --- a/custom_components/maintenance_supporter/frontend/locales/pt-br.json +++ b/custom_components/maintenance_supporter/frontend/locales/pt-br.json @@ -252,6 +252,7 @@ "interval_anchor": "Âncora do intervalo", "anchor_completion": "A partir da data de conclusão", "anchor_planned": "A partir da data planejada (sem deriva)", + "interval_anchor_hint": "A partir da data de conclusão: o próximo vencimento conta a partir do dia em que a tarefa é marcada como feita, então fazê-la antes ou depois desloca o cronograma. A partir da data planejada: o cronograma mantém suas datas, por exemplo quando uma conta é paga semanas antes do vencimento.", "edit_object": "Editar objeto", "name": "Nome", "manufacturer_optional": "Fabricante (opcional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Dê a vários sensores o mesmo nome de objeto e eles viram um único objeto; digite o nome de um objeto existente para adicioná-los lá.", "adopt_summary_title": "O que a adoção muda", "adopt_summary_existing": "\"{name}\": tarefas {before} → {after}", + "adopt_summary_totals": "Tarefas: {tasks} · objetos: {objects}", + "adopt_summary_totals_new": "Tarefas: {tasks} · objetos: {objects} (novos: {new})", "adopt_problem_adopt": "Adotar selecionados", "adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)", "adopt_problem_done_one": "1 sensor de problema adotado", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.", "update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.", "update_reload": "Recarregar", + "restart_pending_banner": "O Maintenance Supporter foi atualizado para a versão {version}. Reinicie o Home Assistant para finalizar a atualização.", + "restart_pending_error": "Não deu certo: o Maintenance Supporter foi atualizado para a versão {version}, mas o Home Assistant ainda não foi reiniciado. Reinicie o Home Assistant e tente de novo.", "row_actions_banner": "Concluir e Pular agora são botões nas linhas de tarefas. Prefere os ícones antigos?", "row_actions_keep": "Manter botões", "row_actions_back": "Voltar aos ícones", diff --git a/custom_components/maintenance_supporter/frontend/locales/pt.json b/custom_components/maintenance_supporter/frontend/locales/pt.json index ba450d3c..92cbe10b 100644 --- a/custom_components/maintenance_supporter/frontend/locales/pt.json +++ b/custom_components/maintenance_supporter/frontend/locales/pt.json @@ -250,6 +250,7 @@ "interval_anchor": "Âncora do intervalo", "anchor_completion": "A partir da data de conclusão", "anchor_planned": "A partir da data planeada (sem desvio)", + "interval_anchor_hint": "A partir da data de conclusão: o próximo vencimento conta a partir do dia em que a tarefa é marcada como feita, por isso fazê-la antes ou depois desloca o calendário. A partir da data planeada: o calendário mantém as suas datas, por exemplo quando uma fatura é paga semanas antes do vencimento.", "edit_object": "Editar objeto", "name": "Nome", "manufacturer_optional": "Fabricante (opcional)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Dê a vários sensores o mesmo nome de objeto e tornam-se um só objeto; escreva o nome de um objeto existente para os juntar lá.", "adopt_summary_title": "O que a adoção altera", "adopt_summary_existing": "«{name}»: tarefas {before} → {after}", + "adopt_summary_totals": "Tarefas: {tasks} · objetos: {objects}", + "adopt_summary_totals_new": "Tarefas: {tasks} · objetos: {objects} (novos: {new})", "adopt_problem_adopt": "Adotar selecionados", "adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)", "adopt_problem_done_one": "1 sensor de problema adotado", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.", "update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.", "update_reload": "Recarregar", + "restart_pending_banner": "O Maintenance Supporter foi atualizado para a versão {version}. Reinicie o Home Assistant para concluir a atualização.", + "restart_pending_error": "Não foi possível: o Maintenance Supporter foi atualizado para a versão {version}, mas o Home Assistant ainda não foi reiniciado. Reinicie o Home Assistant e tente novamente.", "row_actions_banner": "Concluir e Ignorar são agora botões nas linhas de tarefas. Prefere os ícones antigos?", "row_actions_keep": "Manter botões", "row_actions_back": "Voltar aos ícones", diff --git a/custom_components/maintenance_supporter/frontend/locales/ru.json b/custom_components/maintenance_supporter/frontend/locales/ru.json index ee380d4e..0d09c092 100644 --- a/custom_components/maintenance_supporter/frontend/locales/ru.json +++ b/custom_components/maintenance_supporter/frontend/locales/ru.json @@ -249,6 +249,7 @@ "interval_anchor": "Якорь интервала", "anchor_completion": "От даты выполнения", "anchor_planned": "От плановой даты (без смещения)", + "interval_anchor_hint": "От даты выполнения: следующий срок отсчитывается от дня, когда задача отмечена выполненной, поэтому раннее или позднее выполнение сдвигает график. От плановой даты: график сохраняет свои даты, например когда счёт оплачен за несколько недель до срока.", "edit_object": "Изменить объект", "name": "Имя", "manufacturer_optional": "Производитель (опционально)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Дайте нескольким датчикам одно имя объекта — они станут одним объектом; введите имя существующего объекта, чтобы добавить их туда.", "adopt_summary_title": "Что изменит добавление", "adopt_summary_existing": "«{name}»: задачи {before} → {after}", + "adopt_summary_totals": "Задачи: {tasks} · объекты: {objects}", + "adopt_summary_totals_new": "Задачи: {tasks} · объекты: {objects} (из них новых: {new})", "adopt_problem_adopt": "Принять выбранные", "adopt_problem_done": "Принято сенсоров проблем: {tasks}", "adopt_problem_done_one": "Принят 1 сенсор проблем", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.", "update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.", "update_reload": "Перезагрузить", + "restart_pending_banner": "Maintenance Supporter обновлён до версии {version}. Перезапустите Home Assistant, чтобы завершить обновление.", + "restart_pending_error": "Не получилось: Maintenance Supporter обновлён до версии {version}, но Home Assistant с тех пор не перезапускался. Перезапустите Home Assistant и попробуйте ещё раз.", "row_actions_banner": "«Выполнено» и «Пропустить» в строках задач теперь кнопки. Предпочитаете старые значки?", "row_actions_keep": "Оставить кнопки", "row_actions_back": "Вернуть значки", diff --git a/custom_components/maintenance_supporter/frontend/locales/sv.json b/custom_components/maintenance_supporter/frontend/locales/sv.json index de74e3ac..a5964377 100644 --- a/custom_components/maintenance_supporter/frontend/locales/sv.json +++ b/custom_components/maintenance_supporter/frontend/locales/sv.json @@ -250,6 +250,7 @@ "interval_anchor": "Intervallankare", "anchor_completion": "Från slutförandedatum", "anchor_planned": "Från planerat datum (ingen drift)", + "interval_anchor_hint": "Från slutförandedatum: nästa förfallodatum räknas från dagen då uppgiften markeras som utförd, så att göra den tidigt eller sent flyttar schemat. Från planerat datum: schemat behåller sina datum, till exempel när en räkning betalas veckor före förfallodagen.", "edit_object": "Redigera objekt", "name": "Namn", "manufacturer_optional": "Tillverkare (valfritt)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Ge flera sensorer samma objektnamn så blir de ett objekt; skriv ett befintligt objekts namn för att lägga dem där.", "adopt_summary_title": "Vad övertagandet ändrar", "adopt_summary_existing": "”{name}”: uppgifter {before} → {after}", + "adopt_summary_totals": "Uppgifter: {tasks} · objekt: {objects}", + "adopt_summary_totals_new": "Uppgifter: {tasks} · objekt: {objects} (varav nya: {new})", "adopt_problem_adopt": "Anta valda", "adopt_problem_done": "Antog {tasks} problemsensor(er)", "adopt_problem_done_one": "Antog 1 problemsensor", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.", "update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.", "update_reload": "Ladda om", + "restart_pending_banner": "Maintenance Supporter har uppdaterats till {version}. Starta om Home Assistant för att slutföra uppdateringen.", + "restart_pending_error": "Det fungerade inte: Maintenance Supporter har uppdaterats till {version}, men Home Assistant har inte startats om sedan dess. Starta om Home Assistant och försök igen.", "row_actions_banner": "Slutför och Hoppa över är nu knappar i uppgiftsraderna. Föredrar du de gamla ikonerna?", "row_actions_keep": "Behåll knappar", "row_actions_back": "Tillbaka till ikoner", diff --git a/custom_components/maintenance_supporter/frontend/locales/tr.json b/custom_components/maintenance_supporter/frontend/locales/tr.json index ab761230..d1b031bf 100644 --- a/custom_components/maintenance_supporter/frontend/locales/tr.json +++ b/custom_components/maintenance_supporter/frontend/locales/tr.json @@ -252,6 +252,7 @@ "interval_anchor": "Aralık referansı", "anchor_completion": "Tamamlama tarihinden", "anchor_planned": "Planlanan tarihten (kayma olmaz)", + "interval_anchor_hint": "Tamamlama tarihinden: sonraki vade, görevin yapıldı olarak işaretlendiği günden itibaren sayılır; bu yüzden erken veya geç yapmak planı kaydırır. Planlanan tarihten: plan tarihlerini korur; örneğin bir fatura vadesinden haftalar önce ödendiğinde.", "edit_object": "Nesneyi Düzenle", "name": "Ad", "manufacturer_optional": "Üretici (isteğe bağlı)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Birden çok sensöre aynı nesne adını verin, tek nesne olurlar; mevcut bir nesnenin adını yazarsanız oraya eklenirler.", "adopt_summary_title": "Devralmanın değiştirecekleri", "adopt_summary_existing": "\"{name}\": görevler {before} → {after}", + "adopt_summary_totals": "Görevler: {tasks} · nesneler: {objects}", + "adopt_summary_totals_new": "Görevler: {tasks} · nesneler: {objects} (yeni: {new})", "adopt_problem_adopt": "Seçilenleri devral", "adopt_problem_done": "{tasks} sorun sensörü devralındı", "adopt_problem_done_one": "1 sorun sensörü devralındı", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.", "update_banner": "Sunucuda Maintenance Supporter'ın daha yeni bir sürümü var — paneli güncellemek için sayfayı yeniden yükleyin.", "update_reload": "Yeniden yükle", + "restart_pending_banner": "Maintenance Supporter {version} sürümüne güncellendi. Güncellemeyi tamamlamak için Home Assistant'ı yeniden başlatın.", + "restart_pending_error": "Bu işlem başarısız oldu: Maintenance Supporter {version} sürümüne güncellendi, ancak Home Assistant o zamandan beri yeniden başlatılmadı. Home Assistant'ı yeniden başlatın ve tekrar deneyin.", "row_actions_banner": "Görev satırlarındaki Tamamla ve Atla artık düğme. Eski simgeleri mi tercih edersiniz?", "row_actions_keep": "Düğmeleri koru", "row_actions_back": "Simgelere geri dön", diff --git a/custom_components/maintenance_supporter/frontend/locales/uk.json b/custom_components/maintenance_supporter/frontend/locales/uk.json index 687df054..c7f5ee98 100644 --- a/custom_components/maintenance_supporter/frontend/locales/uk.json +++ b/custom_components/maintenance_supporter/frontend/locales/uk.json @@ -249,6 +249,7 @@ "interval_anchor": "Прив'язка інтервалу", "anchor_completion": "Від дати виконання", "anchor_planned": "Від запланованої дати (без зміщення)", + "interval_anchor_hint": "Від дати виконання: наступний термін відраховується від дня, коли завдання позначено виконаним, тож раннє чи пізнє виконання зсуває графік. Від запланованої дати: графік зберігає свої дати, наприклад коли рахунок сплачено за кілька тижнів до терміну.", "edit_object": "Редагувати об'єкт", "name": "Назва", "manufacturer_optional": "Виробник (необов'язково)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "Дайте кільком сенсорам однакову назву об'єкта — вони стануть одним об'єктом; введіть назву наявного об'єкта, щоб додати їх туди.", "adopt_summary_title": "Що змінить додавання", "adopt_summary_existing": "«{name}»: завдання {before} → {after}", + "adopt_summary_totals": "Завдання: {tasks} · об'єкти: {objects}", + "adopt_summary_totals_new": "Завдання: {tasks} · об'єкти: {objects} (з них нових: {new})", "adopt_problem_adopt": "Прийняти вибрані", "adopt_problem_done": "Прийнято сенсорів проблем: {tasks}", "adopt_problem_done_one": "Прийнято 1 сенсор проблем", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.", "update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.", "update_reload": "Перезавантажити", + "restart_pending_banner": "Maintenance Supporter оновлено до версії {version}. Перезапустіть Home Assistant, щоб завершити оновлення.", + "restart_pending_error": "Не вдалося: Maintenance Supporter оновлено до версії {version}, але Home Assistant відтоді не перезапускався. Перезапустіть Home Assistant і спробуйте ще раз.", "row_actions_banner": "«Виконано» та «Пропустити» в рядках завдань тепер кнопки. Бажаєте старі значки?", "row_actions_keep": "Залишити кнопки", "row_actions_back": "Повернути значки", diff --git a/custom_components/maintenance_supporter/frontend/locales/zh.json b/custom_components/maintenance_supporter/frontend/locales/zh.json index 8df8effd..c6092572 100644 --- a/custom_components/maintenance_supporter/frontend/locales/zh.json +++ b/custom_components/maintenance_supporter/frontend/locales/zh.json @@ -252,6 +252,7 @@ "interval_anchor": "计划锚点", "anchor_completion": "从完成日期起算", "anchor_planned": "从计划日期起算 (无偏差)", + "interval_anchor_hint": "从完成日期起算:下一个到期日从任务标记为完成的那天开始计算,因此提前或推迟完成都会移动计划。从计划日期起算:计划保持原定日期,例如账单在到期前几周就已支付时。", "edit_object": "编辑维护项", "name": "名称", "manufacturer_optional": "制造商 (可选)", @@ -1121,6 +1122,8 @@ "adopt_object_hint": "给多个传感器相同的对象名称,它们就会合并为一个对象;输入已有对象的名称即可加入该对象。", "adopt_summary_title": "采用后会发生的变化", "adopt_summary_existing": "“{name}”:任务 {before} → {after}", + "adopt_summary_totals": "任务:{tasks} · 对象:{objects}", + "adopt_summary_totals_new": "任务:{tasks} · 对象:{objects}(其中新建:{new})", "adopt_problem_adopt": "采用所选", "adopt_problem_done": "已采用 {tasks} 个问题传感器", "adopt_problem_done_one": "已采用 1 个问题传感器", @@ -1282,6 +1285,8 @@ "battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。", "update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。", "update_reload": "重新加载", + "restart_pending_banner": "Maintenance Supporter 已更新到 {version} 版本。请重启 Home Assistant 以完成更新。", + "restart_pending_error": "操作失败:Maintenance Supporter 已更新到 {version} 版本,但 Home Assistant 此后尚未重启。请重启 Home Assistant 后重试。", "row_actions_banner": "任务行中的“完成”和“跳过”现在是按钮。更喜欢旧的图标?", "row_actions_keep": "保留按钮", "row_actions_back": "改回图标", diff --git a/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js b/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js index d0685e60..393d0f64 100644 --- a/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js +++ b/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js @@ -1,9 +1,9 @@ -/*! maintenance_supporter frontend 2.102.0 */ -var Hr=Object.defineProperty;var Ba=Object.getOwnPropertyDescriptor;var y=(s,r,e)=>()=>{if(e)throw e[0];try{return s&&(r=s(s=0)),r}catch(t){throw e=[t],t}};var Wa=(s,r)=>{for(var e in r)Hr(s,e,{get:r[e],enumerable:!0})};var c=(s,r,e,t)=>{for(var i=t>1?void 0:t?Ba(r,e):r,n=s.length-1,o;n>=0;n--)(o=s[n])&&(i=(t?o(r,e,i):o(i))||i);return t&&i&&Hr(r,e,i),i};var Nt,Ht,Ci,qr,pt,Or,E,Dr,Ii,Ri=y(()=>{Nt=globalThis,Ht=Nt.ShadowRoot&&(Nt.ShadyCSS===void 0||Nt.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Ci=Symbol(),qr=new WeakMap,pt=class{constructor(r,e,t){if(this._$cssResult$=!0,t!==Ci)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=r,this.t=e}get styleSheet(){let r=this.o,e=this.t;if(Ht&&r===void 0){let t=e!==void 0&&e.length===1;t&&(r=qr.get(e)),r===void 0&&((this.o=r=new CSSStyleSheet).replaceSync(this.cssText),t&&qr.set(e,r))}return r}toString(){return this.cssText}},Or=s=>new pt(typeof s=="string"?s:s+"",void 0,Ci),E=(s,...r)=>{let e=s.length===1?s[0]:r.reduce((t,i,n)=>t+(o=>{if(o._$cssResult$===!0)return o.cssText;if(typeof o=="number")return o;throw Error("Value passed to 'css' function must be a 'css' function result: "+o+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+s[n+1],s[0]);return new pt(e,s,Ci)},Dr=(s,r)=>{if(Ht)s.adoptedStyleSheets=r.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of r){let t=document.createElement("style"),i=Nt.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,s.appendChild(t)}},Ii=Ht?s=>s:s=>s instanceof CSSStyleSheet?(r=>{let e="";for(let t of r.cssRules)e+=t.cssText;return Or(e)})(s):s});var Va,Ga,Ka,Ya,Qa,Za,qt,jr,Ja,Xa,ut,ht,Ot,Mr,ce,_t=y(()=>{Ri();Ri();({is:Va,defineProperty:Ga,getOwnPropertyDescriptor:Ka,getOwnPropertyNames:Ya,getOwnPropertySymbols:Qa,getPrototypeOf:Za}=Object),qt=globalThis,jr=qt.trustedTypes,Ja=jr?jr.emptyScript:"",Xa=qt.reactiveElementPolyfillSupport,ut=(s,r)=>s,ht={toAttribute(s,r){switch(r){case Boolean:s=s?Ja:null;break;case Object:case Array:s=s==null?s:JSON.stringify(s)}return s},fromAttribute(s,r){let e=s;switch(r){case Boolean:e=s!==null;break;case Number:e=s===null?null:Number(s);break;case Object:case Array:try{e=JSON.parse(s)}catch{e=null}}return e}},Ot=(s,r)=>!Va(s,r),Mr={attribute:!0,type:String,converter:ht,reflect:!1,useDefault:!1,hasChanged:Ot};Symbol.metadata??=Symbol("metadata"),qt.litPropertyMetadata??=new WeakMap;ce=class extends HTMLElement{static addInitializer(r){this._$Ei(),(this.l??=[]).push(r)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(r,e=Mr){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(r)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(r,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(r,t,e);i!==void 0&&Ga(this.prototype,r,i)}}static getPropertyDescriptor(r,e,t){let{get:i,set:n}=Ka(this.prototype,r)??{get(){return this[e]},set(o){this[e]=o}};return{get:i,set(o){let d=i?.call(this);n?.call(this,o),this.requestUpdate(r,d,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(r){return this.elementProperties.get(r)??Mr}static _$Ei(){if(this.hasOwnProperty(ut("elementProperties")))return;let r=Za(this);r.finalize(),r.l!==void 0&&(this.l=[...r.l]),this.elementProperties=new Map(r.elementProperties)}static finalize(){if(this.hasOwnProperty(ut("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(ut("properties"))){let e=this.properties,t=[...Ya(e),...Qa(e)];for(let i of t)this.createProperty(i,e[i])}let r=this[Symbol.metadata];if(r!==null){let e=litPropertyMetadata.get(r);if(e!==void 0)for(let[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let i=this._$Eu(e,t);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(r){let e=[];if(Array.isArray(r)){let t=new Set(r.flat(1/0).reverse());for(let i of t)e.unshift(Ii(i))}else r!==void 0&&e.push(Ii(r));return e}static _$Eu(r,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof r=="string"?r.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(r=>this.enableUpdating=r),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(r=>r(this))}addController(r){(this._$EO??=new Set).add(r),this.renderRoot!==void 0&&this.isConnected&&r.hostConnected?.()}removeController(r){this._$EO?.delete(r)}_$E_(){let r=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(r.set(t,this[t]),delete this[t]);r.size>0&&(this._$Ep=r)}createRenderRoot(){let r=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return Dr(r,this.constructor.elementStyles),r}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(r=>r.hostConnected?.())}enableUpdating(r){}disconnectedCallback(){this._$EO?.forEach(r=>r.hostDisconnected?.())}attributeChangedCallback(r,e,t){this._$AK(r,t)}_$ET(r,e){let t=this.constructor.elementProperties.get(r),i=this.constructor._$Eu(r,t);if(i!==void 0&&t.reflect===!0){let n=(t.converter?.toAttribute!==void 0?t.converter:ht).toAttribute(e,t.type);this._$Em=r,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(r,e){let t=this.constructor,i=t._$Eh.get(r);if(i!==void 0&&this._$Em!==i){let n=t.getPropertyOptions(i),o=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:ht;this._$Em=i;let d=o.fromAttribute(e,n.type);this[i]=d??this._$Ej?.get(i)??d,this._$Em=null}}requestUpdate(r,e,t,i=!1,n){if(r!==void 0){let o=this.constructor;if(i===!1&&(n=this[r]),t??=o.getPropertyOptions(r),!((t.hasChanged??Ot)(n,e)||t.useDefault&&t.reflect&&n===this._$Ej?.get(r)&&!this.hasAttribute(o._$Eu(r,t))))return;this.C(r,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(r,e,{useDefault:t,reflect:i,wrapped:n},o){t&&!(this._$Ej??=new Map).has(r)&&(this._$Ej.set(r,o??e??this[r]),n!==!0||o!==void 0)||(this._$AL.has(r)||(this.hasUpdated||t||(e=void 0),this._$AL.set(r,e)),i===!0&&this._$Em!==r&&(this._$Eq??=new Set).add(r))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let r=this.scheduleUpdate();return r!=null&&await r,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,n]of t){let{wrapped:o}=n,d=this[i];o!==!0||this._$AL.has(i)||d===void 0||this.C(i,void 0,n,d)}}let r=!1,e=this._$AL;try{r=this.shouldUpdate(e),r?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw r=!1,this._$EM(),t}r&&this._$AE(e)}willUpdate(r){}_$AE(r){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(r)),this.updated(r)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(r){return!0}update(r){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(r){}firstUpdated(r){}};ce.elementStyles=[],ce.shadowRootOptions={mode:"open"},ce[ut("elementProperties")]=new Map,ce[ut("finalized")]=new Map,Xa?.({ReactiveElement:ce}),(qt.reactiveElementVersions??=[]).push("2.1.2")});function Qr(s,r){if(!qi(s)||!s.hasOwnProperty("raw"))throw Error("invalid template strings array");return Fr!==void 0?Fr.createHTML(r):r}function Re(s,r,e=s,t){if(r===Ie)return r;let i=t!==void 0?e._$Co?.[t]:e._$Cl,n=ft(r)?void 0:r._$litDirective$;return i?.constructor!==n&&(i?._$AO?.(!1),n===void 0?i=void 0:(i=new n(s),i._$AT(s,e,t)),t!==void 0?(e._$Co??=[])[t]=i:e._$Cl=i),i!==void 0&&(r=Re(s,i._$AS(s,r.values),i,t)),r}var Li,zr,Dt,Fr,Ni,de,Hi,eo,Ce,gt,ft,qi,Kr,Pi,mt,Ur,Br,Te,Wr,Vr,Yr,Oi,l,Ve,sc,Ie,u,Gr,Ae,Zr,vt,jt,We,Pe,Mt,zt,Ft,Ut,Jr,to,Xr,Ge=y(()=>{Li=globalThis,zr=s=>s,Dt=Li.trustedTypes,Fr=Dt?Dt.createPolicy("lit-html",{createHTML:s=>s}):void 0,Ni="$lit$",de=`lit$${Math.random().toFixed(9).slice(2)}$`,Hi="?"+de,eo=`<${Hi}>`,Ce=document,gt=()=>Ce.createComment(""),ft=s=>s===null||typeof s!="object"&&typeof s!="function",qi=Array.isArray,Kr=s=>qi(s)||typeof s?.[Symbol.iterator]=="function",Pi=`[ -\f\r]`,mt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Ur=/-->/g,Br=/>/g,Te=RegExp(`>|${Pi}(?:([^\\s"'>=/]+)(${Pi}*=${Pi}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),Wr=/'/g,Vr=/"/g,Yr=/^(?:script|style|textarea|title)$/i,Oi=s=>(r,...e)=>({_$litType$:s,strings:r,values:e}),l=Oi(1),Ve=Oi(2),sc=Oi(3),Ie=Symbol.for("lit-noChange"),u=Symbol.for("lit-nothing"),Gr=new WeakMap,Ae=Ce.createTreeWalker(Ce,129);Zr=(s,r)=>{let e=s.length-1,t=[],i,n=r===2?"":r===3?"":"",o=mt;for(let d=0;d"?(o=i??mt,f=-1):m[1]===void 0?f=-2:(f=o.lastIndex-m[2].length,_=m[1],o=m[3]===void 0?Te:m[3]==='"'?Vr:Wr):o===Vr||o===Wr?o=Te:o===Ur||o===Br?o=mt:(o=Te,i=void 0);let w=o===Te&&s[d+1].startsWith("/>")?" ":"";n+=o===mt?p+eo:f>=0?(t.push(_),p.slice(0,f)+Ni+p.slice(f)+de+w):p+de+(f===-2?d:w)}return[Qr(s,n+(s[e]||"")+(r===2?"":r===3?"":"")),t]},vt=class s{constructor({strings:r,_$litType$:e},t){let i;this.parts=[];let n=0,o=0,d=r.length-1,p=this.parts,[_,m]=Zr(r,e);if(this.el=s.createElement(_,t),Ae.currentNode=this.el.content,e===2||e===3){let f=this.el.content.firstChild;f.replaceWith(...f.childNodes)}for(;(i=Ae.nextNode())!==null&&p.length0){i.textContent=Dt?Dt.emptyScript:"";for(let w=0;w2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=u}_$AI(r,e=this,t,i){let n=this.strings,o=!1;if(n===void 0)r=Re(this,r,e,0),o=!ft(r)||r!==this._$AH&&r!==Ie,o&&(this._$AH=r);else{let d=r,p,_;for(r=n[0],p=0;p{let t=e?.renderBefore??r,i=t._$litPart$;if(i===void 0){let n=e?.renderBefore??null;t._$litPart$=i=new We(r.insertBefore(gt(),n),n,void 0,e??{})}return i._$AI(s),i}});var Di,S,io,es=y(()=>{_t();_t();Ge();Ge();Di=globalThis,S=class extends ce{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let r=super.createRenderRoot();return this.renderOptions.renderBefore??=r.firstChild,r}update(r){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(r),this._$Do=Xr(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return Ie}};S._$litElement$=!0,S.finalized=!0,Di.litElementHydrateSupport?.({LitElement:S});io=Di.litElementPolyfillSupport;io?.({LitElement:S});(Di.litElementVersions??=[]).push("4.2.2")});var ts=y(()=>{});var I=y(()=>{_t();Ge();es();ts()});var is=y(()=>{});function v(s){return(r,e)=>typeof e=="object"?so(s,r,e):((t,i,n)=>{let o=i.hasOwnProperty(n);return i.constructor.createProperty(n,t),o?Object.getOwnPropertyDescriptor(i,n):void 0})(s,r,e)}var ro,so,ji=y(()=>{_t();ro={attribute:!0,type:String,converter:ht,reflect:!1,hasChanged:Ot},so=(s=ro,r,e)=>{let{kind:t,metadata:i}=e,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),t==="setter"&&((s=Object.create(s)).wrapped=!0),n.set(e.name,s),t==="accessor"){let{name:o}=e;return{set(d){let p=r.get.call(this);r.set.call(this,d),this.requestUpdate(o,p,s,!0,d)},init(d){return d!==void 0&&this.C(o,void 0,s,d),d}}}if(t==="setter"){let{name:o}=e;return function(d){let p=this[o];r.call(this,d),this.requestUpdate(o,p,s,!0,d)}}throw Error("Unsupported decorator location: "+t)}});function h(s){return v({...s,state:!0,attribute:!1})}var rs=y(()=>{ji();});var ss=y(()=>{});var Ke=y(()=>{});var ns=y(()=>{Ke();});var as=y(()=>{Ke();});var os=y(()=>{Ke();});var ls=y(()=>{Ke();});var cs=y(()=>{Ke();});var F=y(()=>{is();ji();rs();ss();ns();as();os();ls();cs()});function ds(s){return s==="use"?"use":"purchase"}function ps(s){let r=Wt(s?.cost);if(r===null)return null;let e=Wt(s?.package_size);return e&&e>0?r/e:r}function Vt(s){let r=Wt(s.cost);if(s.cost_basis!=="use")return r;let e=Wt(s.parts_cost),t=s.purchase?null:r;return t===null&&e===null?null:(t??0)+(e??0)}var Wt,Gt=y(()=>{"use strict";Wt=s=>typeof s=="number"&&Number.isFinite(s)?s:null});function zi(s){return ao[s??""]??hs.length}var Mi,hs,ao,yt,Kt=y(()=>{"use strict";Mi={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"},hs=["overdue","triggered","due_soon","ok"],ao=Object.fromEntries(hs.map((s,r)=>[s,r]));yt={ok:"mdi:check-circle",due_soon:"mdi:alert-circle",overdue:"mdi:alert-octagon",triggered:"mdi:bell-alert",archived:"mdi:archive-outline",paused:"mdi:pause-circle-outline",completed:"mdi:check-circle",skipped:"mdi:skip-next",missed:"mdi:calendar-remove",reset:"mdi:refresh"}});function bt(s){let r=ms.get(s);if(r===void 0){try{r=new Intl.DateTimeFormat("en-US",{timeZone:s,hourCycle:"h23",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{r=null}ms.set(s,r)}return r}function fs(){if(Ui===void 0){let s;try{s=new Intl.DateTimeFormat().resolvedOptions().timeZone}catch{}Ui=s&&bt(s)?s:null}return Ui}function vs(){return fs()??"UTC"}function ge(){let s=gs.serverTimeZone;return s&&bt(s)?s:vs()}function fe(){let s=fs();return gs.timeZone!=="server"&&s?s:ge()}function Yt(s,r){let e=bt(r)??bt(vs())??bt("UTC"),t={};for(let i of e.formatToParts(s))i.type!=="literal"&&(t[i.type]=Number(i.value));return{y:t.year,mo:t.month,d:t.day,h:t.hour%24,mi:t.minute,s:t.second}}function ve(s,r){return Wi(Yt(+s,r))}function Qt(s,r){let e=Yt(+s,r);return`${Wi(e)}T${te(e.h)}:${te(e.mi)}`}function ye(){return ve(Date.now(),ge())}function ys(){return Qt(Date.now(),ge())}function Bi(s,r){let e=Yt(s,r);return Date.UTC(e.y,e.mo-1,e.d,e.h,e.mi,e.s)-Math.floor(s/1e3)*1e3}function lo(s,r,e,t,i,n,o){let d=Date.UTC(s,r-1,e,t,i,n),p=Bi(d-_s,o),_=Bi(d+_s,o);for(let m of p===_?[p]:[p,_])if(Bi(d-m,o)===m)return d-m;return d-p}function bs(s,r,e){if(s.has(r))return s.get(r);s.size>=uo&&s.clear();let t=e();return s.set(r,t),t}function Q(s){if(typeof s!="string"||!s)return NaN;let r=Vi.exec(s);if(!r)return Date.parse(s);let e=ge();return bs(po,`${e}|${s}`,()=>{let[t,i,n,o,d,p]=[r[1],r[2],r[3],r[4]??"0",r[5]??"0",r[6]??"0"].map(Number);if(i<1||i>12||new Date(Date.UTC(t,i-1,n)).getUTCDate()!==n||o>23||d>59||p>59)return NaN;let _=r[7]?Math.floor(+`0.${r[7]}`*1e3):0;return lo(t,i,n,o,d,p,e)+_})}function Zt(s){if(typeof s!="string"||!s)return null;let r=Vi.exec(s);if(r)return`${r[1]}-${r[2]}-${r[3]}`;let e=ge();return bs(co,`${e}|${s}`,()=>{let t=Date.parse(s);return Number.isFinite(t)?ve(t,e):/^\d{4}-\d{2}-\d{2}/.test(s)?s.slice(0,10):null})}function xs(s){if(typeof s!="string"||!s)return"";let r=Vi.exec(s);if(r)return`${r[1]}-${r[2]}-${r[3]}T${r[4]??"00"}:${r[5]??"00"}:${r[6]??"00"}`;let e=Date.parse(s);if(!Number.isFinite(e))return"";let t=Yt(e,ge());return`${Wi(t)}T${te(t.h)}:${te(t.mi)}:${te(t.s)}`}function Le(s,r){let e=ho.exec(s);if(!e)return s;let t=new Date(Date.UTC(Number(e[1]),Number(e[2])-1,Number(e[3])+Math.round(r)));return`${te(t.getUTCFullYear(),4)}-${te(t.getUTCMonth()+1)}-${te(t.getUTCDate())}`}var gs,_s,te,ms,Ui,Wi,Vi,co,po,uo,ho,Z=y(()=>{"use strict";gs=window.__msDateTimePrefs??={},_s=864e5,te=(s,r=2)=>String(s).padStart(r,"0"),ms=new Map;Wi=s=>`${te(s.y,4)}-${te(s.mo)}-${te(s.d)}`;Vi=/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?$/,co=new Map,po=new Map,uo=2e4;ho=/^(\d{4})-(\d{2})-(\d{2})$/});var Cs,As=y(()=>{Cs={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",trigger_removed:"Trigger removed",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",templates_task_count_one:"1 task",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",task_moved:"Task moved",move_task_target:"Target object",move_task_message:"Choose the object this task should belong to. Its history, readings and trigger state move with it; it gets a new reference number and its entities are recreated under the new object. Linked documents stay with the current object.",bulk_move_message:"Choose the object the selected tasks should belong to. Tasks that already live there are skipped; history, readings and trigger state move along.",bulk_moved:"{n} tasks moved",more_actions:"More actions",move_task_title:"Move task",move_task:"Move to another object\u2026",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",snoozed_for:"Reminders muted for {hours} hours \u2014 until {until}",snoozed_for_one:"Reminders muted for one hour \u2014 until {until}",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",bulk_delete_objects_confirm:"Delete {n} objects and all their tasks? Archiving keeps their history instead.",bulk_objects_deleted:"{n} objects deleted",bulk_objects_archived:"{n} objects archived",bulk_assign:"Assign",bulk_assign_one:"One person",bulk_assign_rotate:"Several people, in turns",bulk_assign_hint:"Replaces who is assigned to the selected tasks \u2014 a rotation included.",bulk_rotation_min_two:"Pick at least two people.",bulk_labels_add:"Add labels",bulk_labels_remove:"Remove labels",bulk_edit_hint:"Only the ticked settings change; everything else stays as it is.",bulk_no_notifications:"No notifications for these tasks",bulk_updated:"{n} tasks updated",bulk_paused:"{n} tasks paused",bulk_resumed:"{n} tasks resumed",bulk_objects_area:"Area set for {n} objects",bulk_failed:"{n} failed: {reason}",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",last_done_days_ago:"done {n} d ago",last_done_today:"done today",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",trigger_type_due_date:"Date from a sensor",runtime_hours:"Target runtime (hours)",days_before:"Days before the date",days_before_help:`For a sensor that reports when the maintenance is due (a date or timestamp). 0 = on the date itself. The device's own "done" button moves the date forward, which clears the trigger \u2014 make it the completion action.`,target_value:"Target value",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",calendar:"Calendar entity",calendar_entity_label:"Calendar",calendar_entity_hint:"Due once per event of this calendar, on the event's start date \u2014 the next event after the last completion.",calendar_entity_required:"Choose a calendar entity.",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",cost_kind_credit:"Credit",cost_credit_hint:"Money back \u2014 a sale, a refund. It lowers the totals; the average per completion leaves it out.",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",minutes_short:"min",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",require_on_completion:"Require on completion",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter +/*! maintenance_supporter frontend 2.103.0 */ +var qr=Object.defineProperty;var Wa=Object.getOwnPropertyDescriptor;var y=(s,r,e)=>()=>{if(e)throw e[0];try{return s&&(r=s(s=0)),r}catch(t){throw e=[t],t}};var Va=(s,r)=>{for(var e in r)qr(s,e,{get:r[e],enumerable:!0})};var c=(s,r,e,t)=>{for(var i=t>1?void 0:t?Wa(r,e):r,n=s.length-1,o;n>=0;n--)(o=s[n])&&(i=(t?o(r,e,i):o(i))||i);return t&&i&&qr(r,e,i),i};var Nt,Ht,Ci,Or,pt,Dr,E,jr,Ii,Ri=y(()=>{Nt=globalThis,Ht=Nt.ShadowRoot&&(Nt.ShadyCSS===void 0||Nt.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Ci=Symbol(),Or=new WeakMap,pt=class{constructor(r,e,t){if(this._$cssResult$=!0,t!==Ci)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=r,this.t=e}get styleSheet(){let r=this.o,e=this.t;if(Ht&&r===void 0){let t=e!==void 0&&e.length===1;t&&(r=Or.get(e)),r===void 0&&((this.o=r=new CSSStyleSheet).replaceSync(this.cssText),t&&Or.set(e,r))}return r}toString(){return this.cssText}},Dr=s=>new pt(typeof s=="string"?s:s+"",void 0,Ci),E=(s,...r)=>{let e=s.length===1?s[0]:r.reduce((t,i,n)=>t+(o=>{if(o._$cssResult$===!0)return o.cssText;if(typeof o=="number")return o;throw Error("Value passed to 'css' function must be a 'css' function result: "+o+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+s[n+1],s[0]);return new pt(e,s,Ci)},jr=(s,r)=>{if(Ht)s.adoptedStyleSheets=r.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of r){let t=document.createElement("style"),i=Nt.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,s.appendChild(t)}},Ii=Ht?s=>s:s=>s instanceof CSSStyleSheet?(r=>{let e="";for(let t of r.cssRules)e+=t.cssText;return Dr(e)})(s):s});var Ga,Ka,Ya,Qa,Za,Ja,qt,Mr,Xa,eo,ut,ht,Ot,zr,ce,_t=y(()=>{Ri();Ri();({is:Ga,defineProperty:Ka,getOwnPropertyDescriptor:Ya,getOwnPropertyNames:Qa,getOwnPropertySymbols:Za,getPrototypeOf:Ja}=Object),qt=globalThis,Mr=qt.trustedTypes,Xa=Mr?Mr.emptyScript:"",eo=qt.reactiveElementPolyfillSupport,ut=(s,r)=>s,ht={toAttribute(s,r){switch(r){case Boolean:s=s?Xa:null;break;case Object:case Array:s=s==null?s:JSON.stringify(s)}return s},fromAttribute(s,r){let e=s;switch(r){case Boolean:e=s!==null;break;case Number:e=s===null?null:Number(s);break;case Object:case Array:try{e=JSON.parse(s)}catch{e=null}}return e}},Ot=(s,r)=>!Ga(s,r),zr={attribute:!0,type:String,converter:ht,reflect:!1,useDefault:!1,hasChanged:Ot};Symbol.metadata??=Symbol("metadata"),qt.litPropertyMetadata??=new WeakMap;ce=class extends HTMLElement{static addInitializer(r){this._$Ei(),(this.l??=[]).push(r)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(r,e=zr){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(r)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(r,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(r,t,e);i!==void 0&&Ka(this.prototype,r,i)}}static getPropertyDescriptor(r,e,t){let{get:i,set:n}=Ya(this.prototype,r)??{get(){return this[e]},set(o){this[e]=o}};return{get:i,set(o){let d=i?.call(this);n?.call(this,o),this.requestUpdate(r,d,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(r){return this.elementProperties.get(r)??zr}static _$Ei(){if(this.hasOwnProperty(ut("elementProperties")))return;let r=Ja(this);r.finalize(),r.l!==void 0&&(this.l=[...r.l]),this.elementProperties=new Map(r.elementProperties)}static finalize(){if(this.hasOwnProperty(ut("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(ut("properties"))){let e=this.properties,t=[...Qa(e),...Za(e)];for(let i of t)this.createProperty(i,e[i])}let r=this[Symbol.metadata];if(r!==null){let e=litPropertyMetadata.get(r);if(e!==void 0)for(let[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let i=this._$Eu(e,t);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(r){let e=[];if(Array.isArray(r)){let t=new Set(r.flat(1/0).reverse());for(let i of t)e.unshift(Ii(i))}else r!==void 0&&e.push(Ii(r));return e}static _$Eu(r,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof r=="string"?r.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(r=>this.enableUpdating=r),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(r=>r(this))}addController(r){(this._$EO??=new Set).add(r),this.renderRoot!==void 0&&this.isConnected&&r.hostConnected?.()}removeController(r){this._$EO?.delete(r)}_$E_(){let r=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(r.set(t,this[t]),delete this[t]);r.size>0&&(this._$Ep=r)}createRenderRoot(){let r=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return jr(r,this.constructor.elementStyles),r}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(r=>r.hostConnected?.())}enableUpdating(r){}disconnectedCallback(){this._$EO?.forEach(r=>r.hostDisconnected?.())}attributeChangedCallback(r,e,t){this._$AK(r,t)}_$ET(r,e){let t=this.constructor.elementProperties.get(r),i=this.constructor._$Eu(r,t);if(i!==void 0&&t.reflect===!0){let n=(t.converter?.toAttribute!==void 0?t.converter:ht).toAttribute(e,t.type);this._$Em=r,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(r,e){let t=this.constructor,i=t._$Eh.get(r);if(i!==void 0&&this._$Em!==i){let n=t.getPropertyOptions(i),o=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:ht;this._$Em=i;let d=o.fromAttribute(e,n.type);this[i]=d??this._$Ej?.get(i)??d,this._$Em=null}}requestUpdate(r,e,t,i=!1,n){if(r!==void 0){let o=this.constructor;if(i===!1&&(n=this[r]),t??=o.getPropertyOptions(r),!((t.hasChanged??Ot)(n,e)||t.useDefault&&t.reflect&&n===this._$Ej?.get(r)&&!this.hasAttribute(o._$Eu(r,t))))return;this.C(r,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(r,e,{useDefault:t,reflect:i,wrapped:n},o){t&&!(this._$Ej??=new Map).has(r)&&(this._$Ej.set(r,o??e??this[r]),n!==!0||o!==void 0)||(this._$AL.has(r)||(this.hasUpdated||t||(e=void 0),this._$AL.set(r,e)),i===!0&&this._$Em!==r&&(this._$Eq??=new Set).add(r))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let r=this.scheduleUpdate();return r!=null&&await r,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,n]of t){let{wrapped:o}=n,d=this[i];o!==!0||this._$AL.has(i)||d===void 0||this.C(i,void 0,n,d)}}let r=!1,e=this._$AL;try{r=this.shouldUpdate(e),r?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw r=!1,this._$EM(),t}r&&this._$AE(e)}willUpdate(r){}_$AE(r){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(r)),this.updated(r)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(r){return!0}update(r){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(r){}firstUpdated(r){}};ce.elementStyles=[],ce.shadowRootOptions={mode:"open"},ce[ut("elementProperties")]=new Map,ce[ut("finalized")]=new Map,eo?.({ReactiveElement:ce}),(qt.reactiveElementVersions??=[]).push("2.1.2")});function Zr(s,r){if(!qi(s)||!s.hasOwnProperty("raw"))throw Error("invalid template strings array");return Ur!==void 0?Ur.createHTML(r):r}function Re(s,r,e=s,t){if(r===Ie)return r;let i=t!==void 0?e._$Co?.[t]:e._$Cl,n=ft(r)?void 0:r._$litDirective$;return i?.constructor!==n&&(i?._$AO?.(!1),n===void 0?i=void 0:(i=new n(s),i._$AT(s,e,t)),t!==void 0?(e._$Co??=[])[t]=i:e._$Cl=i),i!==void 0&&(r=Re(s,i._$AS(s,r.values),i,t)),r}var Li,Fr,Dt,Ur,Ni,de,Hi,to,Ce,gt,ft,qi,Yr,Pi,mt,Br,Wr,Te,Vr,Gr,Qr,Oi,l,Ve,oc,Ie,u,Kr,Ae,Jr,vt,jt,We,Pe,Mt,zt,Ft,Ut,Xr,io,es,Ge=y(()=>{Li=globalThis,Fr=s=>s,Dt=Li.trustedTypes,Ur=Dt?Dt.createPolicy("lit-html",{createHTML:s=>s}):void 0,Ni="$lit$",de=`lit$${Math.random().toFixed(9).slice(2)}$`,Hi="?"+de,to=`<${Hi}>`,Ce=document,gt=()=>Ce.createComment(""),ft=s=>s===null||typeof s!="object"&&typeof s!="function",qi=Array.isArray,Yr=s=>qi(s)||typeof s?.[Symbol.iterator]=="function",Pi=`[ +\f\r]`,mt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Br=/-->/g,Wr=/>/g,Te=RegExp(`>|${Pi}(?:([^\\s"'>=/]+)(${Pi}*=${Pi}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`,"g"),Vr=/'/g,Gr=/"/g,Qr=/^(?:script|style|textarea|title)$/i,Oi=s=>(r,...e)=>({_$litType$:s,strings:r,values:e}),l=Oi(1),Ve=Oi(2),oc=Oi(3),Ie=Symbol.for("lit-noChange"),u=Symbol.for("lit-nothing"),Kr=new WeakMap,Ae=Ce.createTreeWalker(Ce,129);Jr=(s,r)=>{let e=s.length-1,t=[],i,n=r===2?"":r===3?"":"",o=mt;for(let d=0;d"?(o=i??mt,f=-1):m[1]===void 0?f=-2:(f=o.lastIndex-m[2].length,_=m[1],o=m[3]===void 0?Te:m[3]==='"'?Gr:Vr):o===Gr||o===Vr?o=Te:o===Br||o===Wr?o=mt:(o=Te,i=void 0);let w=o===Te&&s[d+1].startsWith("/>")?" ":"";n+=o===mt?p+to:f>=0?(t.push(_),p.slice(0,f)+Ni+p.slice(f)+de+w):p+de+(f===-2?d:w)}return[Zr(s,n+(s[e]||"")+(r===2?"":r===3?"":"")),t]},vt=class s{constructor({strings:r,_$litType$:e},t){let i;this.parts=[];let n=0,o=0,d=r.length-1,p=this.parts,[_,m]=Jr(r,e);if(this.el=s.createElement(_,t),Ae.currentNode=this.el.content,e===2||e===3){let f=this.el.content.firstChild;f.replaceWith(...f.childNodes)}for(;(i=Ae.nextNode())!==null&&p.length0){i.textContent=Dt?Dt.emptyScript:"";for(let w=0;w2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=u}_$AI(r,e=this,t,i){let n=this.strings,o=!1;if(n===void 0)r=Re(this,r,e,0),o=!ft(r)||r!==this._$AH&&r!==Ie,o&&(this._$AH=r);else{let d=r,p,_;for(r=n[0],p=0;p{let t=e?.renderBefore??r,i=t._$litPart$;if(i===void 0){let n=e?.renderBefore??null;t._$litPart$=i=new We(r.insertBefore(gt(),n),n,void 0,e??{})}return i._$AI(s),i}});var Di,S,ro,ts=y(()=>{_t();_t();Ge();Ge();Di=globalThis,S=class extends ce{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let r=super.createRenderRoot();return this.renderOptions.renderBefore??=r.firstChild,r}update(r){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(r),this._$Do=es(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return Ie}};S._$litElement$=!0,S.finalized=!0,Di.litElementHydrateSupport?.({LitElement:S});ro=Di.litElementPolyfillSupport;ro?.({LitElement:S});(Di.litElementVersions??=[]).push("4.2.2")});var is=y(()=>{});var I=y(()=>{_t();Ge();ts();is()});var rs=y(()=>{});function v(s){return(r,e)=>typeof e=="object"?no(s,r,e):((t,i,n)=>{let o=i.hasOwnProperty(n);return i.constructor.createProperty(n,t),o?Object.getOwnPropertyDescriptor(i,n):void 0})(s,r,e)}var so,no,ji=y(()=>{_t();so={attribute:!0,type:String,converter:ht,reflect:!1,hasChanged:Ot},no=(s=so,r,e)=>{let{kind:t,metadata:i}=e,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),t==="setter"&&((s=Object.create(s)).wrapped=!0),n.set(e.name,s),t==="accessor"){let{name:o}=e;return{set(d){let p=r.get.call(this);r.set.call(this,d),this.requestUpdate(o,p,s,!0,d)},init(d){return d!==void 0&&this.C(o,void 0,s,d),d}}}if(t==="setter"){let{name:o}=e;return function(d){let p=this[o];r.call(this,d),this.requestUpdate(o,p,s,!0,d)}}throw Error("Unsupported decorator location: "+t)}});function h(s){return v({...s,state:!0,attribute:!1})}var ss=y(()=>{ji();});var ns=y(()=>{});var Ke=y(()=>{});var as=y(()=>{Ke();});var os=y(()=>{Ke();});var ls=y(()=>{Ke();});var cs=y(()=>{Ke();});var ds=y(()=>{Ke();});var F=y(()=>{rs();ji();ss();ns();as();os();ls();cs();ds()});function ps(s){return s==="use"?"use":"purchase"}function us(s){let r=Wt(s?.cost);if(r===null)return null;let e=Wt(s?.package_size);return e&&e>0?r/e:r}function Vt(s){let r=Wt(s.cost);if(s.cost_basis!=="use")return r;let e=Wt(s.parts_cost),t=s.purchase?null:r;return t===null&&e===null?null:(t??0)+(e??0)}var Wt,Gt=y(()=>{"use strict";Wt=s=>typeof s=="number"&&Number.isFinite(s)?s:null});function zi(s){return oo[s??""]??_s.length}var Mi,_s,oo,yt,Kt=y(()=>{"use strict";Mi={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"},_s=["overdue","triggered","due_soon","ok"],oo=Object.fromEntries(_s.map((s,r)=>[s,r]));yt={ok:"mdi:check-circle",due_soon:"mdi:alert-circle",overdue:"mdi:alert-octagon",triggered:"mdi:bell-alert",archived:"mdi:archive-outline",paused:"mdi:pause-circle-outline",completed:"mdi:check-circle",skipped:"mdi:skip-next",missed:"mdi:calendar-remove",reset:"mdi:refresh"}});function bt(s){let r=gs.get(s);if(r===void 0){try{r=new Intl.DateTimeFormat("en-US",{timeZone:s,hourCycle:"h23",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{r=null}gs.set(s,r)}return r}function vs(){if(Ui===void 0){let s;try{s=new Intl.DateTimeFormat().resolvedOptions().timeZone}catch{}Ui=s&&bt(s)?s:null}return Ui}function ys(){return vs()??"UTC"}function ge(){let s=fs.serverTimeZone;return s&&bt(s)?s:ys()}function fe(){let s=vs();return fs.timeZone!=="server"&&s?s:ge()}function Yt(s,r){let e=bt(r)??bt(ys())??bt("UTC"),t={};for(let i of e.formatToParts(s))i.type!=="literal"&&(t[i.type]=Number(i.value));return{y:t.year,mo:t.month,d:t.day,h:t.hour%24,mi:t.minute,s:t.second}}function ve(s,r){return Wi(Yt(+s,r))}function Qt(s,r){let e=Yt(+s,r);return`${Wi(e)}T${te(e.h)}:${te(e.mi)}`}function ye(){return ve(Date.now(),ge())}function bs(){return Qt(Date.now(),ge())}function Bi(s,r){let e=Yt(s,r);return Date.UTC(e.y,e.mo-1,e.d,e.h,e.mi,e.s)-Math.floor(s/1e3)*1e3}function co(s,r,e,t,i,n,o){let d=Date.UTC(s,r-1,e,t,i,n),p=Bi(d-ms,o),_=Bi(d+ms,o);for(let m of p===_?[p]:[p,_])if(Bi(d-m,o)===m)return d-m;return d-p}function xs(s,r,e){if(s.has(r))return s.get(r);s.size>=ho&&s.clear();let t=e();return s.set(r,t),t}function Q(s){if(typeof s!="string"||!s)return NaN;let r=Vi.exec(s);if(!r)return Date.parse(s);let e=ge();return xs(uo,`${e}|${s}`,()=>{let[t,i,n,o,d,p]=[r[1],r[2],r[3],r[4]??"0",r[5]??"0",r[6]??"0"].map(Number);if(i<1||i>12||new Date(Date.UTC(t,i-1,n)).getUTCDate()!==n||o>23||d>59||p>59)return NaN;let _=r[7]?Math.floor(+`0.${r[7]}`*1e3):0;return co(t,i,n,o,d,p,e)+_})}function Zt(s){if(typeof s!="string"||!s)return null;let r=Vi.exec(s);if(r)return`${r[1]}-${r[2]}-${r[3]}`;let e=ge();return xs(po,`${e}|${s}`,()=>{let t=Date.parse(s);return Number.isFinite(t)?ve(t,e):/^\d{4}-\d{2}-\d{2}/.test(s)?s.slice(0,10):null})}function ws(s){if(typeof s!="string"||!s)return"";let r=Vi.exec(s);if(r)return`${r[1]}-${r[2]}-${r[3]}T${r[4]??"00"}:${r[5]??"00"}:${r[6]??"00"}`;let e=Date.parse(s);if(!Number.isFinite(e))return"";let t=Yt(e,ge());return`${Wi(t)}T${te(t.h)}:${te(t.mi)}:${te(t.s)}`}function Le(s,r){let e=_o.exec(s);if(!e)return s;let t=new Date(Date.UTC(Number(e[1]),Number(e[2])-1,Number(e[3])+Math.round(r)));return`${te(t.getUTCFullYear(),4)}-${te(t.getUTCMonth()+1)}-${te(t.getUTCDate())}`}var fs,ms,te,gs,Ui,Wi,Vi,po,uo,ho,_o,Z=y(()=>{"use strict";fs=window.__msDateTimePrefs??={},ms=864e5,te=(s,r=2)=>String(s).padStart(r,"0"),gs=new Map;Wi=s=>`${te(s.y,4)}-${te(s.mo)}-${te(s.d)}`;Vi=/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?$/,po=new Map,uo=new Map,ho=2e4;_o=/^(\d{4})-(\d{2})-(\d{2})$/});var Is,Cs=y(()=>{Is={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",trigger_removed:"Trigger removed",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",templates_task_count_one:"1 task",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",task_moved:"Task moved",move_task_target:"Target object",move_task_message:"Choose the object this task should belong to. Its history, readings and trigger state move with it; it gets a new reference number and its entities are recreated under the new object. Linked documents stay with the current object.",bulk_move_message:"Choose the object the selected tasks should belong to. Tasks that already live there are skipped; history, readings and trigger state move along.",bulk_moved:"{n} tasks moved",more_actions:"More actions",move_task_title:"Move task",move_task:"Move to another object\u2026",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",snoozed_for:"Reminders muted for {hours} hours \u2014 until {until}",snoozed_for_one:"Reminders muted for one hour \u2014 until {until}",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",bulk_delete_objects_confirm:"Delete {n} objects and all their tasks? Archiving keeps their history instead.",bulk_objects_deleted:"{n} objects deleted",bulk_objects_archived:"{n} objects archived",bulk_assign:"Assign",bulk_assign_one:"One person",bulk_assign_rotate:"Several people, in turns",bulk_assign_hint:"Replaces who is assigned to the selected tasks \u2014 a rotation included.",bulk_rotation_min_two:"Pick at least two people.",bulk_labels_add:"Add labels",bulk_labels_remove:"Remove labels",bulk_edit_hint:"Only the ticked settings change; everything else stays as it is.",bulk_no_notifications:"No notifications for these tasks",bulk_updated:"{n} tasks updated",bulk_paused:"{n} tasks paused",bulk_resumed:"{n} tasks resumed",bulk_objects_area:"Area set for {n} objects",bulk_failed:"{n} failed: {reason}",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",last_done_days_ago:"done {n} d ago",last_done_today:"done today",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",trigger_type_due_date:"Date from a sensor",runtime_hours:"Target runtime (hours)",days_before:"Days before the date",days_before_help:`For a sensor that reports when the maintenance is due (a date or timestamp). 0 = on the date itself. The device's own "done" button moves the date forward, which clears the trigger \u2014 make it the completion action.`,target_value:"Target value",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",calendar:"Calendar entity",calendar_entity_label:"Calendar",calendar_entity_hint:"Due once per event of this calendar, on the event's start date \u2014 the next event after the last completion.",calendar_entity_required:"Choose a calendar entity.",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",cost_kind_credit:"Credit",cost_credit_hint:"Money back \u2014 a sale, a refund. It lowers the totals; the average per completion leaves it out.",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",minutes_short:"min",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",require_on_completion:"Require on completion",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter Replace seal -Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",ws_err_not_found:"Not found \u2014 it may have been deleted meanwhile",ws_err_invalid_input:"Invalid input",ws_err_invalid_date:"Invalid date",ws_err_invalid_format:"Invalid format",ws_err_invalid_url:"Invalid link",ws_err_invalid_mirror_todo:"Invalid to-do list",ws_err_not_configured:"Not set up yet",ws_err_not_loaded:"Not loaded yet \u2014 try again in a moment",ws_err_limit_reached:"Limit reached",ws_err_too_many:"Too many entries",ws_err_too_large:"Too large",ws_err_not_archived:"Not archived",ws_err_invalid_target:"Invalid target",ws_err_create_failed:"Could not be created",ws_err_archived:"Archived \u2014 restore it first",ws_err_already_archived:"Already archived",ws_err_unavailable:"Currently unavailable",ws_err_unauthorized:"Not permitted for your user",ws_err_too_many_views:"Too many saved views",ws_err_storage_unavailable:"Storage is not available",ws_err_replace_failed:"Replacement failed",ws_err_not_paused:"Not paused",ws_err_not_available:"Nothing available",ws_err_no_url:"No link given",ws_err_no_phases:"This task has no phases",ws_err_invalid_view:"Invalid view",ws_err_invalid_user:"Unknown user",ws_err_invalid_range:"Invalid range",ws_err_invalid_parent:"Invalid parent object",ws_err_invalid_device:"Unknown device",ws_err_empty_csv:"The CSV file is empty",ws_err_empty:"Nothing to import",ws_err_duplicate_failed:"Could not duplicate",ws_err_already_paused:"Already paused",ws_err_invalid_cursor:"Invalid phase position",ws_err_invalid_entity_slug:"Invalid entity name",ws_err_invalid_trigger_config:"Invalid trigger configuration",ws_err_no_defaults:"No quick-complete defaults set",ws_err_self_link_device:"An object cannot be linked to itself",ws_err_too_early:"Too early \u2014 this task can only be completed closer to its due date",ws_err_invalid_search_template:"The search link must be an http(s) URL containing {q}",ws_err_invalid_icon:"The notification icon must be an mdi: name, e.g. mdi:air-filter",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_toggle:"Due at a specific time",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",notes_markdown_hint:"Markdown is supported \u2014 **bold**, lists, [links](\u2026)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",completed_at_optional:"Completed at (optional, empty = now)",completed_at_pick:"Set date & time",completed_at_future_error:"The completion date cannot be in the future.",ha_time_zone_hint:"Time in Home Assistant's time zone ({zone})",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",task_mirror_todo:"Mirror into to-do lists",task_mirror_todo_hint:"The task appears in these lists while it is due; checking it off there completes it here.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",all_priorities:"All priorities",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",object_replaces:"Replaces",object_replaced_by:"Replaced by",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",all_parts:"All parts",all_areas:"All areas",areas_filter:"Filter areas\u2026",areas_filter_none:"No area matches the filter.",area_cost_year:"Cost {year}",area_object_count:"{n} objects",area_object_count_one:"1 object",area_range_12m:"Last 12 months",area_range_all:"All time",area_task_filter:"Filter by task\u2026",area_kpi_completions:"Completions",area_kpi_total_time:"Total time",area_cost_per_month:"Cost per month",area_cost_per_year:"Cost per year",area_cost_per_object:"Cost per object",area_cost_share:"Share",area_history_section:"History (all objects)",area_report_print:"Area report (PDF)",area_report_title:"Area report",area_report_period:"Period",area_no_costs:"No costs recorded in this period.",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",trigger_equals:"Trigger when equal to (=)",trigger_not_equals:"Trigger when different from (\u2260)",for_at_least_minutes:"For at least (minutes)",safety_interval:"Safety interval (optional)",trigger_combinator:"Combine trigger and interval",trigger_combinator_any:"Trigger or interval (whichever first)",trigger_combinator_all:"Trigger and interval (both required)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",adaptive_prediction_enabled:"Enable sensor-driven predictions",adaptive_seasonal_enabled:"Enable seasonal awareness",adaptive_max_interval:"Maximum interval (days)",adaptive_min_interval:"Minimum interval (days)",adaptive_ewa_alpha:"Learning rate (alpha)",adaptive_enabled:"Enable adaptive scheduling",adaptive_section_title:"Adaptive Scheduling",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_every_n_days:"every {n} days",cal_every_day:"every day",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",clear:"Clear",date_type_toggle:"Type the date",date_type_invalid:"Not a valid date \u2014 try 1978-03-15, a year like 1978, or your own date format",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",subsave_warning:"Saved, but one setting was rejected: {detail}",qr_print:"Print",qr_download:"Download SVG",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_action_quick_complete:"Quick-complete \u2014 no dialog",qr_print_quick_none:"Quick-complete codes need quick-complete defaults on a task (Settings \u2192 Advanced Features \u2192 Completion actions; task dialog \u2192 Quick-complete defaults) \u2014 none of the chosen objects' tasks has them yet.",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",strat_this_month:"This Month",strat_later:"Later",strat_week:"Week",strat_fortnight:"Fortnight",strat_month:"Month",strat_year:"Year",strat_other:"Other",strat_status:"Status",strat_empty_title:"No maintenance objects yet",strat_empty_content:"Open the Maintenance panel to add your first object \u2014 pool pump, HVAC filter, vehicle, anything that needs scheduled care.",strat_open_panel:"Open Maintenance panel",strat_add_object:"Add object",strat_not_loaded:"**Maintenance Supporter** is not loaded. Install/enable the integration first.",strat_group_by:"Group views by",strat_group_area:"By area (default)",strat_group_status:"By status (Overdue / Triggered / Due Soon / OK)",strat_group_floor:"By floor (uses HA floors)",strat_group_due_date:"By due date (Overdue / Today / Week / Month / Later)",strat_group_calendar:"Rolling calendar (Week / Fortnight / Month / Year)",strat_editor_help:'The "Overview" view is always first. Empty groups are skipped.',picker_name_calendar:"Maintenance Supporter \u2014 Calendar",picker_name_panel:"Maintenance Supporter \u2014 Panel",picker_name_budget:"Maintenance Supporter \u2014 Budget",picker_name_groups:"Maintenance Supporter \u2014 Groups",picker_name_vacation:"Maintenance Supporter \u2014 Vacation",picker_name_battery_fleet:"Battery Fleet",picker_name_section:"Maintenance Supporter \u2014 Section",picker_name_vacation_status:"Maintenance Supporter \u2014 Vacation Status",picker_name_budget_status:"Maintenance Supporter \u2014 Budget Status",picker_desc_card:"Overview of your maintenance tasks with quick actions.",picker_desc_calendar:"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",picker_desc_panel:"The complete Maintenance Supporter panel as a card \u2014 for a panel view / dashboard subview without the sidebar entry.",picker_desc_budget:"Edit the monthly and yearly budget right on the dashboard.",picker_desc_groups:"Create, rename and delete task groups right on the dashboard.",picker_desc_vacation:"Switch vacation mode on or off and set its dates right on the dashboard.",picker_desc_battery_fleet:"All tracked batteries: what is low now, what runs out soon, and what to buy.",picker_desc_dashboard:"Auto-generated dashboard. Group views by area, status, floor, or due date \u2014 picked from the strategy editor or YAML.",picker_desc_section:"Embed maintenance tasks (filterable by area, status, due date) as a section in any dashboard view.",picker_desc_vacation_status:"Compact vacation-mode status banner. Tap to open settings in the panel.",picker_desc_budget_status:"Monthly + yearly maintenance budget overview. Tap for details.",picker_desc_groups_status:"List of configured task groups with member counts.",hist_note_button:"Completed from dashboard button",hist_note_todo:"Completed from the To-do list",hist_note_voice:"Completed by voice",hist_note_notification:"Completed from the notification",hist_note_nfc:"Completed via NFC tag",hist_note_shopping_list:"Completed from the shopping list",hist_note_todo_mirror:"Completed from a mirrored to-do list",hist_note_skipped_notification:"Skipped from notification",hist_note_skipped_button:"Skipped from dashboard button",hist_note_sensor_triggered:"Sensor trigger activated",hist_note_initial:"Initial value set during task creation",hist_note_auto_recovered:"Auto-completed: sensor recovered ({value})",hist_note_reset:"Reset to {date}",hist_note_trigger_replaced:"Trigger entity replaced: {old} \u2192 {new}",hist_note_trigger_removed:"Sensor trigger removed (entity was: {entity}). Schedule converted to {schedule}.",hist_note_compound_removed:"Entity {entity} removed from compound trigger; {count} conditions remain.",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_action_style:"Complete button style",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_areas:"Filter by areas",card_filter_areas_help:"Empty = show all areas.",card_filter_priority_help:"Empty = show all priorities. Tasks without an explicit priority count as Normal.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",place_optional:"Place (zone, optional)",remind_on_site:"Remind only while somebody is there",remind_on_site_hint:"Reminders wait until somebody is in this zone. Whoever arrives there gets a message with what is due.",place_missing:"Zone not found: reminders as at home",place_home:"Home",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_delete_entry:"Delete entry",history_delete_confirm_entry:"Delete this history entry? The task's last-performed date falls back to the previous completion; photos stay with the object.",history_delete_returns_parts:"The parts it used go back to stock: {parts}.",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",notify_icon:"Notification icon",notify_icon_hint:"Shown by the Companion app on Android. Empty = the icon of this task's maintenance type ({default}).",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects, tasks, spare parts, documents, notes\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close \xB7 / opens the search anywhere",search_open:"Search",search_group_parts:"Spare parts",search_group_content:"In documents",search_group_history:"History notes",search_page:"Page {page}",search_empty_hint:"Type to search objects, tasks, spare parts, documents and notes",search_searching:"Searching documents and notes\u2026",search_index_status:"Full-text search: {indexed} of {total} files indexed \xB7 {no_text} without text layer \xB7 {pending} pending",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_consumable_threshold:"Consumable low threshold (%)",settings_battery_low_percent:"Battery low threshold (%)",settings_battery_recovered_percent:"Battery counts as replaced above (%)",settings_battery_recovered_percent_hint:"A low battery stays counted as low until its level rises above this value or a replacement is recorded \u2014 so a level hovering around the low threshold cannot complete the fleet task again and again.",settings_battery_auto_record:"Record a replacement automatically when a low battery recovers",settings_battery_auto_record_hint:"When a battery that was low rises above the recovery threshold, the replacement date is recorded in Battery Notes and the spare cells are taken from stock \u2014 no tap needed.",settings_battery_lifetimes:"Typical battery lifetimes",settings_battery_lifetimes_hint:'Only used for batteries without a percentage: a Battery Notes note that has just a type and a last-replaced date, or a sensor that only reports "low". Their due date is last replaced + this lifetime. Batteries that report a level get their forecast from the measured discharge instead. Once the fleet has seen three replacements of a type, the median interval takes over from the built-in value \u2014 your own value always wins.',settings_battery_lifetime_months:"months",settings_battery_lifetime_reset:"Use default",settings_battery_lifetime_in_fleet:"in your fleet",settings_battery_lifetime_learned_models:"Learned per model: {list}",settings_thresholds_hint:"Defaults for new Suggested setups (percent-remaining consumables) and for fleet batteries without their own Battery Notes threshold. Existing tasks keep their value.",settings_part_search_url:"Shopping search link",settings_part_search_url_hint:"URL with {q} in place of the search terms, used for parts without a product link. Leave empty for the automatic store (by country, then by language).",settings_value_out_of_range:"Value must be between {min} and {max}",bn_summary:"{name}: {pct} % for {n} batteries",bn_floor_decides:"Your {floor} % floor decides for all of them; the higher of the two thresholds counts per battery.",bn_above_floor:"That is above your {floor} % floor, so {name} decides for all noted batteries.",bn_overrides:"{n} devices with their own threshold:",bn_more:"+ {n} more",settings_row_actions:"Task row actions",settings_ref_numbers_in_lists:"Reference numbers in lists",settings_ref_numbers_in_lists_hint:"Show the #8 / #8.3 chips in front of object and task names in the Today list, the task table, the object cards and the objects table.",settings_compact_refs:"Compact reference numbers",settings_compact_refs_hint:"Renumbers all objects, tasks and completions in order (1, 2, 3 \u2026) and closes the gaps left by deletions. Numbers already printed on service booklets, photos or notes will no longer match.",settings_compact_refs_confirm:"Renumber every object, task and completion sequentially in creation order? Numbers already written on printed service booklets, photos or notes will no longer match. This cannot be undone.",settings_compact_refs_done:"Renumbered {objects} objects, {tasks} tasks and {completions} completions.",row_actions_buttons_compact:"Buttons (icons only on phones)",row_actions_buttons:"Buttons with text",row_actions_icons:"Icons (classic)",row_actions_follow:"Follow the household setting",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",settings_shopping_list:"Shopping list (buy tasks)",settings_shopping_list_help:"Low-part buy reminders appear in this to-do list; checking one off restocks the part.",shopping_list_none:"Off \u2014 no shopping list",settings_install_assist_sentences:"Install Assist sentences",settings_install_assist_sentences_hint:"Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",notify_per_person:"Per-person delivery",member_avatars:"Member avatars",member_avatars_hint:"Shown next to assigned tasks \u2014 initials in the member's colour. Defaults come from the name; set your own initials or pick a colour to tell members apart.",member_initials:"Initials",member_color:"Colour",member_avatar_reset:"Reset",notify_no_own_device:"No own device \u2014 uses the household service",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_snooze_hours_hint:"How long Snooze mutes a task's reminders \u2014 from the task menu, a card, voice or the phone's Snooze button. The due date stays as it is.",settings_budget:"Budget",settings_currency:"Currency",settings_currency_decimals:"Decimal places for amounts",settings_currency_decimals_hint:"How many decimals every amount shows: KPIs, budgets, costs in lists and reports, budget alerts. 0 = whole numbers.",settings_parts_cost_mode:"Spare parts count as spending",settings_parts_cost_purchase:"When bought",settings_parts_cost_use:"When used",settings_parts_cost_mode_hint:"When bought: a buy reminder's cost counts, and the parts a job uses are shown as information. When used: every completion books the value of the parts it used, and a purchase sets the part's price instead of counting. Applies to completions from now on \u2014 history keeps how it was booked.",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_export_settings:"Export settings (JSON)",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_import_unmatched_users:"Not found on this instance: {names}. Their task assignments were removed \u2014 add people with the same names in Home Assistant and import again to keep them.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON and YAML exports list your documents but not their files; CSV leaves documents out. This ZIP holds the uploaded files \u2014 restore it after importing the JSON and nothing is missing.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",settings_docs_import_missing:"{n} skipped \u2014 their files were not in the ZIP",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",groupby_object:"By object",filter_label:"Filter",user_label:"User",photo_label:"Photo",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",state_latch_help:"With 1 transition the trigger is a latch: it recovers when the entity leaves the To-state \u2014 or, with only a From-state set, when it returns to that state.",for_minutes_state_help:"0 counts every change immediately. Set minutes and the new state must hold that long first \u2014 brief flickers then neither trigger nor count.",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else only gets Complete, Skip, Reset, Postpone and Snooze.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",feat_places:"Places",feat_places_desc:"Objects maintained at another place (a Home Assistant zone): reminders only while somebody is there, and a message on arrival.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_target_more:"This action targets more than this field shows: {targets}. That is kept as it is \u2014 choosing another entity here replaces the whole target.",on_complete_action_skip_auto:"Not when the task completes itself (its sensor recovered)",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",action_test_bad_format:"Invalid service format (expected 'domain.service')",action_test_unknown_service:'Service "{service}" is not registered in Home Assistant. Check the spelling and that the integration providing it is loaded.',action_test_domain_mismatch:`Service "{service}" only works on {domain}.* entities; entity "{entity}" is in {entity_domain}.* \u2014 pick a service that matches the entity's domain (e.g. {suggestion})`,action_test_entity_missing:'Target entity "{entity}" was not found in Home Assistant \u2014 it may have been renamed or its integration removed.',quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_completed_named:"Completed: {task}",undo_done:"Completion undone",undo_done_action_ran:"Completion undone. Its completion action had already run and cannot be taken back.",undo_nothing:"Nothing to undo. Only your own last completion can be taken back, within 10 minutes.",undo_changed:"Can't undo. The task has changed since.",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",section_collapse:"Collapse section",section_expand:"Expand section",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 100 MB).",doc_upload_failed:"Upload failed.",docs_archive_too_large:"The archive is too large (max {max}).",docs_archive_no_manifest:"This ZIP has no manifest.json, so the restore cannot tell which document a file belongs to. Keep manifest.json next to the folders when zipping again.",completion_photos_optional:"Completion photos (optional)",completion_photos:"Completion photos",choose_photos:"Choose photos",choose_photo:"Choose photo",photos_android_hint:"The Android app hands over one photo per pick \u2014 each pick is added.",photos_limit:"Up to {max} photos per completion",history_edit_photos_hint:"Removing a photo here keeps the file in the object's documents.",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_not_allowed:"This link is not allowed: links that run scripts or open local files (javascript:, data:, file: \u2026) are refused.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",camera_capture_shoot:"Capture",camera_switch_lens:"Switch camera",camera_switching:"Switching camera\u2026",camera_not_answered:"This camera did not answer. Tap the switch again to try the next one, or use the file picker.",camera_diagnostics:"Details for a bug report",camera_copy:"Copy",camera_copied:"Copied",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",doc_description:"Description (optional)",doc_sort:"Sort documents",doc_sort_newest:"Newest first",doc_sort_oldest:"Oldest first",doc_sort_title:"By title (1, 2, 3 \u2026)",doc_sort_category:"By category",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_task:"Pause",resume_task:"Resume",pause_task_prompt:"Freeze this task's schedule \u2014 it does not become due and does not notify until you resume it, and resuming starts a fresh cycle from that day. The object's other tasks keep running. Optionally set a date to resume by itself.",task_paused:"Task paused",task_resumed:"Task resumed \u2014 its schedule restarted",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",replace_device_heading:"Device in Home Assistant",replace_device_keep:"Same device as before ({device})",replace_device_keep_hint:"Also the choice when the new unit is not in Home Assistant yet: once it is, pick it as the object's device in its settings \u2014 the sensor triggers move along then.",replace_device_other:"The new unit is another device",replace_device_pick:"Device of the new unit",replace_device_other_hint:"Sensor triggers, completion actions and the counter reset move to the matching entities of that device.",replace_device_none:"No device",object_replaced:"Object replaced \u2014 successor created",device_swap_moved:"{count} sensor links moved to the new device.",device_swap_moved_one:"One sensor link moved to the new device.",device_swap_unmatched:"{count} could not be matched \u2014 check those tasks' sensor triggers.",device_swap_unmatched_one:"One could not be matched \u2014 check that task's sensor trigger.",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",readings_section:"Readings",readings_hint:"Several named values per completion \u2014 one row per meter. Leave empty to record a single value.",reading_name_label:"Reading name",reading_unit_short:"Unit",reading_add:"Add reading",reading_duplicate_name:"Name already used",reading_last:"last: {value}",reading_below_last:"Lower than the last reading ({value})",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,home_profile_title:"Home profile",home_profile_hint:"Recommends templates and sets seasonal windows for your climate and hemisphere. Everything is worked out locally from your floors, areas and home location.",home_type_label:"Home type",home_type_auto:"Automatic ({detected})",home_region_label:"State, province or region",home_region_none:"not found",home_region_hint:"Some rules differ within a country \u2014 a yearly vehicle inspection in one state, none in the next. Found from your home location.",home_dwelling_house:"House",home_dwelling_apartment:"Apartment",home_dwelling_unknown:"not recognised",home_detected_from:"Detected from: {reasons}",home_reason_floors:"several floors",home_reason_basement:"a basement",home_reason_garden:"a garden",home_reason_shed:"a shed",home_reason_driveway:"a driveway",home_reason_garage:"a garage",home_reason_attic:"an attic",home_reason_lawn_mower:"a lawn mower",home_reason_gate:"a gate",home_reason_balcony:"a balcony",home_reason_small_home:"few rooms on one floor",home_reason_ups:"a UPS",home_climate_label:"Climate",home_climate_value:"K\xF6ppen {koppen} \xB7 coldest month {cold} \xB0C \xB7 warmest month {warm} \xB0C",home_climate_unknown:"No home location set in Home Assistant",home_hemisphere_south:"Southern hemisphere \u2014 seasonal windows are mirrored",home_trait_freeze:"Frost in winter",home_trait_snow:"Snowy winters",home_trait_severe_winter:"Severe winters",home_trait_damp:"Damp climate",home_trait_mediterranean:"Mediterranean climate",home_trait_earthquake:"Earthquake region",home_trait_hot_summer:"Hot summers",home_trait_hot_humid:"Hot and humid",home_trait_hot_dry:"Hot and dry",home_trait_termites:"Termite region",home_trait_wildfire:"Wildfire risk",home_trait_cyclone:"Hurricane / typhoon region",home_trait_tropical:"Tropical climate",home_trait_radon:"Radon test recommended",home_reason_starter:"Basics for your home",home_reason_country:"Common in {country}",home_reason_feature:"Found in your home: {feature}",templates_recommended_title:"Recommended for your home",templates_recommended_hint:"Based on your home profile \u2014 adjust it in Settings.",templates_legal_hint:"Notes on legal duties and intervals are guidance, not legal advice. Rules vary by region and change over time \u2014 local regulations and the manufacturer's instructions take precedence.",templates_not_typical:"Not typical for your home type",templates_set_up:"Already set up",worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",parts_inventory_value:"Inventory value",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_package_size:"Package size (units)",part_restock_packages:"Restock amount (packages)",part_package_hint:"Bought in packages, used in the unit: stock, the amount a task uses and the reorder threshold count units; the restock amount and the price are per package.",part_auto_buy:"Auto-create buy task when low",part_reuse:"After use",part_reuse_none:"Used up",part_reuse_charge:"Charged",part_reuse_sharpen:"Sharpened",part_reuse_clean:"Cleaned",part_reuse_dry:"Dried",part_reuse_refill:"Refilled",part_reuse_restore:"Restored",part_waiting_charge:"{count} to charge",part_waiting_sharpen:"{count} to sharpen",part_waiting_clean:"{count} to clean",part_waiting_dry:"{count} to dry",part_waiting_refill:"{count} to refill",part_waiting_restore:"{count} to restore",part_restoring:"Used, not ready yet",part_ready_again_hint:"All used ones are ready again",part_reuse_hint:"A used one is not gone: it waits until it is ready again and still counts as owned, so it is not bought again.",part_restock:"Adjust stock",parts_used_by:"Used by",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",task_parts_fleet_hint:"Battery quantities come from Battery Notes per device \u2014 nothing to link here.",shared_parts_other_objects:"Parts from other objects",shared_parts_help:"Several objects can share one stock. Completing this task takes from the owning object.",shared_part_unknown:"Unknown part",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_object_hint:"Give several sensors the same object name and they become one object; type an existing object's name to add them there.",adopt_summary_title:"What adopting changes",adopt_summary_existing:'"{name}": tasks {before} \u2192 {after}',adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",adopt_problem_done_one:"Adopted 1 problem sensor",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_overlap:"These limits overlap: every reading triggers the task and it can never recover. Leave one of them empty.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_due_date:"The task becomes due {days} days before the date the sensor reports.",trigger_hint_due_date_one:"The task becomes due one day before the date the sensor reports.",trigger_hint_due_date_same_day:"The task becomes due on the date the sensor reports.",trigger_hint_due_date_now:"Reported date: {date}.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",filter_place:"Place",filter_place_all:"All places",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",settings_notify_completed:"Completion notifications",settings_notify_completed_hint:`Household news when a task is completed, with the reason and who did it. "Automatic only" announces just the completions nobody made on the spot: sensor recovery, shopping-list check-off, an automation's service call. Sent to the household service only; quiet hours and the daily cap apply.`,settings_title_style:"Notification title style",settings_title_style_hint:"What appears as the notification's TITLE. Default keeps the per-status text (e.g. 'Maintenance overdue!'). Object name / Task name help when phone notifications stack.",title_style_default:"Default (per-status title)",title_style_object_name:"Object name as title",title_style_task_name:"Task name as title",notify_completed_off:"Off",notify_completed_automatic:"Automatic completions only",notify_completed_all:"All completions",settings_notify_rule:"Your own notification rule",settings_notify_rule_hint:"Every notification also fires the event maintenance_supporter_notification (kind, status, object, task, reference numbers, priority, deep link, the payload). Route it with an automation \u2014 e.g. into Ticker, Telegram or Pushover.",settings_notify_event_only:"Only fire the event, send nothing myself",settings_notify_event_only_hint:"Your automation is the whole delivery. Rate limits, quiet hours and the daily cap still apply to when the event fires.",settings_notify_extra_data:"Extra notification data (template)",settings_notify_extra_data_hint:"JSON or YAML, Jinja allowed; merged into the notify call's data (your keys win). Variables: kind, status, priority, labels, notes, area_name, object_name, task_name, object_ref, task_ref, days_until_due, next_due, last_performed, responsible_user_id, sensor_entity_id, url, target, title, message. Test it with the Send test button.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user, label and priority filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",panel_card_tab:"Open on tab",panel_card_tab_default:"Remembered tab (as in the panel)",panel_card_view:"Open with saved view",panel_card_height:"Height",panel_card_height_help:"Empty = fill the screen below the card (panel view). Otherwise a CSS length such as 600px or 80vh for views with other cards.",panel_card_not_registered:"The Maintenance Supporter panel is not available \u2014 is the integration set up?",panel_card_load_failed:"The Maintenance Supporter panel could not be loaded \u2014 reload the page (after an update: clear the browser cache).",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",setups_done_one:"1 sensor-wired task created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",runtime_max_session:"Max session runtime (seconds)",runtime_max_session_help:"A single run counts at most this many seconds \u2014 protects against a sensor stuck ON (lost connection, restart). Empty = no cap.",trend_approaching:"Heading toward the threshold",trend_easing:"Easing away from the threshold",split_select_hint:"Select a task to see its details here",setups_target_new:"Create new: {name}",setups_target_match:"{name} \u2014 matches: {reasons}",setups_reason_model:"model number",setups_reason_name:"name",setups_reason_area:"area",setups_reason_sibling:"same model on another device",setups_maybe_covered:'Probably there already as "{name}"',setups_already:"Already there: {names}",setups_reset_line:"Completing it also resets the counter in the integration (\u201C{button}\u201D)",setups_reset_enable:"\u2014 the button gets switched on for this",reset_offers_title:"Reset counters on completion",reset_offers_hint:"These tasks watch a counter the integration keeps itself. Connected, completing the task here also presses the integration's reset button \u2014 otherwise the counter keeps running and the task falls due again right away.",reset_offers_before:"Now: completing it leaves the counter in {integration} running",reset_offers_after:"Then: it also presses \u201C{button}\u201D",reset_offers_renamed:"Renamed since it was set up \u2014 check that this task is for this part",reset_offers_apply:"Connect resets",reset_offers_done:"{count} tasks now reset their counter on completion",reset_offers_done_one:"1 task now resets its counter on completion",setups_before_after:"Tasks on the object: {before} \u2192 {after}",setups_new_object_count:'New object "{name}" \xB7 tasks: {count}',schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_for_minutes_hint:"Only trigger once the problem has persisted this long \u2014 0 reacts to the first flicker.",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_progress:"{n} to replace",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_no_sensor:"No sensor",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_exclude:"Exclude from the fleet",battery_fleet_excluded:"Excluded",battery_fleet_include:"Track again",battery_fleet_all:"All tracked batteries",battery_fleet_all_hint:"Exclude a device here to drop it from the fleet before it ever reports low \u2014 a vacuum that recharges itself, or a phone that warns you on its own.",battery_fleet_add:"Add a battery",battery_fleet_add_hint:"Pick a battery sensor the automatic discovery missed \u2014 it joins the roster immediately.",battery_fleet_track_self:"Track self-charging batteries",battery_fleet_track_self_hint:"Phones, vacuums and other devices that recharge themselves appear as rechargeables \u2014 a low one asks for a charge, never for new cells.",battery_fleet_due_without_sensor:"Treat a passed forecast as due when a battery has no sensor",battery_fleet_due_without_sensor_hint:"Some devices report no battery level at all \u2014 Battery Notes only knows the type and the last replacement. Once the typical lifetime has passed, such a battery counts as low and the fleet task reminds you; marking it replaced resets the forecast.",battery_fleet_status_low:"Low",battery_fleet_status_soon:"Soon",battery_fleet_status_ok:"Healthy",battery_fleet_status_due:"Due",battery_fleet_predicted_on:"Expected around {date}",battery_fleet_predicted_typical:"Expected around {date} \u2014 {months} months typical for {type} ({source})",lifetime_source_default:"built-in default",lifetime_source_table:"built-in table",lifetime_source_learned:"learned from {n} replacements",lifetime_source_override:"your setting",lifetime_source_learned_device:"learned from this device's {n} replacements",lifetime_source_learned_model:"learned from {n} replacements on devices of this model",battery_fleet_predicted_trend:"Predicted from this battery's discharge trend: around {date} ({confidence})",battery_fleet_rechargeable:"Rechargeable: charge instead of replacing \u2014 never on the shopping list",battery_fleet_sort_name:"Sort by name",battery_fleet_sort_urgency:"Sort by urgency",battery_fleet_mark_recharged:"Mark as recharged",battery_fleet_charging:"Rechargeable spares",battery_fleet_ready:"{count} ready",battery_fleet_sparkline_hint:"Battery level over the last 30 days \u2014 dotted: projected until the low threshold",battery_fleet_filter_type:"Show only this battery type",battery_fleet_record_replacement:"The level jumped around {date} \u2014 record this replacement in Battery Notes",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries.",update_banner:"A newer version of Maintenance Supporter is on the server \u2014 reload to update the panel.",update_reload:"Reload",row_actions_banner:"Complete and Skip in task rows are now buttons. Prefer the old icons?",row_actions_keep:"Keep buttons",row_actions_back:"Back to icons",battery_fleet_forecast_overdue:"Predicted date passed \u2014 the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",cost_from_parts:"Use \u2248 {amount} from parts",cost_parts_booked:"Parts used \u2248 {amount} are booked automatically \u2014 enter only additional costs.",cost_parts_info:"Parts used \u2248 {amount} \u2014 counted when they were bought, not here.",cost_purchase_use_hint:"The price paid becomes the part's price \u2014 spare parts count when they are used.",history_parts_counted:"Parts: {amount}",history_parts_counted_hint:"Value of the parts used \u2014 counted as spending",history_parts_info:"Parts \u2248 {amount}",history_parts_info_hint:"Value of the parts used \u2014 information only; the money counted when they were bought",history_purchase_stock:"Bought for {amount} (counted when used)",dismiss:"Dismiss",gs_label:"Getting started \u2014 these hints retire as your setup grows",gs_setups_chip:"Suggested setups found {n} devices with pre-wired triggers",gs_setups_chip_one:"Suggested setups found 1 device with pre-wired triggers",gs_adopt_chip:"{n} problem sensors can become maintenance tasks",gs_adopt_chip_one:"1 problem sensor can become a maintenance task",gs_fleet_chip:"One click sets up the battery fleet",gs_reset_chip:"{n} tasks can reset their counter in the integration",gs_reset_chip_one:"1 task can reset its counter in the integration",cal_editor_window:"Default window",cal_editor_window_week:"Week (7 days)",cal_editor_window_fortnight:"Fortnight (14 days)",cal_editor_window_month:"Month (30 days, default)",cal_editor_window_year:"Year (365 days, empty days collapsed)",cal_editor_window_past_30:"Past 30 days (history)",cal_editor_window_past_90:"Past 90 days (history)",cal_editor_show_chips:"Show window chips inside the card",cal_editor_chips_hint:"Hide the chips when the card is embedded in a strategy view that already serves as the window selector.",cal_editor_show_user_filter:"Show user filter dropdown",cal_editor_default_user:"Default user filter",cal_editor_my_tasks:"My tasks (current user)",cal_editor_show_object_filter:"Show object filter dropdown",cal_editor_object_hint:'Pre-select one object via YAML: object_filter: "" \u2014 or a list of names to restrict the card to several objects.',object_history_section:"History (all tasks)",object_history_all_tasks:"All tasks",object_history_empty:"No entries in this range.",object_history_cap_note:"History keeps up to 500 entries per task \u2014 very old entries may be missing.",service_record_title:"Service record",service_record_print:"Service record (PDF)",date:"Date",service_record_entries:"entries",ref_number:"Reference number",print_options_title:"Print service record",print_layout:"Layout",print_layout_chronological:"Chronological",print_layout_by_task:"By task",print_include:"Include",print_inc_readings:"Readings",print_inc_parts:"Parts used",print_inc_photos:"Photos",print_inc_documents:"Linked documents",print_inc_doc_desc:"Document descriptions",print_inc_checklist:"Checklist",print_inc_notes:"Notes",print_inc_costs:"Cost and duration",print_inc_person:"Completed by",print_inc_refs:"Reference numbers",print_inc_bare:"Completions without details",print_inc_qr:"QR code per task",print_button:"Print",report_scan_hint:"Scan to open the task",completed_by:"Completed by",date_from:"From",date_to:"To",phases_section:"Cycle phases (optional)",phases_hint:"Different work on one shared schedule \u2014 each completion moves to the next step (e.g. small service, small service, big service).",phase_add:"Add phase",phase_name:"Phase name",phase_sequence_label:"Cycle order",phase_sequence_add_step:"Add step",phase_current:"Current phase",phase_set:"Set as current",chart_history_fallback:"No long-term statistics for this entity \u2014 showing recorder state history (typically the last ~10 days)",chart_history_alarm:"Trigger view from recorder state history: 1 = alert state held for the hold time, 0 = fine (typically the last ~10 days)",chart_history_count:"Change count rebuilt from recorder state history since the last service (typically the last ~10 days)",prediction_cycles:"Learned from cycles",phase_require_override:"Override \u201CRequire on completion\u201D for this phase",history_add_past:"Add past completion",require_tag_scan:"Only complete by scanning the tag",require_tag_scan_help:"Proof of presence: Done is blocked on every surface until the NFC tag or QR code on the thing itself is scanned. Automations can pass 'via_tag_scan' to the complete service.",disallow_skip:"Don't allow skipping",disallow_skip_help:"The Skip action disappears from every surface, and the server refuses a skip from automations and voice too.",no_notifications:"No notifications for this task",no_notifications_help:"No reminders for this task \u2014 no status notifications, repeats, lead-time reminders or bundles. It still shows on the dashboard and in its entities; the weekly digest still counts it.",require_tag_scan_hint:"This task completes only by scanning its NFC tag or QR code on the thing itself \u2014 saving here will be refused."}});var Is,Rs=y(()=>{"use strict";Is="2.102.0"});function Ki(s){Ne.en=Object.assign({},s,Ne.en??{})}function be(s){let r=(s||Gi).toLowerCase();return r.startsWith("pt")&&r.endsWith("br")?"pt-br":r.substring(0,2)}function a(s,r,e){let t=be(r);return Ne[t]?.[s]??Ne.en?.[s]??e??s}function He(s){let r=be(s);return r===Gi||r in Ne}function qe(s){let r=be(s);return r===Gi||r in Ne||!bo.has(r)?Promise.resolve():(r in xt||(xt[r]=fetch(`${xo}/${r}.json?v=${Is}`).then(e=>e.ok?e.json():null).then(e=>{e?Ne[r]=e:delete xt[r]}).catch(()=>{delete xt[r]})),xt[r])}var Gi,Ps,Ne,xt,bo,xo,Xt=y(()=>{"use strict";Rs();Gi="en",Ps=(()=>{let s=window;return s.__msLocales||(s.__msLocales={store:{},inflight:{}}),s.__msLocales})(),Ne=Ps.store,xt=Ps.inflight;bo=new Set(["de","nl","fr","it","es","pt","pt-br","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi","hu","ko","tr"]),xo="/maintenance_supporter_locales"});function Ls(s){let r=s?.language;!r||typeof s?.loadBackendTranslation!="function"||ie.lang===r&&(ie.localize||ie.inflight)||(ie.lang=r,ie.localize=null,ie.inflight=s.loadBackendTranslation("exceptions",Yi).then(e=>{ie.lang===r&&(ie.localize=e)}).catch(()=>{}).finally(()=>{ie.inflight=null}))}function Ns(s,r,e){if(!r||!ie.localize||s&&s!==Yi)return null;let t=`component.${Yi}.exceptions.${r}.message`,i=ie.localize(t,e??void 0);return i&&i!==t?i:null}var Yi,ie,Qi=y(()=>{"use strict";Yi="maintenance_supporter",ie=(()=>{let s=window;return s.__msBackendErrors??={lang:"",localize:null,inflight:null}})()});function De(s){return s?.currency_symbol||wo}function Hs(s,r){r.has("hass")&&Ji(s.hass?.locale,s.hass?.config?.country,s.hass?.config?.time_zone);let e=s.hass?.language;e&&!He(e)&&qe(e).then(()=>s.requestUpdate()),Ls(s.hass)}function N(s){return s?.language||"en"}function qs(s){let r=be(s);return{de:"de-DE",en:"en-US",nl:"nl-NL",fr:"fr-FR",it:"it-IT",es:"es-ES",pt:"pt-PT",ru:"ru-RU",uk:"uk-UA",zh:"zh-CN",da:"da-DK",fi:"fi-FI",nb:"nb-NO",ja:"ja-JP",hi:"hi-IN",pl:"pl-PL",cs:"cs-CZ",sv:"sv-SE","pt-br":"pt-BR",hu:"hu-HU",ko:"ko-KR",tr:"tr-TR"}[r]??"en-US"}function ko(s){Ds.decimals=typeof s=="number"&&Number.isInteger(s)&&s>=0&&s<=3?s:0}function $o(){return Ds.decimals??0}function ei(s){s&&s.currency_decimals!==void 0&&s.currency_decimals!==null&&ko(Number(s.currency_decimals))}function Ji(s,r,e){e!==void 0&&(re.serverTimeZone=e||void 0),s&&(re.date=s.date_format,re.time=s.time_format,re.number=s.number_format,re.timeZone=s.time_zone,r!==void 0&&(re.country=r||void 0))}function Eo(s){switch(re.number){case"comma_decimal":return["en-US","en"];case"decimal_comma":return["de","es","it"];case"space_comma":return["fr","sv","cs"];case"system":return;default:return qs(s)}}function C(s,r,e){if(!Number.isFinite(s))return String(s);let t=typeof e=="number"?{minimumFractionDigits:e,maximumFractionDigits:e}:{maximumFractionDigits:2,...e};if(re.number==="none"){let i=Math.pow(10,t.maximumFractionDigits??2);return String(Math.round(s*i)/i)}try{return new Intl.NumberFormat(Eo(r),t).format(s)}catch{return new Intl.NumberFormat(void 0,t).format(s)}}function J(s,r,e,t){let i=t??$o(),n=Math.abs(s)<.5/Math.pow(10,i)?0:s,o=C(n,e,i);return r?`${o} ${r}`:o}function Ze(s,r,e){let t=C(s,e);return r?`${t} ${r}`:t}function Ye(s){let r=qs(s),e=re.country;if(e&&/^[A-Za-z]{2}$/.test(e)){let t=`${be(s).split("-")[0]}-${e.toUpperCase()}`;try{return new Intl.DateTimeFormat(t),t}catch{}}return r}function Zi(s,r,e){let t=re.date;if(t==="DMY"||t==="MDY"||t==="YMD"){let[n,o,d]=ve(s,e).split("-");return t==="DMY"?`${d}/${o}/${n}`:t==="MDY"?`${o}/${d}/${n}`:`${n}-${o}-${d}`}let i=t==="system"?void 0:Ye(r);return s.toLocaleDateString(i,{day:"2-digit",month:"2-digit",year:"numeric",timeZone:e})}function ti(s,r){let e=fe();switch(re.time){case"12":return s.toLocaleTimeString(Ye(r),{hour:"2-digit",minute:"2-digit",hour12:!0,timeZone:e});case"24":return s.toLocaleTimeString(Ye(r),{hour:"2-digit",minute:"2-digit",hour12:!1,timeZone:e});case"system":return s.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:e});default:return s.toLocaleTimeString(Ye(r),{hour:"2-digit",minute:"2-digit",timeZone:e})}}function O(s,r){if(!s)return"\u2014";try{let e=js.exec(s);return e?Zi(new Date(Date.UTC(+e[1],+e[2]-1,+e[3])),r,"UTC"):Zi(new Date(Q(s)),r,fe())}catch{return s}}function ii(s,r){if(!s)return"\u2014";if(js.test(s))return O(s,r);try{let e=new Date(Q(s));return Zi(e,r,fe())+" "+ti(e,r)}catch{return s}}function ri(s,r=Date.now()){let e=ge();return Qt(r,e)===Qt(r,fe())?"":a("ha_time_zone_hint",s).replace("{zone}",e)}function Ms(s,r){if(s==null)return"\u2014";let e=r||"en";return s<0?`${Math.abs(s)} ${a("d_overdue",e)}`:s===0?a("today",e):`${s} ${s===1?a("day",e):a("days",e)}`}function Oe(s,r,e){return s==null?"\u2014":`${s} ${a("unit_"+(r||"days"),e)}`}function si(s,r){return s==null?"\u2014":`${C(s,r,{maximumFractionDigits:1})} ${a("minutes_short",r)}`}function Qe(s,r,e="long"){return new Date(Date.UTC(2024,0,1+s)).toLocaleDateString(Ye(r),{weekday:e,timeZone:"UTC"})}function Xi(s,r,e="long"){return new Date(Date.UTC(2024,s,1)).toLocaleDateString(Ye(r),{month:e,timeZone:"UTC"})}function zs(s,r,e="long"){return Qe((s.getDay()+6)%7,r,e)}function Fs(s,r,e="long"){return Xi(s.getMonth(),r,e)}function Us(s,r){let e=s.schedule;if(s.schedule_type==="sensor_based"){let i=a("sensor_based",r),n=e&&e.kind==="interval"&&e.every?e.every:s.interval_days;return n?`${i} \xB7 ${Oe(n,(e&&e.kind==="interval"?e.unit:s.interval_unit)||"days",r)}`:i}let t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>Qe(i,r,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?a("ord_last",r):a("ord_"+e.nth,r)} ${Qe(e.weekday,r,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?a(e.business?"last_business_day_month":"last_day_month",r):`${a("day_word",r)} ${e.day}`)+t;case"calendar":return`${a("calendar_entity_label",r)}: ${s.schedule_entity_name||e.entity_id||"\u2014"}${t}`;case"one_time":return s.due_date?O(s.due_date,r):a("one_time",r);case"manual":return a("manual",r);case"interval":return Oe(e.every,e.unit,r)}return s.schedule_type==="one_time"?s.due_date?O(s.due_date,r):a("one_time",r):s.schedule_type==="manual"?a("manual",r):s.schedule_type==="sensor_based"?a("sensor_based",r):s.interval_days!=null?Oe(s.interval_days,s.interval_unit,r):"\u2014"}function Bs(s,r){s.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:r},bubbles:!0,composed:!0}))}var wo,Os,re,Ds,js,Je,So,To,ni,T=y(()=>{"use strict";I();As();Kt();Xt();Qi();Z();Xt();wo="\u20AC";Ki(Cs);Os=window,re=Os.__msDateTimePrefs??={},Ds=Os.__msMoneyPrefs??={};js=/^(\d{4})-(\d{2})-(\d{2})$/;Je=E` +Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",ws_err_not_found:"Not found \u2014 it may have been deleted meanwhile",ws_err_invalid_input:"Invalid input",ws_err_invalid_date:"Invalid date",ws_err_invalid_format:"Invalid format",ws_err_invalid_url:"Invalid link",ws_err_invalid_mirror_todo:"Invalid to-do list",ws_err_not_configured:"Not set up yet",ws_err_not_loaded:"Not loaded yet \u2014 try again in a moment",ws_err_limit_reached:"Limit reached",ws_err_too_many:"Too many entries",ws_err_too_large:"Too large",ws_err_not_archived:"Not archived",ws_err_invalid_target:"Invalid target",ws_err_create_failed:"Could not be created",ws_err_archived:"Archived \u2014 restore it first",ws_err_already_archived:"Already archived",ws_err_unavailable:"Currently unavailable",ws_err_unauthorized:"Not permitted for your user",ws_err_too_many_views:"Too many saved views",ws_err_storage_unavailable:"Storage is not available",ws_err_replace_failed:"Replacement failed",ws_err_not_paused:"Not paused",ws_err_not_available:"Nothing available",ws_err_no_url:"No link given",ws_err_no_phases:"This task has no phases",ws_err_invalid_view:"Invalid view",ws_err_invalid_user:"Unknown user",ws_err_invalid_range:"Invalid range",ws_err_invalid_parent:"Invalid parent object",ws_err_invalid_device:"Unknown device",ws_err_empty_csv:"The CSV file is empty",ws_err_empty:"Nothing to import",ws_err_duplicate_failed:"Could not duplicate",ws_err_already_paused:"Already paused",ws_err_invalid_cursor:"Invalid phase position",ws_err_invalid_entity_slug:"Invalid entity name",ws_err_invalid_trigger_config:"Invalid trigger configuration",ws_err_no_defaults:"No quick-complete defaults set",ws_err_self_link_device:"An object cannot be linked to itself",ws_err_too_early:"Too early \u2014 this task can only be completed closer to its due date",ws_err_invalid_search_template:"The search link must be an http(s) URL containing {q}",ws_err_invalid_icon:"The notification icon must be an mdi: name, e.g. mdi:air-filter",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_toggle:"Due at a specific time",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",notes_markdown_hint:"Markdown is supported \u2014 **bold**, lists, [links](\u2026)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",completed_at_optional:"Completed at (optional, empty = now)",completed_at_pick:"Set date & time",completed_at_future_error:"The completion date cannot be in the future.",ha_time_zone_hint:"Time in Home Assistant's time zone ({zone})",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",task_mirror_todo:"Mirror into to-do lists",task_mirror_todo_hint:"The task appears in these lists while it is due; checking it off there completes it here.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",all_priorities:"All priorities",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",interval_anchor_hint:"From completion date: the next due date counts from the day the task is marked done, so doing it early or late moves the schedule. From planned date: the schedule keeps its dates, for example when a bill is paid weeks before it is due.",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",object_replaces:"Replaces",object_replaced_by:"Replaced by",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",all_parts:"All parts",all_areas:"All areas",areas_filter:"Filter areas\u2026",areas_filter_none:"No area matches the filter.",area_cost_year:"Cost {year}",area_object_count:"{n} objects",area_object_count_one:"1 object",area_range_12m:"Last 12 months",area_range_all:"All time",area_task_filter:"Filter by task\u2026",area_kpi_completions:"Completions",area_kpi_total_time:"Total time",area_cost_per_month:"Cost per month",area_cost_per_year:"Cost per year",area_cost_per_object:"Cost per object",area_cost_share:"Share",area_history_section:"History (all objects)",area_report_print:"Area report (PDF)",area_report_title:"Area report",area_report_period:"Period",area_no_costs:"No costs recorded in this period.",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",trigger_equals:"Trigger when equal to (=)",trigger_not_equals:"Trigger when different from (\u2260)",for_at_least_minutes:"For at least (minutes)",safety_interval:"Safety interval (optional)",trigger_combinator:"Combine trigger and interval",trigger_combinator_any:"Trigger or interval (whichever first)",trigger_combinator_all:"Trigger and interval (both required)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",adaptive_prediction_enabled:"Enable sensor-driven predictions",adaptive_seasonal_enabled:"Enable seasonal awareness",adaptive_max_interval:"Maximum interval (days)",adaptive_min_interval:"Minimum interval (days)",adaptive_ewa_alpha:"Learning rate (alpha)",adaptive_enabled:"Enable adaptive scheduling",adaptive_section_title:"Adaptive Scheduling",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_every_n_days:"every {n} days",cal_every_day:"every day",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",clear:"Clear",date_type_toggle:"Type the date",date_type_invalid:"Not a valid date \u2014 try 1978-03-15, a year like 1978, or your own date format",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",subsave_warning:"Saved, but one setting was rejected: {detail}",qr_print:"Print",qr_download:"Download SVG",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_action_quick_complete:"Quick-complete \u2014 no dialog",qr_print_quick_none:"Quick-complete codes need quick-complete defaults on a task (Settings \u2192 Advanced Features \u2192 Completion actions; task dialog \u2192 Quick-complete defaults) \u2014 none of the chosen objects' tasks has them yet.",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",strat_this_month:"This Month",strat_later:"Later",strat_week:"Week",strat_fortnight:"Fortnight",strat_month:"Month",strat_year:"Year",strat_other:"Other",strat_status:"Status",strat_empty_title:"No maintenance objects yet",strat_empty_content:"Open the Maintenance panel to add your first object \u2014 pool pump, HVAC filter, vehicle, anything that needs scheduled care.",strat_open_panel:"Open Maintenance panel",strat_add_object:"Add object",strat_not_loaded:"**Maintenance Supporter** is not loaded. Install/enable the integration first.",strat_group_by:"Group views by",strat_group_area:"By area (default)",strat_group_status:"By status (Overdue / Triggered / Due Soon / OK)",strat_group_floor:"By floor (uses HA floors)",strat_group_due_date:"By due date (Overdue / Today / Week / Month / Later)",strat_group_calendar:"Rolling calendar (Week / Fortnight / Month / Year)",strat_editor_help:'The "Overview" view is always first. Empty groups are skipped.',picker_name_calendar:"Maintenance Supporter \u2014 Calendar",picker_name_panel:"Maintenance Supporter \u2014 Panel",picker_name_budget:"Maintenance Supporter \u2014 Budget",picker_name_groups:"Maintenance Supporter \u2014 Groups",picker_name_vacation:"Maintenance Supporter \u2014 Vacation",picker_name_battery_fleet:"Battery Fleet",picker_name_section:"Maintenance Supporter \u2014 Section",picker_name_vacation_status:"Maintenance Supporter \u2014 Vacation Status",picker_name_budget_status:"Maintenance Supporter \u2014 Budget Status",picker_desc_card:"Overview of your maintenance tasks with quick actions.",picker_desc_calendar:"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",picker_desc_panel:"The complete Maintenance Supporter panel as a card \u2014 for a panel view / dashboard subview without the sidebar entry.",picker_desc_budget:"Edit the monthly and yearly budget right on the dashboard.",picker_desc_groups:"Create, rename and delete task groups right on the dashboard.",picker_desc_vacation:"Switch vacation mode on or off and set its dates right on the dashboard.",picker_desc_battery_fleet:"All tracked batteries: what is low now, what runs out soon, and what to buy.",picker_desc_dashboard:"Auto-generated dashboard. Group views by area, status, floor, or due date \u2014 picked from the strategy editor or YAML.",picker_desc_section:"Embed maintenance tasks (filterable by area, status, due date) as a section in any dashboard view.",picker_desc_vacation_status:"Compact vacation-mode status banner. Tap to open settings in the panel.",picker_desc_budget_status:"Monthly + yearly maintenance budget overview. Tap for details.",picker_desc_groups_status:"List of configured task groups with member counts.",hist_note_button:"Completed from dashboard button",hist_note_todo:"Completed from the To-do list",hist_note_voice:"Completed by voice",hist_note_notification:"Completed from the notification",hist_note_nfc:"Completed via NFC tag",hist_note_shopping_list:"Completed from the shopping list",hist_note_todo_mirror:"Completed from a mirrored to-do list",hist_note_skipped_notification:"Skipped from notification",hist_note_skipped_button:"Skipped from dashboard button",hist_note_sensor_triggered:"Sensor trigger activated",hist_note_initial:"Initial value set during task creation",hist_note_auto_recovered:"Auto-completed: sensor recovered ({value})",hist_note_reset:"Reset to {date}",hist_note_trigger_replaced:"Trigger entity replaced: {old} \u2192 {new}",hist_note_trigger_removed:"Sensor trigger removed (entity was: {entity}). Schedule converted to {schedule}.",hist_note_compound_removed:"Entity {entity} removed from compound trigger; {count} conditions remain.",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_action_style:"Complete button style",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_areas:"Filter by areas",card_filter_areas_help:"Empty = show all areas.",card_filter_priority_help:"Empty = show all priorities. Tasks without an explicit priority count as Normal.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",place_optional:"Place (zone, optional)",remind_on_site:"Remind only while somebody is there",remind_on_site_hint:"Reminders wait until somebody is in this zone. Whoever arrives there gets a message with what is due.",place_missing:"Zone not found: reminders as at home",place_home:"Home",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_delete_entry:"Delete entry",history_delete_confirm_entry:"Delete this history entry? The task's last-performed date falls back to the previous completion; photos stay with the object.",history_delete_returns_parts:"The parts it used go back to stock: {parts}.",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",notify_icon:"Notification icon",notify_icon_hint:"Shown by the Companion app on Android. Empty = the icon of this task's maintenance type ({default}).",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects, tasks, spare parts, documents, notes\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close \xB7 / opens the search anywhere",search_open:"Search",search_group_parts:"Spare parts",search_group_content:"In documents",search_group_history:"History notes",search_page:"Page {page}",search_empty_hint:"Type to search objects, tasks, spare parts, documents and notes",search_searching:"Searching documents and notes\u2026",search_index_status:"Full-text search: {indexed} of {total} files indexed \xB7 {no_text} without text layer \xB7 {pending} pending",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_consumable_threshold:"Consumable low threshold (%)",settings_battery_low_percent:"Battery low threshold (%)",settings_battery_recovered_percent:"Battery counts as replaced above (%)",settings_battery_recovered_percent_hint:"A low battery stays counted as low until its level rises above this value or a replacement is recorded \u2014 so a level hovering around the low threshold cannot complete the fleet task again and again.",settings_battery_auto_record:"Record a replacement automatically when a low battery recovers",settings_battery_auto_record_hint:"When a battery that was low rises above the recovery threshold, the replacement date is recorded in Battery Notes and the spare cells are taken from stock \u2014 no tap needed.",settings_battery_lifetimes:"Typical battery lifetimes",settings_battery_lifetimes_hint:'Only used for batteries without a percentage: a Battery Notes note that has just a type and a last-replaced date, or a sensor that only reports "low". Their due date is last replaced + this lifetime. Batteries that report a level get their forecast from the measured discharge instead. Once the fleet has seen three replacements of a type, the median interval takes over from the built-in value \u2014 your own value always wins.',settings_battery_lifetime_months:"months",settings_battery_lifetime_reset:"Use default",settings_battery_lifetime_in_fleet:"in your fleet",settings_battery_lifetime_learned_models:"Learned per model: {list}",settings_thresholds_hint:"Defaults for new Suggested setups (percent-remaining consumables) and for fleet batteries without their own Battery Notes threshold. Existing tasks keep their value.",settings_part_search_url:"Shopping search link",settings_part_search_url_hint:"URL with {q} in place of the search terms, used for parts without a product link. Leave empty for the automatic store (by country, then by language).",settings_value_out_of_range:"Value must be between {min} and {max}",bn_summary:"{name}: {pct} % for {n} batteries",bn_floor_decides:"Your {floor} % floor decides for all of them; the higher of the two thresholds counts per battery.",bn_above_floor:"That is above your {floor} % floor, so {name} decides for all noted batteries.",bn_overrides:"{n} devices with their own threshold:",bn_more:"+ {n} more",settings_row_actions:"Task row actions",settings_ref_numbers_in_lists:"Reference numbers in lists",settings_ref_numbers_in_lists_hint:"Show the #8 / #8.3 chips in front of object and task names in the Today list, the task table, the object cards and the objects table.",settings_compact_refs:"Compact reference numbers",settings_compact_refs_hint:"Renumbers all objects, tasks and completions in order (1, 2, 3 \u2026) and closes the gaps left by deletions. Numbers already printed on service booklets, photos or notes will no longer match.",settings_compact_refs_confirm:"Renumber every object, task and completion sequentially in creation order? Numbers already written on printed service booklets, photos or notes will no longer match. This cannot be undone.",settings_compact_refs_done:"Renumbered {objects} objects, {tasks} tasks and {completions} completions.",row_actions_buttons_compact:"Buttons (icons only on phones)",row_actions_buttons:"Buttons with text",row_actions_icons:"Icons (classic)",row_actions_follow:"Follow the household setting",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",settings_shopping_list:"Shopping list (buy tasks)",settings_shopping_list_help:"Low-part buy reminders appear in this to-do list; checking one off restocks the part.",shopping_list_none:"Off \u2014 no shopping list",settings_install_assist_sentences:"Install Assist sentences",settings_install_assist_sentences_hint:"Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",notify_per_person:"Per-person delivery",member_avatars:"Member avatars",member_avatars_hint:"Shown next to assigned tasks \u2014 initials in the member's colour. Defaults come from the name; set your own initials or pick a colour to tell members apart.",member_initials:"Initials",member_color:"Colour",member_avatar_reset:"Reset",notify_no_own_device:"No own device \u2014 uses the household service",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_snooze_hours_hint:"How long Snooze mutes a task's reminders \u2014 from the task menu, a card, voice or the phone's Snooze button. The due date stays as it is.",settings_budget:"Budget",settings_currency:"Currency",settings_currency_decimals:"Decimal places for amounts",settings_currency_decimals_hint:"How many decimals every amount shows: KPIs, budgets, costs in lists and reports, budget alerts. 0 = whole numbers.",settings_parts_cost_mode:"Spare parts count as spending",settings_parts_cost_purchase:"When bought",settings_parts_cost_use:"When used",settings_parts_cost_mode_hint:"When bought: a buy reminder's cost counts, and the parts a job uses are shown as information. When used: every completion books the value of the parts it used, and a purchase sets the part's price instead of counting. Applies to completions from now on \u2014 history keeps how it was booked.",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_export_settings:"Export settings (JSON)",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_import_unmatched_users:"Not found on this instance: {names}. Their task assignments were removed \u2014 add people with the same names in Home Assistant and import again to keep them.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON and YAML exports list your documents but not their files; CSV leaves documents out. This ZIP holds the uploaded files \u2014 restore it after importing the JSON and nothing is missing.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",settings_docs_import_missing:"{n} skipped \u2014 their files were not in the ZIP",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",groupby_object:"By object",filter_label:"Filter",user_label:"User",photo_label:"Photo",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",state_latch_help:"With 1 transition the trigger is a latch: it recovers when the entity leaves the To-state \u2014 or, with only a From-state set, when it returns to that state.",for_minutes_state_help:"0 counts every change immediately. Set minutes and the new state must hold that long first \u2014 brief flickers then neither trigger nor count.",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else only gets Complete, Skip, Reset, Postpone and Snooze.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",feat_places:"Places",feat_places_desc:"Objects maintained at another place (a Home Assistant zone): reminders only while somebody is there, and a message on arrival.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_target_more:"This action targets more than this field shows: {targets}. That is kept as it is \u2014 choosing another entity here replaces the whole target.",on_complete_action_skip_auto:"Not when the task completes itself (its sensor recovered)",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",action_test_bad_format:"Invalid service format (expected 'domain.service')",action_test_unknown_service:'Service "{service}" is not registered in Home Assistant. Check the spelling and that the integration providing it is loaded.',action_test_domain_mismatch:`Service "{service}" only works on {domain}.* entities; entity "{entity}" is in {entity_domain}.* \u2014 pick a service that matches the entity's domain (e.g. {suggestion})`,action_test_entity_missing:'Target entity "{entity}" was not found in Home Assistant \u2014 it may have been renamed or its integration removed.',quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_completed_named:"Completed: {task}",undo_done:"Completion undone",undo_done_action_ran:"Completion undone. Its completion action had already run and cannot be taken back.",undo_nothing:"Nothing to undo. Only your own last completion can be taken back, within 10 minutes.",undo_changed:"Can't undo. The task has changed since.",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",section_collapse:"Collapse section",section_expand:"Expand section",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 100 MB).",doc_upload_failed:"Upload failed.",docs_archive_too_large:"The archive is too large (max {max}).",docs_archive_no_manifest:"This ZIP has no manifest.json, so the restore cannot tell which document a file belongs to. Keep manifest.json next to the folders when zipping again.",completion_photos_optional:"Completion photos (optional)",completion_photos:"Completion photos",choose_photos:"Choose photos",choose_photo:"Choose photo",photos_android_hint:"The Android app hands over one photo per pick \u2014 each pick is added.",photos_limit:"Up to {max} photos per completion",history_edit_photos_hint:"Removing a photo here keeps the file in the object's documents.",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_not_allowed:"This link is not allowed: links that run scripts or open local files (javascript:, data:, file: \u2026) are refused.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",camera_capture_shoot:"Capture",camera_switch_lens:"Switch camera",camera_switching:"Switching camera\u2026",camera_not_answered:"This camera did not answer. Tap the switch again to try the next one, or use the file picker.",camera_diagnostics:"Details for a bug report",camera_copy:"Copy",camera_copied:"Copied",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",doc_description:"Description (optional)",doc_sort:"Sort documents",doc_sort_newest:"Newest first",doc_sort_oldest:"Oldest first",doc_sort_title:"By title (1, 2, 3 \u2026)",doc_sort_category:"By category",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_task:"Pause",resume_task:"Resume",pause_task_prompt:"Freeze this task's schedule \u2014 it does not become due and does not notify until you resume it, and resuming starts a fresh cycle from that day. The object's other tasks keep running. Optionally set a date to resume by itself.",task_paused:"Task paused",task_resumed:"Task resumed \u2014 its schedule restarted",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",replace_device_heading:"Device in Home Assistant",replace_device_keep:"Same device as before ({device})",replace_device_keep_hint:"Also the choice when the new unit is not in Home Assistant yet: once it is, pick it as the object's device in its settings \u2014 the sensor triggers move along then.",replace_device_other:"The new unit is another device",replace_device_pick:"Device of the new unit",replace_device_other_hint:"Sensor triggers, completion actions and the counter reset move to the matching entities of that device.",replace_device_none:"No device",object_replaced:"Object replaced \u2014 successor created",device_swap_moved:"{count} sensor links moved to the new device.",device_swap_moved_one:"One sensor link moved to the new device.",device_swap_unmatched:"{count} could not be matched \u2014 check those tasks' sensor triggers.",device_swap_unmatched_one:"One could not be matched \u2014 check that task's sensor trigger.",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",readings_section:"Readings",readings_hint:"Several named values per completion \u2014 one row per meter. Leave empty to record a single value.",reading_name_label:"Reading name",reading_unit_short:"Unit",reading_add:"Add reading",reading_duplicate_name:"Name already used",reading_last:"last: {value}",reading_below_last:"Lower than the last reading ({value})",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,home_profile_title:"Home profile",home_profile_hint:"Recommends templates and sets seasonal windows for your climate and hemisphere. Everything is worked out locally from your floors, areas and home location.",home_type_label:"Home type",home_type_auto:"Automatic ({detected})",home_region_label:"State, province or region",home_region_none:"not found",home_region_hint:"Some rules differ within a country \u2014 a yearly vehicle inspection in one state, none in the next. Found from your home location.",home_dwelling_house:"House",home_dwelling_apartment:"Apartment",home_dwelling_unknown:"not recognised",home_detected_from:"Detected from: {reasons}",home_reason_floors:"several floors",home_reason_basement:"a basement",home_reason_garden:"a garden",home_reason_shed:"a shed",home_reason_driveway:"a driveway",home_reason_garage:"a garage",home_reason_attic:"an attic",home_reason_lawn_mower:"a lawn mower",home_reason_gate:"a gate",home_reason_balcony:"a balcony",home_reason_small_home:"few rooms on one floor",home_reason_ups:"a UPS",home_climate_label:"Climate",home_climate_value:"K\xF6ppen {koppen} \xB7 coldest month {cold} \xB0C \xB7 warmest month {warm} \xB0C",home_climate_unknown:"No home location set in Home Assistant",home_hemisphere_south:"Southern hemisphere \u2014 seasonal windows are mirrored",home_trait_freeze:"Frost in winter",home_trait_snow:"Snowy winters",home_trait_severe_winter:"Severe winters",home_trait_damp:"Damp climate",home_trait_mediterranean:"Mediterranean climate",home_trait_earthquake:"Earthquake region",home_trait_hot_summer:"Hot summers",home_trait_hot_humid:"Hot and humid",home_trait_hot_dry:"Hot and dry",home_trait_termites:"Termite region",home_trait_wildfire:"Wildfire risk",home_trait_cyclone:"Hurricane / typhoon region",home_trait_tropical:"Tropical climate",home_trait_radon:"Radon test recommended",home_reason_starter:"Basics for your home",home_reason_country:"Common in {country}",home_reason_feature:"Found in your home: {feature}",templates_recommended_title:"Recommended for your home",templates_recommended_hint:"Based on your home profile \u2014 adjust it in Settings.",templates_legal_hint:"Notes on legal duties and intervals are guidance, not legal advice. Rules vary by region and change over time \u2014 local regulations and the manufacturer's instructions take precedence.",templates_not_typical:"Not typical for your home type",templates_set_up:"Already set up",worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",parts_inventory_value:"Inventory value",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_package_size:"Package size (units)",part_restock_packages:"Restock amount (packages)",part_package_hint:"Bought in packages, used in the unit: stock, the amount a task uses and the reorder threshold count units; the restock amount and the price are per package.",part_auto_buy:"Auto-create buy task when low",part_reuse:"After use",part_reuse_none:"Used up",part_reuse_charge:"Charged",part_reuse_sharpen:"Sharpened",part_reuse_clean:"Cleaned",part_reuse_dry:"Dried",part_reuse_refill:"Refilled",part_reuse_restore:"Restored",part_waiting_charge:"{count} to charge",part_waiting_sharpen:"{count} to sharpen",part_waiting_clean:"{count} to clean",part_waiting_dry:"{count} to dry",part_waiting_refill:"{count} to refill",part_waiting_restore:"{count} to restore",part_restoring:"Used, not ready yet",part_ready_again_hint:"All used ones are ready again",part_reuse_hint:"A used one is not gone: it waits until it is ready again and still counts as owned, so it is not bought again.",part_restock:"Adjust stock",parts_used_by:"Used by",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",task_parts_fleet_hint:"Battery quantities come from Battery Notes per device \u2014 nothing to link here.",shared_parts_other_objects:"Parts from other objects",shared_parts_help:"Several objects can share one stock. Completing this task takes from the owning object.",shared_part_unknown:"Unknown part",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_object_hint:"Give several sensors the same object name and they become one object; type an existing object's name to add them there.",adopt_summary_title:"What adopting changes",adopt_summary_existing:'"{name}": tasks {before} \u2192 {after}',adopt_summary_totals:"Tasks: {tasks} \xB7 objects: {objects}",adopt_summary_totals_new:"Tasks: {tasks} \xB7 objects: {objects} ({new} new)",adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",adopt_problem_done_one:"Adopted 1 problem sensor",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_overlap:"These limits overlap: every reading triggers the task and it can never recover. Leave one of them empty.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_due_date:"The task becomes due {days} days before the date the sensor reports.",trigger_hint_due_date_one:"The task becomes due one day before the date the sensor reports.",trigger_hint_due_date_same_day:"The task becomes due on the date the sensor reports.",trigger_hint_due_date_now:"Reported date: {date}.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",filter_place:"Place",filter_place_all:"All places",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",settings_notify_completed:"Completion notifications",settings_notify_completed_hint:`Household news when a task is completed, with the reason and who did it. "Automatic only" announces just the completions nobody made on the spot: sensor recovery, shopping-list check-off, an automation's service call. Sent to the household service only; quiet hours and the daily cap apply.`,settings_title_style:"Notification title style",settings_title_style_hint:"What appears as the notification's TITLE. Default keeps the per-status text (e.g. 'Maintenance overdue!'). Object name / Task name help when phone notifications stack.",title_style_default:"Default (per-status title)",title_style_object_name:"Object name as title",title_style_task_name:"Task name as title",notify_completed_off:"Off",notify_completed_automatic:"Automatic completions only",notify_completed_all:"All completions",settings_notify_rule:"Your own notification rule",settings_notify_rule_hint:"Every notification also fires the event maintenance_supporter_notification (kind, status, object, task, reference numbers, priority, deep link, the payload). Route it with an automation \u2014 e.g. into Ticker, Telegram or Pushover.",settings_notify_event_only:"Only fire the event, send nothing myself",settings_notify_event_only_hint:"Your automation is the whole delivery. Rate limits, quiet hours and the daily cap still apply to when the event fires.",settings_notify_extra_data:"Extra notification data (template)",settings_notify_extra_data_hint:"JSON or YAML, Jinja allowed; merged into the notify call's data (your keys win). Variables: kind, status, priority, labels, notes, area_name, object_name, task_name, object_ref, task_ref, days_until_due, next_due, last_performed, responsible_user_id, sensor_entity_id, url, target, title, message. Test it with the Send test button.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user, label and priority filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",panel_card_tab:"Open on tab",panel_card_tab_default:"Remembered tab (as in the panel)",panel_card_view:"Open with saved view",panel_card_height:"Height",panel_card_height_help:"Empty = fill the screen below the card (panel view). Otherwise a CSS length such as 600px or 80vh for views with other cards.",panel_card_not_registered:"The Maintenance Supporter panel is not available \u2014 is the integration set up?",panel_card_load_failed:"The Maintenance Supporter panel could not be loaded \u2014 reload the page (after an update: clear the browser cache).",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",setups_done_one:"1 sensor-wired task created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",runtime_max_session:"Max session runtime (seconds)",runtime_max_session_help:"A single run counts at most this many seconds \u2014 protects against a sensor stuck ON (lost connection, restart). Empty = no cap.",trend_approaching:"Heading toward the threshold",trend_easing:"Easing away from the threshold",split_select_hint:"Select a task to see its details here",setups_target_new:"Create new: {name}",setups_target_match:"{name} \u2014 matches: {reasons}",setups_reason_model:"model number",setups_reason_name:"name",setups_reason_area:"area",setups_reason_sibling:"same model on another device",setups_maybe_covered:'Probably there already as "{name}"',setups_already:"Already there: {names}",setups_reset_line:"Completing it also resets the counter in the integration (\u201C{button}\u201D)",setups_reset_enable:"\u2014 the button gets switched on for this",reset_offers_title:"Reset counters on completion",reset_offers_hint:"These tasks watch a counter the integration keeps itself. Connected, completing the task here also presses the integration's reset button \u2014 otherwise the counter keeps running and the task falls due again right away.",reset_offers_before:"Now: completing it leaves the counter in {integration} running",reset_offers_after:"Then: it also presses \u201C{button}\u201D",reset_offers_renamed:"Renamed since it was set up \u2014 check that this task is for this part",reset_offers_apply:"Connect resets",reset_offers_done:"{count} tasks now reset their counter on completion",reset_offers_done_one:"1 task now resets its counter on completion",setups_before_after:"Tasks on the object: {before} \u2192 {after}",setups_new_object_count:'New object "{name}" \xB7 tasks: {count}',schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_for_minutes_hint:"Only trigger once the problem has persisted this long \u2014 0 reacts to the first flicker.",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_progress:"{n} to replace",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_no_sensor:"No sensor",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_exclude:"Exclude from the fleet",battery_fleet_excluded:"Excluded",battery_fleet_include:"Track again",battery_fleet_all:"All tracked batteries",battery_fleet_all_hint:"Exclude a device here to drop it from the fleet before it ever reports low \u2014 a vacuum that recharges itself, or a phone that warns you on its own.",battery_fleet_add:"Add a battery",battery_fleet_add_hint:"Pick a battery sensor the automatic discovery missed \u2014 it joins the roster immediately.",battery_fleet_track_self:"Track self-charging batteries",battery_fleet_track_self_hint:"Phones, vacuums and other devices that recharge themselves appear as rechargeables \u2014 a low one asks for a charge, never for new cells.",battery_fleet_due_without_sensor:"Treat a passed forecast as due when a battery has no sensor",battery_fleet_due_without_sensor_hint:"Some devices report no battery level at all \u2014 Battery Notes only knows the type and the last replacement. Once the typical lifetime has passed, such a battery counts as low and the fleet task reminds you; marking it replaced resets the forecast.",battery_fleet_status_low:"Low",battery_fleet_status_soon:"Soon",battery_fleet_status_ok:"Healthy",battery_fleet_status_due:"Due",battery_fleet_predicted_on:"Expected around {date}",battery_fleet_predicted_typical:"Expected around {date} \u2014 {months} months typical for {type} ({source})",lifetime_source_default:"built-in default",lifetime_source_table:"built-in table",lifetime_source_learned:"learned from {n} replacements",lifetime_source_override:"your setting",lifetime_source_learned_device:"learned from this device's {n} replacements",lifetime_source_learned_model:"learned from {n} replacements on devices of this model",battery_fleet_predicted_trend:"Predicted from this battery's discharge trend: around {date} ({confidence})",battery_fleet_rechargeable:"Rechargeable: charge instead of replacing \u2014 never on the shopping list",battery_fleet_sort_name:"Sort by name",battery_fleet_sort_urgency:"Sort by urgency",battery_fleet_mark_recharged:"Mark as recharged",battery_fleet_charging:"Rechargeable spares",battery_fleet_ready:"{count} ready",battery_fleet_sparkline_hint:"Battery level over the last 30 days \u2014 dotted: projected until the low threshold",battery_fleet_filter_type:"Show only this battery type",battery_fleet_record_replacement:"The level jumped around {date} \u2014 record this replacement in Battery Notes",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries.",update_banner:"A newer version of Maintenance Supporter is on the server \u2014 reload to update the panel.",update_reload:"Reload",restart_pending_banner:"Maintenance Supporter was updated to {version}. Restart Home Assistant to finish the update.",restart_pending_error:"That did not work: Maintenance Supporter was updated to {version}, but Home Assistant has not been restarted since. Restart Home Assistant, then try again.",row_actions_banner:"Complete and Skip in task rows are now buttons. Prefer the old icons?",row_actions_keep:"Keep buttons",row_actions_back:"Back to icons",battery_fleet_forecast_overdue:"Predicted date passed \u2014 the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",cost_from_parts:"Use \u2248 {amount} from parts",cost_parts_booked:"Parts used \u2248 {amount} are booked automatically \u2014 enter only additional costs.",cost_parts_info:"Parts used \u2248 {amount} \u2014 counted when they were bought, not here.",cost_purchase_use_hint:"The price paid becomes the part's price \u2014 spare parts count when they are used.",history_parts_counted:"Parts: {amount}",history_parts_counted_hint:"Value of the parts used \u2014 counted as spending",history_parts_info:"Parts \u2248 {amount}",history_parts_info_hint:"Value of the parts used \u2014 information only; the money counted when they were bought",history_purchase_stock:"Bought for {amount} (counted when used)",dismiss:"Dismiss",gs_label:"Getting started \u2014 these hints retire as your setup grows",gs_setups_chip:"Suggested setups found {n} devices with pre-wired triggers",gs_setups_chip_one:"Suggested setups found 1 device with pre-wired triggers",gs_adopt_chip:"{n} problem sensors can become maintenance tasks",gs_adopt_chip_one:"1 problem sensor can become a maintenance task",gs_fleet_chip:"One click sets up the battery fleet",gs_reset_chip:"{n} tasks can reset their counter in the integration",gs_reset_chip_one:"1 task can reset its counter in the integration",cal_editor_window:"Default window",cal_editor_window_week:"Week (7 days)",cal_editor_window_fortnight:"Fortnight (14 days)",cal_editor_window_month:"Month (30 days, default)",cal_editor_window_year:"Year (365 days, empty days collapsed)",cal_editor_window_past_30:"Past 30 days (history)",cal_editor_window_past_90:"Past 90 days (history)",cal_editor_show_chips:"Show window chips inside the card",cal_editor_chips_hint:"Hide the chips when the card is embedded in a strategy view that already serves as the window selector.",cal_editor_show_user_filter:"Show user filter dropdown",cal_editor_default_user:"Default user filter",cal_editor_my_tasks:"My tasks (current user)",cal_editor_show_object_filter:"Show object filter dropdown",cal_editor_object_hint:'Pre-select one object via YAML: object_filter: "" \u2014 or a list of names to restrict the card to several objects.',object_history_section:"History (all tasks)",object_history_all_tasks:"All tasks",object_history_empty:"No entries in this range.",object_history_cap_note:"History keeps up to 500 entries per task \u2014 very old entries may be missing.",service_record_title:"Service record",service_record_print:"Service record (PDF)",date:"Date",service_record_entries:"entries",ref_number:"Reference number",print_options_title:"Print service record",print_layout:"Layout",print_layout_chronological:"Chronological",print_layout_by_task:"By task",print_include:"Include",print_inc_readings:"Readings",print_inc_parts:"Parts used",print_inc_photos:"Photos",print_inc_documents:"Linked documents",print_inc_doc_desc:"Document descriptions",print_inc_checklist:"Checklist",print_inc_notes:"Notes",print_inc_costs:"Cost and duration",print_inc_person:"Completed by",print_inc_refs:"Reference numbers",print_inc_bare:"Completions without details",print_inc_qr:"QR code per task",print_button:"Print",report_scan_hint:"Scan to open the task",completed_by:"Completed by",date_from:"From",date_to:"To",phases_section:"Cycle phases (optional)",phases_hint:"Different work on one shared schedule \u2014 each completion moves to the next step (e.g. small service, small service, big service).",phase_add:"Add phase",phase_name:"Phase name",phase_sequence_label:"Cycle order",phase_sequence_add_step:"Add step",phase_current:"Current phase",phase_set:"Set as current",chart_history_fallback:"No long-term statistics for this entity \u2014 showing recorder state history (typically the last ~10 days)",chart_history_alarm:"Trigger view from recorder state history: 1 = alert state held for the hold time, 0 = fine (typically the last ~10 days)",chart_history_count:"Change count rebuilt from recorder state history since the last service (typically the last ~10 days)",prediction_cycles:"Learned from cycles",phase_require_override:"Override \u201CRequire on completion\u201D for this phase",history_add_past:"Add past completion",require_tag_scan:"Only complete by scanning the tag",require_tag_scan_help:"Proof of presence: Done is blocked on every surface until the NFC tag or QR code on the thing itself is scanned. Automations can pass 'via_tag_scan' to the complete service.",disallow_skip:"Don't allow skipping",disallow_skip_help:"The Skip action disappears from every surface, and the server refuses a skip from automations and voice too.",no_notifications:"No notifications for this task",no_notifications_help:"No reminders for this task \u2014 no status notifications, repeats, lead-time reminders or bundles. It still shows on the dashboard and in its entities; the weekly digest still counts it.",require_tag_scan_hint:"This task completes only by scanning its NFC tag or QR code on the thing itself \u2014 saving here will be refused."}});function Ps(s){return s?.restart_pending&&s.installed_version?s.installed_version:null}var Rs,Gi=y(()=>{"use strict";Rs="2.103.0"});function Yi(s){Ne.en=Object.assign({},s,Ne.en??{})}function be(s){let r=(s||Ki).toLowerCase();return r.startsWith("pt")&&r.endsWith("br")?"pt-br":r.substring(0,2)}function a(s,r,e){let t=be(r);return Ne[t]?.[s]??Ne.en?.[s]??e??s}function He(s){let r=be(s);return r===Ki||r in Ne}function qe(s){let r=be(s);return r===Ki||r in Ne||!xo.has(r)?Promise.resolve():(r in xt||(xt[r]=fetch(`${wo}/${r}.json?v=${Rs}`).then(e=>e.ok?e.json():null).then(e=>{e?Ne[r]=e:delete xt[r]}).catch(()=>{delete xt[r]})),xt[r])}var Ki,Ls,Ne,xt,xo,wo,Xt=y(()=>{"use strict";Gi();Ki="en",Ls=(()=>{let s=window;return s.__msLocales||(s.__msLocales={store:{},inflight:{}}),s.__msLocales})(),Ne=Ls.store,xt=Ls.inflight;xo=new Set(["de","nl","fr","it","es","pt","pt-br","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi","hu","ko","tr"]),wo="/maintenance_supporter_locales"});function Ns(s){let r=s?.language;!r||typeof s?.loadBackendTranslation!="function"||ie.lang===r&&(ie.localize||ie.inflight)||(ie.lang=r,ie.localize=null,ie.inflight=s.loadBackendTranslation("exceptions",Qi).then(e=>{ie.lang===r&&(ie.localize=e)}).catch(()=>{}).finally(()=>{ie.inflight=null}))}function Hs(s,r,e){if(!r||!ie.localize||s&&s!==Qi)return null;let t=`component.${Qi}.exceptions.${r}.message`,i=ie.localize(t,e??void 0);return i&&i!==t?i:null}var Qi,ie,Zi=y(()=>{"use strict";Qi="maintenance_supporter",ie=(()=>{let s=window;return s.__msBackendErrors??={lang:"",localize:null,inflight:null}})()});function De(s){return s?.currency_symbol||ko}function qs(s,r){r.has("hass")&&Xi(s.hass?.locale,s.hass?.config?.country,s.hass?.config?.time_zone);let e=s.hass?.language;e&&!He(e)&&qe(e).then(()=>s.requestUpdate()),Ns(s.hass)}function N(s){return s?.language||"en"}function Os(s){let r=be(s);return{de:"de-DE",en:"en-US",nl:"nl-NL",fr:"fr-FR",it:"it-IT",es:"es-ES",pt:"pt-PT",ru:"ru-RU",uk:"uk-UA",zh:"zh-CN",da:"da-DK",fi:"fi-FI",nb:"nb-NO",ja:"ja-JP",hi:"hi-IN",pl:"pl-PL",cs:"cs-CZ",sv:"sv-SE","pt-br":"pt-BR",hu:"hu-HU",ko:"ko-KR",tr:"tr-TR"}[r]??"en-US"}function $o(s){js.decimals=typeof s=="number"&&Number.isInteger(s)&&s>=0&&s<=3?s:0}function Eo(){return js.decimals??0}function ei(s){s&&s.currency_decimals!==void 0&&s.currency_decimals!==null&&$o(Number(s.currency_decimals))}function Xi(s,r,e){e!==void 0&&(re.serverTimeZone=e||void 0),s&&(re.date=s.date_format,re.time=s.time_format,re.number=s.number_format,re.timeZone=s.time_zone,r!==void 0&&(re.country=r||void 0))}function So(s){switch(re.number){case"comma_decimal":return["en-US","en"];case"decimal_comma":return["de","es","it"];case"space_comma":return["fr","sv","cs"];case"system":return;default:return Os(s)}}function C(s,r,e){if(!Number.isFinite(s))return String(s);let t=typeof e=="number"?{minimumFractionDigits:e,maximumFractionDigits:e}:{maximumFractionDigits:2,...e};if(re.number==="none"){let i=Math.pow(10,t.maximumFractionDigits??2);return String(Math.round(s*i)/i)}try{return new Intl.NumberFormat(So(r),t).format(s)}catch{return new Intl.NumberFormat(void 0,t).format(s)}}function J(s,r,e,t){let i=t??Eo(),n=Math.abs(s)<.5/Math.pow(10,i)?0:s,o=C(n,e,i);return r?`${o} ${r}`:o}function Ze(s,r,e){let t=C(s,e);return r?`${t} ${r}`:t}function Ye(s){let r=Os(s),e=re.country;if(e&&/^[A-Za-z]{2}$/.test(e)){let t=`${be(s).split("-")[0]}-${e.toUpperCase()}`;try{return new Intl.DateTimeFormat(t),t}catch{}}return r}function Ji(s,r,e){let t=re.date;if(t==="DMY"||t==="MDY"||t==="YMD"){let[n,o,d]=ve(s,e).split("-");return t==="DMY"?`${d}/${o}/${n}`:t==="MDY"?`${o}/${d}/${n}`:`${n}-${o}-${d}`}let i=t==="system"?void 0:Ye(r);return s.toLocaleDateString(i,{day:"2-digit",month:"2-digit",year:"numeric",timeZone:e})}function ti(s,r){let e=fe();switch(re.time){case"12":return s.toLocaleTimeString(Ye(r),{hour:"2-digit",minute:"2-digit",hour12:!0,timeZone:e});case"24":return s.toLocaleTimeString(Ye(r),{hour:"2-digit",minute:"2-digit",hour12:!1,timeZone:e});case"system":return s.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:e});default:return s.toLocaleTimeString(Ye(r),{hour:"2-digit",minute:"2-digit",timeZone:e})}}function O(s,r){if(!s)return"\u2014";try{let e=Ms.exec(s);return e?Ji(new Date(Date.UTC(+e[1],+e[2]-1,+e[3])),r,"UTC"):Ji(new Date(Q(s)),r,fe())}catch{return s}}function ii(s,r){if(!s)return"\u2014";if(Ms.test(s))return O(s,r);try{let e=new Date(Q(s));return Ji(e,r,fe())+" "+ti(e,r)}catch{return s}}function ri(s,r=Date.now()){let e=ge();return Qt(r,e)===Qt(r,fe())?"":a("ha_time_zone_hint",s).replace("{zone}",e)}function zs(s,r){if(s==null)return"\u2014";let e=r||"en";return s<0?`${Math.abs(s)} ${a("d_overdue",e)}`:s===0?a("today",e):`${s} ${s===1?a("day",e):a("days",e)}`}function Oe(s,r,e){return s==null?"\u2014":`${s} ${a("unit_"+(r||"days"),e)}`}function si(s,r){return s==null?"\u2014":`${C(s,r,{maximumFractionDigits:1})} ${a("minutes_short",r)}`}function Qe(s,r,e="long"){return new Date(Date.UTC(2024,0,1+s)).toLocaleDateString(Ye(r),{weekday:e,timeZone:"UTC"})}function er(s,r,e="long"){return new Date(Date.UTC(2024,s,1)).toLocaleDateString(Ye(r),{month:e,timeZone:"UTC"})}function Fs(s,r,e="long"){return Qe((s.getDay()+6)%7,r,e)}function Us(s,r,e="long"){return er(s.getMonth(),r,e)}function Bs(s,r){let e=s.schedule;if(s.schedule_type==="sensor_based"){let i=a("sensor_based",r),n=e&&e.kind==="interval"&&e.every?e.every:s.interval_days;return n?`${i} \xB7 ${Oe(n,(e&&e.kind==="interval"?e.unit:s.interval_unit)||"days",r)}`:i}let t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>Qe(i,r,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?a("ord_last",r):a("ord_"+e.nth,r)} ${Qe(e.weekday,r,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?a(e.business?"last_business_day_month":"last_day_month",r):`${a("day_word",r)} ${e.day}`)+t;case"calendar":return`${a("calendar_entity_label",r)}: ${s.schedule_entity_name||e.entity_id||"\u2014"}${t}`;case"one_time":return s.due_date?O(s.due_date,r):a("one_time",r);case"manual":return a("manual",r);case"interval":return Oe(e.every,e.unit,r)}return s.schedule_type==="one_time"?s.due_date?O(s.due_date,r):a("one_time",r):s.schedule_type==="manual"?a("manual",r):s.schedule_type==="sensor_based"?a("sensor_based",r):s.interval_days!=null?Oe(s.interval_days,s.interval_unit,r):"\u2014"}function Ws(s,r){s.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:r},bubbles:!0,composed:!0}))}var ko,Ds,re,js,Ms,Je,To,Ao,ni,T=y(()=>{"use strict";I();Cs();Kt();Xt();Zi();Z();Xt();ko="\u20AC";Yi(Is);Ds=window,re=Ds.__msDateTimePrefs??={},js=Ds.__msMoneyPrefs??={};Ms=/^(\d{4})-(\d{2})-(\d{2})$/;Je=E` .field { display: flex; flex-direction: column; gap: 4px; } .field-label { font-size: 12px; color: var(--secondary-text-color); } .field-input { @@ -14,7 +14,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-family: inherit; width: 100%; box-sizing: border-box; } .field-input:focus { outline: none; border-color: var(--primary-color); } -`,So=E` +`,To=E` .person-chip { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; vertical-align: middle; } .person-avatar { width: 22px; height: 22px; border-radius: 50%; flex: none; @@ -24,7 +24,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-variant-numeric: tabular-nums; } .person-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } -`,To=E` +`,Ao=E` .ref-chip { display: inline-block; font-size: 11.5px; font-weight: 500; line-height: 1; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--divider-color); color: var(--secondary-text-color); @@ -34,8 +34,8 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" /* #189: "· Residual waste, Paper" after a calendar-driven task's name. */ .event-titles { color: var(--secondary-text-color); font-weight: 400; } `,ni=E` - ${So} ${To} + ${Ao} :host { --maint-ok-color: var(--success-color, #4caf50); --maint-due-soon-color: var(--warning-color, #ff9800); @@ -1290,7 +1290,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .stat-item .stat-label { font-size: 11px; white-space: normal; text-align: center; line-height: 1.2; hyphens: auto; overflow-wrap: anywhere; } .stat-value { font-size: 20px; } } -`});function ai(s){if(!s)return[];let r=[],e=s.photo_doc_id;typeof e=="string"&&r.push(e);let t=s.photo_doc_ids;Array.isArray(t)&&r.push(...t);let i=[];for(let n of r){if(typeof n!="string")continue;let o=n.trim();if(!(!o||i.includes(o))&&(i.push(o),i.length>=10))break}return i}var oi=y(()=>{"use strict"});function Gs(){let s=new Uint8Array(4);return(globalThis.crypto??{getRandomValues:r=>r.map(()=>Math.floor(Math.random()*256))}).getRandomValues(s),Array.from(s,r=>r.toString(16).padStart(2,"0")).join("")}function xe(s){let r=s?.reading_values;if(!Array.isArray(r))return[];let e=[],t=new Set;for(let i of r){if(!i||typeof i!="object")continue;let n=i;typeof n.id!="string"||!n.id||t.has(n.id)||typeof n.value!="number"||!Number.isFinite(n.value)||(t.add(n.id),e.push({id:n.id,name:typeof n.name=="string"?n.name:n.id,unit:typeof n.unit=="string"?n.unit:null,value:n.value}))}return e}function Ks(s){return s.filter(r=>r.type==="completed"&&(r.reading_value!=null||xe(r).length>0)).sort((r,e)=>r.timestamp.localeCompare(e.timestamp))}function Ys(s){return Ks(s??[]).map(r=>({timestamp:r.timestamp,values:xe(r)})).filter(r=>r.values.length>0)}function Qs(s,r,e){let t;for(let i of s){if(e!==void 0){let o=Q(i.timestamp);if(!isNaN(o)&&o>=e)break}let n=i.values.find(o=>o.id===r);n&&(t=n)}return t}function Zs(s,r,e){let t=xe(r).find(n=>n.id===e);if(!t)return null;let i=null;for(let n of Ks(s)){if(n.timestamp>=r.timestamp)break;let o=xe(n).find(d=>d.id===e);o&&(i=o.value)}return i==null?null:t.value-i}function Js(s){let r=new Set,e=new Set;for(let t of s){let i=(t.name||"").trim().toLowerCase();i&&(r.has(i)?e.add(t.id):r.add(i))}return e}function Xs(s){let r=[],e=new Set,t=new Set;for(let i of s){let n=(i.name||"").trim();if(!(!n||e.has(i.id)||t.has(n.toLowerCase()))&&(e.add(i.id),t.add(n.toLowerCase()),r.push({id:i.id,name:n,unit:(i.unit||"").trim()||null}),r.length>=er))break}return r}var er,je=y(()=>{"use strict";Z();er=20});function tr(s,r,e,t){let i=e,n=p=>typeof p=="string"?p:null,o=p=>typeof p=="number"?p:null,d=n(i.timestamp)??"";return{entry_id:s,task_id:r,original_timestamp:d,type:n(i.type)||"completed",timestamp:d,notes:n(i.notes),cost:o(i.cost),duration:o(i.duration),completed_by:n(i.completed_by),used_parts:Array.isArray(i.used_parts)?i.used_parts:null,photo_doc_ids:ai(i),reading_value:o(i.reading_value),reading_values:xe(i),readings:t?.readings??[],task_type:t?.type??null,reading_unit:t?.reading_unit??null}}async function en(s,r,e,t){let n=(await s.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:r})).tasks?.find(d=>d.id===e);if(!n)return null;let o=n.history?.find(d=>d.timestamp===t);return o||(o=(await s.connection.sendMessagePromise({type:"maintenance_supporter/task/history",entry_id:r,task_id:e})).history?.find(p=>p.timestamp===t)),o?tr(r,e,o,n):null}var ir=y(()=>{"use strict";oi();je()});function we(s,r=Me){return s?s.is_admin?!0:r.operatorWriteEnabled&&r.operatorIds.includes(s.id):!1}var Me,wt=y(()=>{"use strict";Me={operatorWriteEnabled:!1,operatorIds:[]}});var tn,rn,sn,kt,$t,Et,rr,ke,Xe,St=y(()=>{"use strict";tn={default_warning_days:[0,365],default_consumable_threshold:[1,90],battery_low_percent:[1,90],battery_recovered_percent:[20,100],archive_oneoff_days:[0,3650],delete_archived_oneoff_days:[0,3650],notify_due_soon_interval_hours:[0,720],notify_overdue_interval_hours:[0,720],notify_triggered_interval_hours:[0,720],max_notifications_per_day:[0,1e3],notification_bundle_threshold:[2,20],snooze_duration_hours:[1,168],warranty_reminder_days:[1,365],budget_alert_threshold:[10,100],currency_decimals:[0,3]},rn=[0,1440],sn=[1,1e4],kt=[0,365],$t=15,Et=[.1,.9],rr=[0,365],ke=[.01,1e4],Xe=[.01,1e5]});function Co(s){let[r,e]=tn.default_warning_days,t=s?.general?.default_warning_days,i=s?.general?.row_action_style;return{features:{...li.features,...s?.features??{}},defaultWarningDays:typeof t=="number"&&Number.isFinite(t)&&t>=r&&t<=e?t:li.defaultWarningDays,rowActionStyle:Ao.includes(i??"")?i:li.rowActionStyle,access:{operatorWriteEnabled:s?.operator_write_enabled===!0,operatorIds:Array.isArray(s?.admin_panel_user_ids)?s.admin_panel_user_ids.filter(n=>typeof n=="string"):[]},budget:s?.budget??null,rowActionNoticePending:s?.general?.row_action_notice_pending===!0,refsInLists:s?.general?.ref_numbers_in_lists===!0,objectsTableColumns:Array.isArray(s?.objects_table_columns)?s.objects_table_columns:void 0,partsCostMode:ds(s?.budget?.parts_cost_mode)}}function nn(){let s=window;return s.__msSettingsCache??={promise:null}}function se(s){let r=nn();if(r.promise)return r.promise;let e=s.connection.sendMessagePromise({type:"maintenance_supporter/settings"}).then(t=>Co(t)).catch(()=>(r.promise===e&&(r.promise=null),li));return r.promise=e,e}function an(){nn().promise=null}var Ao,li,Tt=y(()=>{"use strict";wt();St();Gt();Ao=["buttons_compact","buttons","icons"];li={features:{adaptive:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1,places:!1},defaultWarningDays:7,rowActionStyle:"buttons_compact",access:Me,budget:null,rowActionNoticePending:!1,refsInLists:!1,objectsTableColumns:void 0,partsCostMode:"purchase"}});function Po(s){let r=s.replace(/_ids?$/,"").split("_").filter(Boolean);if(r.length===0)return s;let e=r.join(" ");return e.charAt(0).toUpperCase()+e.slice(1)}function Lo(s,r){let e=Ro[s];if(e){let t=a(e,r);if(t&&t!==e)return t}return Po(s)}function No(s){let r=[...s.matchAll(/\['([^']+)'\]/g)].map(i=>i[1]),e=r.length?r[r.length-1]:void 0,t;return(t=s.match(/length of value must be at most (\d+)/))?{field:e,rule:"too_long",param:t[1]}:(t=s.match(/length of value must be at least (\d+)/))?{field:e,rule:"too_short",param:t[1]}:(t=s.match(/value must be at most (\S+)/))?{field:e,rule:"value_too_high",param:t[1]}:(t=s.match(/value must be at least (\S+)/))?{field:e,rule:"value_too_low",param:t[1]}:/required key not provided/.test(s)?{field:e,rule:"required"}:(t=s.match(/expected (\w+)/))?{field:e,rule:"wrong_type",param:t[1]}:/value must be one of/.test(s)?{field:e,rule:"invalid_choice"}:/not a valid value/.test(s)?{field:e,rule:"invalid_value"}:{field:e,rule:"unknown"}}function Ho(s,r){let e=new Set(on(r));return on(s).every(t=>e.has(t))}function ln(s,r,e){if(e=e??a("action_error",r),typeof s=="string")return s;if(typeof s!="object"||s===null)return e;let t=s,i=t.message||t.error?.message||"",n=t.code||t.error?.code||"",o=n?Io[n]:void 0,d=Ns(t.translation_domain??t.error?.translation_domain,t.translation_key??t.error?.translation_key,t.translation_placeholders??t.error?.translation_placeholders);if(d)return d;if(!i&&!o)return e;let p=No(i),_=p.field?Lo(p.field,r):"",m=f=>a(f,r).replace("{field}",_).replace("{n}",p.param??"");switch(p.rule){case"too_long":return m("err_too_long");case"too_short":return m("err_too_short");case"value_too_high":return m("err_value_too_high");case"value_too_low":return m("err_value_too_low");case"required":return m("err_required");case"wrong_type":return m("err_wrong_type").replace("{type}",p.param??"");case"invalid_choice":return m("err_invalid_choice");case"invalid_value":return m("err_invalid_value");default:if(o){let f=a(o,r);return be(r)==="en"&&i&&!Ho(i,n)?`${f} (${i})`:f}return i||e}}var Io,Ro,on,cn=y(()=>{"use strict";T();Qi();Xt();Io={already_archived:"ws_err_already_archived",already_paused:"ws_err_already_paused",archived:"ws_err_archived",changed_since:"undo_changed",create_failed:"ws_err_create_failed",duplicate_failed:"ws_err_duplicate_failed",empty:"ws_err_empty",empty_csv:"ws_err_empty_csv",invalid_cursor:"ws_err_invalid_cursor",invalid_date:"ws_err_invalid_date",invalid_device:"ws_err_invalid_device",invalid_entity_slug:"ws_err_invalid_entity_slug",invalid_format:"ws_err_invalid_format",invalid_input:"ws_err_invalid_input",invalid_mirror_todo:"ws_err_invalid_mirror_todo",invalid_parent:"ws_err_invalid_parent",invalid_icon:"ws_err_invalid_icon",invalid_range:"ws_err_invalid_range",invalid_search_template:"ws_err_invalid_search_template",invalid_target:"ws_err_invalid_target",invalid_trigger_config:"ws_err_invalid_trigger_config",invalid_url:"ws_err_invalid_url",invalid_user:"ws_err_invalid_user",invalid_view:"ws_err_invalid_view",limit_reached:"ws_err_limit_reached",no_defaults:"ws_err_no_defaults",no_phases:"ws_err_no_phases",no_url:"ws_err_no_url",not_archived:"ws_err_not_archived",not_available:"ws_err_not_available",not_configured:"ws_err_not_configured",not_found:"ws_err_not_found",not_loaded:"ws_err_not_loaded",not_paused:"ws_err_not_paused",nothing_to_undo:"undo_nothing",replace_failed:"ws_err_replace_failed",self_link_device:"ws_err_self_link_device",storage_unavailable:"ws_err_storage_unavailable",too_early:"ws_err_too_early",too_large:"ws_err_too_large",too_many:"ws_err_too_many",too_many_views:"ws_err_too_many_views",unauthorized:"ws_err_unauthorized",unavailable:"ws_err_unavailable"},Ro={entry_id:"object",name:"name",task_type:"maintenance_type",schedule_type:"schedule_type",interval_days:"interval_days",interval_anchor:"interval_anchor",warning_days:"warning_days",last_performed:"last_performed_optional",notes:"notes_optional",documentation_url:"documentation_url_optional",custom_icon:"custom_icon_optional",nfc_tag_id:"nfc_tag_id_optional",responsible_user_id:"responsible_user",entity_slug:"entity_slug",entity_id:"entity_id",area_id:"area_id_optional",manufacturer:"manufacturer_optional",model:"model_optional",serial_number:"serial_number_optional",installation_date:"installation_date_optional",warranty_expiry:"warranty_expiry_optional",checklist:"checklist_steps_optional",reason:"reason",feedback:"feedback",cost:"cost",duration:"duration",description:"description_optional",environmental_entity:"environmental_entity_optional",environmental_attribute:"environmental_attribute_optional",trigger_above:"trigger_above",trigger_below:"trigger_below",trigger_equals:"trigger_equals",trigger_not_equals:"trigger_not_equals",trigger_for_minutes:"trigger_for_minutes"};on=s=>s.toLowerCase().split(/[^a-z0-9]+/).filter(r=>r.length>1)});async function D(s,r,e={}){e.busy?.(!0);try{let t=typeof r=="function"?await r():await s.hass.connection.sendMessagePromise(r);return e.reload&&await e.reload(),e.successToast&&e.onSuccess?.(e.successToast),t===void 0?null:t}catch(t){let i=e.lang||N(s.hass);e.onError?.(ln(t,i,e.fallbackKey?a(e.fallbackKey,i):void 0),t);return}finally{e.busy?.(!1)}}var ze=y(()=>{"use strict";cn();T()});var j,sr=y(()=>{"use strict";I();F();j=class extends S{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1;this.multiline=!1;this.rows=3}_onInput(e){let t=e.target.value;this.value=t,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:t}}))}render(){return l` +`});function ai(s){if(!s)return[];let r=[],e=s.photo_doc_id;typeof e=="string"&&r.push(e);let t=s.photo_doc_ids;Array.isArray(t)&&r.push(...t);let i=[];for(let n of r){if(typeof n!="string")continue;let o=n.trim();if(!(!o||i.includes(o))&&(i.push(o),i.length>=10))break}return i}var oi=y(()=>{"use strict"});function Ks(){let s=new Uint8Array(4);return(globalThis.crypto??{getRandomValues:r=>r.map(()=>Math.floor(Math.random()*256))}).getRandomValues(s),Array.from(s,r=>r.toString(16).padStart(2,"0")).join("")}function xe(s){let r=s?.reading_values;if(!Array.isArray(r))return[];let e=[],t=new Set;for(let i of r){if(!i||typeof i!="object")continue;let n=i;typeof n.id!="string"||!n.id||t.has(n.id)||typeof n.value!="number"||!Number.isFinite(n.value)||(t.add(n.id),e.push({id:n.id,name:typeof n.name=="string"?n.name:n.id,unit:typeof n.unit=="string"?n.unit:null,value:n.value}))}return e}function Ys(s){return s.filter(r=>r.type==="completed"&&(r.reading_value!=null||xe(r).length>0)).sort((r,e)=>r.timestamp.localeCompare(e.timestamp))}function Qs(s){return Ys(s??[]).map(r=>({timestamp:r.timestamp,values:xe(r)})).filter(r=>r.values.length>0)}function Zs(s,r,e){let t;for(let i of s){if(e!==void 0){let o=Q(i.timestamp);if(!isNaN(o)&&o>=e)break}let n=i.values.find(o=>o.id===r);n&&(t=n)}return t}function Js(s,r,e){let t=xe(r).find(n=>n.id===e);if(!t)return null;let i=null;for(let n of Ys(s)){if(n.timestamp>=r.timestamp)break;let o=xe(n).find(d=>d.id===e);o&&(i=o.value)}return i==null?null:t.value-i}function Xs(s){let r=new Set,e=new Set;for(let t of s){let i=(t.name||"").trim().toLowerCase();i&&(r.has(i)?e.add(t.id):r.add(i))}return e}function en(s){let r=[],e=new Set,t=new Set;for(let i of s){let n=(i.name||"").trim();if(!(!n||e.has(i.id)||t.has(n.toLowerCase()))&&(e.add(i.id),t.add(n.toLowerCase()),r.push({id:i.id,name:n,unit:(i.unit||"").trim()||null}),r.length>=tr))break}return r}var tr,je=y(()=>{"use strict";Z();tr=20});function ir(s,r,e,t){let i=e,n=p=>typeof p=="string"?p:null,o=p=>typeof p=="number"?p:null,d=n(i.timestamp)??"";return{entry_id:s,task_id:r,original_timestamp:d,type:n(i.type)||"completed",timestamp:d,notes:n(i.notes),cost:o(i.cost),duration:o(i.duration),completed_by:n(i.completed_by),used_parts:Array.isArray(i.used_parts)?i.used_parts:null,photo_doc_ids:ai(i),reading_value:o(i.reading_value),reading_values:xe(i),readings:t?.readings??[],task_type:t?.type??null,reading_unit:t?.reading_unit??null}}async function tn(s,r,e,t){let n=(await s.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:r})).tasks?.find(d=>d.id===e);if(!n)return null;let o=n.history?.find(d=>d.timestamp===t);return o||(o=(await s.connection.sendMessagePromise({type:"maintenance_supporter/task/history",entry_id:r,task_id:e})).history?.find(p=>p.timestamp===t)),o?ir(r,e,o,n):null}var rr=y(()=>{"use strict";oi();je()});function we(s,r=Me){return s?s.is_admin?!0:r.operatorWriteEnabled&&r.operatorIds.includes(s.id):!1}var Me,wt=y(()=>{"use strict";Me={operatorWriteEnabled:!1,operatorIds:[]}});var rn,sn,nn,kt,$t,Et,sr,ke,Xe,St=y(()=>{"use strict";rn={default_warning_days:[0,365],default_consumable_threshold:[1,90],battery_low_percent:[1,90],battery_recovered_percent:[20,100],archive_oneoff_days:[0,3650],delete_archived_oneoff_days:[0,3650],notify_due_soon_interval_hours:[0,720],notify_overdue_interval_hours:[0,720],notify_triggered_interval_hours:[0,720],max_notifications_per_day:[0,1e3],notification_bundle_threshold:[2,20],snooze_duration_hours:[1,168],warranty_reminder_days:[1,365],budget_alert_threshold:[10,100],currency_decimals:[0,3]},sn=[0,1440],nn=[1,1e4],kt=[0,365],$t=15,Et=[.1,.9],sr=[0,365],ke=[.01,1e4],Xe=[.01,1e5]});function Io(s){let[r,e]=rn.default_warning_days,t=s?.general?.default_warning_days,i=s?.general?.row_action_style;return{features:{...li.features,...s?.features??{}},defaultWarningDays:typeof t=="number"&&Number.isFinite(t)&&t>=r&&t<=e?t:li.defaultWarningDays,rowActionStyle:Co.includes(i??"")?i:li.rowActionStyle,access:{operatorWriteEnabled:s?.operator_write_enabled===!0,operatorIds:Array.isArray(s?.admin_panel_user_ids)?s.admin_panel_user_ids.filter(n=>typeof n=="string"):[]},budget:s?.budget??null,rowActionNoticePending:s?.general?.row_action_notice_pending===!0,refsInLists:s?.general?.ref_numbers_in_lists===!0,objectsTableColumns:Array.isArray(s?.objects_table_columns)?s.objects_table_columns:void 0,partsCostMode:ps(s?.budget?.parts_cost_mode)}}function an(){let s=window;return s.__msSettingsCache??={promise:null}}function se(s){let r=an();if(r.promise)return r.promise;let e=s.connection.sendMessagePromise({type:"maintenance_supporter/settings"}).then(t=>Io(t)).catch(()=>(r.promise===e&&(r.promise=null),li));return r.promise=e,e}function on(){an().promise=null}var Co,li,Tt=y(()=>{"use strict";wt();St();Gt();Co=["buttons_compact","buttons","icons"];li={features:{adaptive:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1,places:!1},defaultWarningDays:7,rowActionStyle:"buttons_compact",access:Me,budget:null,rowActionNoticePending:!1,refsInLists:!1,objectsTableColumns:void 0,partsCostMode:"purchase"}});function Lo(s){let r=s.replace(/_ids?$/,"").split("_").filter(Boolean);if(r.length===0)return s;let e=r.join(" ");return e.charAt(0).toUpperCase()+e.slice(1)}function No(s,r){let e=Po[s];if(e){let t=a(e,r);if(t&&t!==e)return t}return Lo(s)}function Ho(s){let r=[...s.matchAll(/\['([^']+)'\]/g)].map(i=>i[1]),e=r.length?r[r.length-1]:void 0,t;return(t=s.match(/length of value must be at most (\d+)/))?{field:e,rule:"too_long",param:t[1]}:(t=s.match(/length of value must be at least (\d+)/))?{field:e,rule:"too_short",param:t[1]}:(t=s.match(/value must be at most (\S+)/))?{field:e,rule:"value_too_high",param:t[1]}:(t=s.match(/value must be at least (\S+)/))?{field:e,rule:"value_too_low",param:t[1]}:/required key not provided/.test(s)?{field:e,rule:"required"}:(t=s.match(/expected (\w+)/))?{field:e,rule:"wrong_type",param:t[1]}:/value must be one of/.test(s)?{field:e,rule:"invalid_choice"}:/not a valid value/.test(s)?{field:e,rule:"invalid_value"}:{field:e,rule:"unknown"}}function qo(s,r){let e=new Set(ln(r));return ln(s).every(t=>e.has(t))}function cn(s,r,e){if(e=e??a("action_error",r),typeof s=="string")return s;if(typeof s!="object"||s===null)return e;let t=s,i=t.message||t.error?.message||"",n=t.code||t.error?.code||"",o=n?Ro[n]:void 0,d=Hs(t.translation_domain??t.error?.translation_domain,t.translation_key??t.error?.translation_key,t.translation_placeholders??t.error?.translation_placeholders);if(d)return d;if(!i&&!o)return e;let p=Ho(i),_=p.field?No(p.field,r):"",m=f=>a(f,r).replace("{field}",_).replace("{n}",p.param??"");switch(p.rule){case"too_long":return m("err_too_long");case"too_short":return m("err_too_short");case"value_too_high":return m("err_value_too_high");case"value_too_low":return m("err_value_too_low");case"required":return m("err_required");case"wrong_type":return m("err_wrong_type").replace("{type}",p.param??"");case"invalid_choice":return m("err_invalid_choice");case"invalid_value":return m("err_invalid_value");default:if(o){let f=a(o,r);return be(r)==="en"&&i&&!qo(i,n)?`${f} (${i})`:f}return i||e}}var Ro,Po,ln,dn=y(()=>{"use strict";T();Zi();Xt();Ro={already_archived:"ws_err_already_archived",already_paused:"ws_err_already_paused",archived:"ws_err_archived",changed_since:"undo_changed",create_failed:"ws_err_create_failed",duplicate_failed:"ws_err_duplicate_failed",empty:"ws_err_empty",empty_csv:"ws_err_empty_csv",invalid_cursor:"ws_err_invalid_cursor",invalid_date:"ws_err_invalid_date",invalid_device:"ws_err_invalid_device",invalid_entity_slug:"ws_err_invalid_entity_slug",invalid_format:"ws_err_invalid_format",invalid_input:"ws_err_invalid_input",invalid_mirror_todo:"ws_err_invalid_mirror_todo",invalid_parent:"ws_err_invalid_parent",invalid_icon:"ws_err_invalid_icon",invalid_range:"ws_err_invalid_range",invalid_search_template:"ws_err_invalid_search_template",invalid_target:"ws_err_invalid_target",invalid_trigger_config:"ws_err_invalid_trigger_config",invalid_url:"ws_err_invalid_url",invalid_user:"ws_err_invalid_user",invalid_view:"ws_err_invalid_view",limit_reached:"ws_err_limit_reached",no_defaults:"ws_err_no_defaults",no_phases:"ws_err_no_phases",no_url:"ws_err_no_url",not_archived:"ws_err_not_archived",not_available:"ws_err_not_available",not_configured:"ws_err_not_configured",not_found:"ws_err_not_found",not_loaded:"ws_err_not_loaded",not_paused:"ws_err_not_paused",nothing_to_undo:"undo_nothing",replace_failed:"ws_err_replace_failed",self_link_device:"ws_err_self_link_device",storage_unavailable:"ws_err_storage_unavailable",too_early:"ws_err_too_early",too_large:"ws_err_too_large",too_many:"ws_err_too_many",too_many_views:"ws_err_too_many_views",unauthorized:"ws_err_unauthorized",unavailable:"ws_err_unavailable"},Po={entry_id:"object",name:"name",task_type:"maintenance_type",schedule_type:"schedule_type",interval_days:"interval_days",interval_anchor:"interval_anchor",warning_days:"warning_days",last_performed:"last_performed_optional",notes:"notes_optional",documentation_url:"documentation_url_optional",custom_icon:"custom_icon_optional",nfc_tag_id:"nfc_tag_id_optional",responsible_user_id:"responsible_user",entity_slug:"entity_slug",entity_id:"entity_id",area_id:"area_id_optional",manufacturer:"manufacturer_optional",model:"model_optional",serial_number:"serial_number_optional",installation_date:"installation_date_optional",warranty_expiry:"warranty_expiry_optional",checklist:"checklist_steps_optional",reason:"reason",feedback:"feedback",cost:"cost",duration:"duration",description:"description_optional",environmental_entity:"environmental_entity_optional",environmental_attribute:"environmental_attribute_optional",trigger_above:"trigger_above",trigger_below:"trigger_below",trigger_equals:"trigger_equals",trigger_not_equals:"trigger_not_equals",trigger_for_minutes:"trigger_for_minutes"};ln=s=>s.toLowerCase().split(/[^a-z0-9]+/).filter(r=>r.length>1)});async function D(s,r,e={}){e.busy?.(!0);try{let t=typeof r=="function"?await r():await s.hass.connection.sendMessagePromise(r);return e.reload&&await e.reload(),e.successToast&&e.onSuccess?.(e.successToast),t===void 0?null:t}catch(t){let i=e.lang||N(s.hass),n=await Do(s,t,i)??cn(t,i,e.fallbackKey?a(e.fallbackKey,i):void 0);e.onError?.(n,t);return}finally{e.busy?.(!1)}}async function Do(s,r,e){let t=typeof r=="object"&&r!==null?r:{};if(!Oo.has(t.code||t.error?.code||""))return null;try{let i=Ps(await s.hass.connection.sendMessagePromise({type:"maintenance_supporter/version"}));return i?a("restart_pending_error",e).replace("{version}",i):null}catch{return null}}var Oo,ze=y(()=>{"use strict";dn();T();Gi();Oo=new Set(["unknown_error","invalid_format","unknown_command"])});var j,nr=y(()=>{"use strict";I();F();j=class extends S{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1;this.multiline=!1;this.rows=3}_onInput(e){let t=e.target.value;this.value=t,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:t}}))}render(){return l`