291 files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.10.1", "start_ts": 1791844372.9210048}
|
||||
{"pid": 73, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792001005.2570755}
|
||||
+37
-37
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()}
|
||||
</style>
|
||||
<ha-card part="card">
|
||||
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17">${
|
||||
paths.map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join("")}</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register both elements so they survive the
|
||||
* ``@webcomponents/scoped-custom-element-registry`` polyfill that
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
Apache License
|
||||
==============
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
### 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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/<id>``), 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/<id>, 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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+89
-56
@@ -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<void> {
|
||||
@@ -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<string, { name: string; entry: string | null; adding: number }>();
|
||||
/** The objects the ticked rows go to, each with the tasks it gains. */
|
||||
private _summaryGroups(): SummaryGroup[] {
|
||||
const groups = new Map<string, SummaryGroup>();
|
||||
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`
|
||||
<div class="summary">
|
||||
<div class="summary-title">${t("adopt_summary_title", L)}</div>
|
||||
${[...groups.values()].map((g) => {
|
||||
<details
|
||||
class="summary"
|
||||
.open=${this._summaryOpen}
|
||||
@toggle=${(e: Event) => { this._summaryOpen = (e.target as HTMLDetailsElement).open; }}
|
||||
>
|
||||
<summary>
|
||||
<span class="summary-title">${t("adopt_summary_title", L)}</span>
|
||||
<span class="summary-totals">${totals}</span>
|
||||
</summary>
|
||||
${groups.map((g) => {
|
||||
if (!g.entry) {
|
||||
return html`<div class="summary-line new">
|
||||
${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))}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -269,13 +310,12 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
const allSelected =
|
||||
this._sensors.length > 0 && this._selected.size === this._sensors.length;
|
||||
|
||||
return html`
|
||||
<div class="overlay" @click=${this._close}>
|
||||
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="title">${t("adopt_problem_title", L)}</div>
|
||||
<div class="hint">${t("adopt_problem_hint", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
return renderModalShell(() => this._close(), html`
|
||||
<div class="title">${t("adopt_problem_title", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="dialog-scroll">
|
||||
<div class="hint">${t("adopt_problem_hint", L)}</div>
|
||||
${this._loading
|
||||
? html`<div class="loading">…</div>`
|
||||
: this._sensors.length === 0
|
||||
@@ -392,47 +432,28 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size === 0 || this._adopting}
|
||||
>
|
||||
${t("adopt_problem_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size === 0 || this._adopting}
|
||||
>
|
||||
${t("adopt_problem_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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")) {
|
||||
|
||||
+54
-76
@@ -7,6 +7,9 @@
|
||||
*
|
||||
* Uses a native <input> for the name — <ha-textfield> 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<void> {
|
||||
@@ -90,81 +95,58 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
return html`
|
||||
<div class="overlay" @click=${this._close}>
|
||||
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="title">${t("views_dialog_title", L)}</div>
|
||||
<div class="hint">${t("views_dialog_hint", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
return renderModalShell(() => this._close(), html`
|
||||
<div class="title">${t("views_dialog_title", L)}</div>
|
||||
<div class="hint">${t("views_dialog_hint", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="save-row">
|
||||
<input
|
||||
class="name-input"
|
||||
type="text"
|
||||
.value=${this._name}
|
||||
placeholder=${t("views_name_placeholder", L)}
|
||||
maxlength="60"
|
||||
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") this._save();
|
||||
}}
|
||||
/>
|
||||
<ha-button @click=${this._save} .disabled=${!this._name.trim() || this._busy}>
|
||||
${t("views_save_current", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
|
||||
${this._views.length === 0
|
||||
? html`<div class="empty">${t("views_none_yet", L)}</div>`
|
||||
: html`
|
||||
<div class="list">
|
||||
${this._views.map(
|
||||
(v) => html`
|
||||
<div class="row">
|
||||
<span class="row-name">${v.name}</span>
|
||||
<ha-icon-button
|
||||
.path=${"M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"}
|
||||
.label=${t("delete", L)}
|
||||
@click=${() => this._delete(v.id)}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>${t("close", L)}</ha-button>
|
||||
</div>
|
||||
<div class="save-row">
|
||||
<input
|
||||
class="name-input"
|
||||
type="text"
|
||||
.value=${this._name}
|
||||
placeholder=${t("views_name_placeholder", L)}
|
||||
maxlength="60"
|
||||
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") this._save();
|
||||
}}
|
||||
/>
|
||||
<ha-button @click=${this._save} .disabled=${!this._name.trim() || this._busy}>
|
||||
${t("views_save_current", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
${this._views.length === 0
|
||||
? html`<div class="empty">${t("views_none_yet", L)}</div>`
|
||||
: html`
|
||||
<div class="list dialog-scroll">
|
||||
${this._views.map(
|
||||
(v) => html`
|
||||
<div class="row">
|
||||
<span class="row-name">${v.name}</span>
|
||||
<ha-icon-button
|
||||
.path=${"M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"}
|
||||
.label=${t("delete", L)}
|
||||
@click=${() => this._delete(v.id)}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>${t("close", L)}</ha-button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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")) {
|
||||
|
||||
+28
-44
@@ -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`
|
||||
<div class="overlay" @click=${this._close}>
|
||||
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="title">${t("setups_title", L)}</div>
|
||||
<div class="hint">${t("setups_hint", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
return renderModalShell(() => this._close(), html`
|
||||
<div class="title">${t("setups_title", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="dialog-scroll">
|
||||
<div class="hint">${t("setups_hint", L)}</div>
|
||||
${this._renderOffers(L)}
|
||||
${this._loading
|
||||
? html`<div class="loading">…</div>`
|
||||
@@ -428,50 +432,31 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._adopt} .disabled=${!this._anythingTicked || this._adopting}>
|
||||
${t("setups_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._adopt} .disabled=${!this._anythingTicked || this._adopting}>
|
||||
${t("setups_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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")) {
|
||||
|
||||
@@ -3072,6 +3072,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<option value="completion" ?selected=${this._intervalAnchor === "completion"}>${t("anchor_completion", L)}</option>
|
||||
<option value="planned" ?selected=${this._intervalAnchor === "planned"}>${t("anchor_planned", L)}</option>
|
||||
</select>
|
||||
<div class="field-help anchor-help">${t("interval_anchor_hint", L)}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "आइकन पर वापस जाएँ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アイコンに戻す",
|
||||
|
||||
@@ -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": "아이콘으로 되돌리기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вернуть значки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Повернути значки",
|
||||
|
||||
@@ -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": "改回图标",
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
private _dismissedSuggestions = new Set<string>();
|
||||
|
||||
@@ -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<ServerVersion>({ 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<void> {
|
||||
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<SortMode, (a: TaskRow, b: TaskRow) => number> = {
|
||||
due_date: byDue,
|
||||
@@ -3172,6 +3196,14 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<div class="panel">
|
||||
${this._restartPendingVersion
|
||||
? html`
|
||||
<div class="update-banner restart-pending-banner" role="status">
|
||||
<ha-icon icon="mdi:restart"></ha-icon>
|
||||
<span>${t("restart_pending_banner", this._lang).replace("{version}", this._restartPendingVersion)}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this._staleBundle
|
||||
? html`
|
||||
<div class="update-banner" role="status">
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
options?: Record<string, unknown>
|
||||
): 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "आइकन पर वापस जाएँ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アイコンに戻す",
|
||||
|
||||
@@ -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": "아이콘으로 되돌리기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вернуть значки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Повернути значки",
|
||||
|
||||
@@ -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": "改回图标",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+300
@@ -0,0 +1,300 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{e as b}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DPJSGXJS.js";import{d as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2BQZ33DM.js";import{a as x,b as w,c as $}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-QVOLUT24.js";import{a as m}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3LDXEWBF.js";import{I as y,a as l,b as u,c as n,f as p,h as g,m as v,n as d,t as i,v as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var j=3,o=class extends g{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._objectNames={};this._objects=[];this._covered={};this._users=[];this._responsible="";this._forMinutes="0";this._summaryOpen=!0;this._localeReady=!1;this._userService=null;this._toggle=e=>{let s=new Set(this._selected);s.has(e)?s.delete(e):s.add(e),this._selected=s};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(e=>e.entity_id))};this._adopt=async()=>{if(this._selected.size===0||this._adopting)return;this._error="";let e=this._sensors.filter(t=>this._selected.has(t.entity_id)).map(t=>({entity_id:t.entity_id,name:t.name,entry_id:this._existingEntryFor(t)??void 0,object_name:this._effectiveName(t),device_id:t.device_id??void 0,part_id:t.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),s=await m(this,{type:"maintenance_supporter/problem_sensors/adopt",selections:e},{busy:t=>{this._adopting=t},onError:t=>{this._error=t}});s!==void 0&&(this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:s})),this._open=!1)}}get _lang(){return y(this.hass)}updated(e){e.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,f(this._lang).then(()=>this.requestUpdate())),e.has("_open")&&this._open&&$(this.shadowRoot)}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._objectNames={},this._responsible="",this._forMinutes="0",this._userService?this._userService.updateHass(this.hass):this._userService=new b(this.hass);let[e,s,t]=await Promise.all([m(this,{type:"maintenance_supporter/problem_sensors/discover"},{onError:r=>{this._error=r}}),this._userService.getUsers().catch(()=>[]),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>({objects:[]}))]);this._loading=!1,e!==void 0&&(this._objects=(t?.objects||[]).filter(r=>!r.object.archived_at).map(r=>({entry_id:r.entry_id,name:r.object.name,tasks:(r.tasks||[]).length})),this._sensors=e?.sensors||[],this._covered=Object.fromEntries(this._sensors.map(r=>[r.entity_id,r.covered_by??null])),this._selected=new Set(this._sensors.filter(r=>!r.covered_by).map(r=>r.entity_id)),this._users=s,this._summaryOpen=this._summaryGroups().length<=j)}_defaultName(e){return e.suggested_entry_id?e.suggested_object_name:e.candidate?.name??e.suggested_object_name}_effectiveName(e){return(this._objectNames[e.entity_id]??"").trim()||this._defaultName(e)}_existingEntryFor(e){let s=this._effectiveName(e),t=e.suggested_entry_id||e.target_entry_id||null;if(s===this._defaultName(e)&&t)return t;let r=this._objects.find(a=>a.name.trim().toLowerCase()===s.toLowerCase());return r?r.entry_id:null}_schedulePreview(){clearTimeout(this._previewTimer),this._previewTimer=setTimeout(()=>{this._preview()},350)}async _preview(){let e=this._sensors.map(t=>({entity_id:t.entity_id,name:t.name,entry_id:this._existingEntryFor(t)})),s=await m(this,{type:"maintenance_supporter/problem_sensors/preview",selections:e},{onError:()=>{}});s?.covered&&(this._covered={...this._covered,...s.covered})}_summaryGroups(){let e=new Map;for(let s of this._sensors){if(!this._selected.has(s.entity_id))continue;let t=this._existingEntryFor(s),r=this._effectiveName(s),a=t??`new:${r.toLowerCase()}`,c=e.get(a)??{name:r,entry:t,adding:0};c.adding+=1,e.set(a,c)}return[...e.values()]}_summary(e){let s=this._summaryGroups();if(s.length===0)return p;let t=s.filter(a=>!a.entry).length,r=i(t?"adopt_summary_totals_new":"adopt_summary_totals",e).replace("{tasks}",String(s.reduce((a,c)=>a+c.adding,0))).replace("{objects}",String(s.length)).replace("{new}",String(t));return n`
|
||||
<details
|
||||
class="summary"
|
||||
.open=${this._summaryOpen}
|
||||
@toggle=${a=>{this._summaryOpen=a.target.open}}
|
||||
>
|
||||
<summary>
|
||||
<span class="summary-title">${i("adopt_summary_title",e)}</span>
|
||||
<span class="summary-totals">${r}</span>
|
||||
</summary>
|
||||
${s.map(a=>{if(!a.entry)return n`<div class="summary-line new">
|
||||
${i("setups_new_object_count",e).replace("{name}",a.name).replace("{count}",String(a.adding))}
|
||||
</div>`;let c=this._objects.find(_=>_.entry_id===a.entry)?.tasks??0;return n`<div class="summary-line">
|
||||
${i("adopt_summary_existing",e).replace("{name}",a.name).replace("{before}",String(c)).replace("{after}",String(c+a.adding))}
|
||||
</div>`})}
|
||||
</details>
|
||||
`}_close(){clearTimeout(this._previewTimer),this._open=!1}_targetLabel(e,s){if(e.candidate&&this._existingEntryFor(e)===e.candidate.entry_id){let t=e.candidate.reasons.map(r=>i(`setups_reason_${r}`,s)).join(", ");return i("setups_target_match",s).replace("{name}",e.candidate.name).replace("{reasons}",t)}return this._effectiveName(e)}render(){if(!this._open)return n``;let e=this._lang,s=this._sensors.length>0&&this._selected.size===this._sensors.length;return w(()=>this._close(),n`
|
||||
<div class="title">${i("adopt_problem_title",e)}</div>
|
||||
${this._error?n`<div class="error">${this._error}</div>`:p}
|
||||
|
||||
<div class="dialog-scroll">
|
||||
<div class="hint">${i("adopt_problem_hint",e)}</div>
|
||||
${this._loading?n`<div class="loading">…</div>`:this._sensors.length===0?n`<div class="empty">${i("adopt_problem_none",e)}</div>`:n`
|
||||
<label class="select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${s}
|
||||
@change=${this._toggleAll}
|
||||
/>
|
||||
<span>${i("selected",e)}: ${this._selected.size} / ${this._sensors.length}</span>
|
||||
</label>
|
||||
<div class="row-sub adopt-object-hint">${i("adopt_object_hint",e)}</div>
|
||||
<datalist id="adopt-object-names">
|
||||
${this._objects.map(t=>n`<option value=${t.name}></option>`)}
|
||||
</datalist>
|
||||
<div class="list">
|
||||
${this._sensors.map(t=>{let r=this._selected.has(t.entity_id),a=t.state==="on",c=[t.device_name,t.area_name].filter(Boolean).join(" \xB7 ");return n`
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${r}
|
||||
@change=${()=>this._toggle(t.entity_id)}
|
||||
/>
|
||||
<div class="row-main">
|
||||
<div class="row-top">
|
||||
<span class="row-name">${t.name}</span>
|
||||
<span class="chip ${a?"chip-active":"chip-ok"}">
|
||||
${a?i("adopt_problem_active",e):i("adopt_problem_ok",e)}
|
||||
</span>
|
||||
</div>
|
||||
${c?n`<div class="row-sub">${c}</div>`:p}
|
||||
${r?n`<div class="row-object" @click=${_=>_.stopPropagation()}>
|
||||
<input
|
||||
class="adopt-object"
|
||||
list="adopt-object-names"
|
||||
aria-label=${i("object",e)}
|
||||
placeholder=${this._defaultName(t)}
|
||||
.value=${this._objectNames[t.entity_id]??this._defaultName(t)}
|
||||
@input=${_=>{this._objectNames={...this._objectNames,[t.entity_id]:_.target.value},this._schedulePreview()}}
|
||||
/>
|
||||
</div>`:p}
|
||||
<div class="row-target">
|
||||
→ ${this._targetLabel(t,e)}${this._existingEntryFor(t)?p:n` <span class="new-tag">${i("adopt_problem_new_object",e)}</span>`}
|
||||
</div>
|
||||
${this._covered[t.entity_id]?n`<div class="maybe">
|
||||
${i("setups_maybe_covered",e).replace("{name}",this._covered[t.entity_id].name)}
|
||||
</div>`:p}
|
||||
${t.suggested_part_name?n`<div class="row-part">
|
||||
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
|
||||
${i("adopt_problem_part",e).replace("{name}",t.suggested_part_name)}
|
||||
</div>`:p}
|
||||
</div>
|
||||
</label>
|
||||
`})}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!this._loading&&this._sensors.length>0?this._summary(e):p}
|
||||
|
||||
${!this._loading&&this._sensors.length>0?n`
|
||||
<label class="responsible">
|
||||
<span>${i("for_at_least_minutes",e)}</span>
|
||||
<input
|
||||
class="for-input"
|
||||
type="number"
|
||||
min=${h[0]}
|
||||
max=${h[1]}
|
||||
.value=${this._forMinutes}
|
||||
@input=${t=>this._forMinutes=t.target.value}
|
||||
/>
|
||||
</label>
|
||||
<div class="for-hint">${i("adopt_for_minutes_hint",e)}</div>
|
||||
`:p}
|
||||
|
||||
${!this._loading&&this._sensors.length>0&&this._users.length>0?n`
|
||||
<label class="responsible">
|
||||
<span>${i("adopt_problem_responsible",e)}</span>
|
||||
<select
|
||||
.value=${this._responsible}
|
||||
@change=${t=>{this._responsible=t.target.value}}
|
||||
>
|
||||
<option value="" ?selected=${!this._responsible}>${i("no_user_assigned",e)}</option>
|
||||
${this._users.map(t=>n`<option value=${t.id} ?selected=${t.id===this._responsible}>${t.name}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
`:p}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${i("cancel",e)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size===0||this._adopting}
|
||||
>
|
||||
${i("adopt_problem_adopt",e)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`)}};o.styles=[x,u`
|
||||
: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;
|
||||
}
|
||||
/* 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;
|
||||
gap: 8px;
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row input {
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.row-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.row-sub {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row-object { margin-top: 6px; }
|
||||
.row-object input {
|
||||
width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
background: var(--card-background-color); color: var(--primary-text-color);
|
||||
}
|
||||
.adopt-object-hint { margin: 0 0 8px; }
|
||||
.row-target {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row-part {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.row-part ha-icon {
|
||||
--mdc-icon-size: 14px;
|
||||
}
|
||||
.new-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
.maybe {
|
||||
font-size: 11px;
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
border-top: 1px dashed var(--divider-color);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.summary > summary {
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.summary-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.summary-totals {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.summary-line {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-active {
|
||||
background: var(--error-color, #f44336);
|
||||
color: #fff;
|
||||
}
|
||||
.chip-ok {
|
||||
background: var(--divider-color);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.responsible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.for-input {
|
||||
width: 70px;
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.for-hint {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
.responsible select {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
`],l([v({attribute:!1})],o.prototype,"hass",2),l([d()],o.prototype,"_open",2),l([d()],o.prototype,"_loading",2),l([d()],o.prototype,"_adopting",2),l([d()],o.prototype,"_error",2),l([d()],o.prototype,"_sensors",2),l([d()],o.prototype,"_selected",2),l([d()],o.prototype,"_objectNames",2),l([d()],o.prototype,"_objects",2),l([d()],o.prototype,"_covered",2),l([d()],o.prototype,"_users",2),l([d()],o.prototype,"_responsible",2),l([d()],o.prototype,"_forMinutes",2),l([d()],o.prototype,"_summaryOpen",2);customElements.get("maintenance-adopt-problem-sensors-dialog")||customElements.define("maintenance-adopt-problem-sensors-dialog",o);export{o as MaintenanceAdoptProblemSensorsDialog};
|
||||
@@ -0,0 +1,279 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{a as X}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-H44SM3WA.js";import{a as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-274MLN75.js";import{f as E,g as M,h as z,i as Q,j as U,k as Y,l as q,m as G,n as R,o as A,p as W}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-FH35SLMS.js";import{a as B}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KZ3OKTLK.js";import{e as P,f as K}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DR2AFVDA.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-EOMYEBQ4.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-MMIPM7EE.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-R77C34J4.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-IRRUA3DS.js";import{C as S,I as F,L as g,M as v,P as N,Q as I,V as w,X as T,a as $,c as p,f as y,h as H,m as k,n as j,o as C,r as D,t as a,v as L,z as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";function J(b,i,t,s){let l=b.entries.filter(e=>e.type==="completed").sort((e,n)=>e.ts-n.ts),d=`${b.from?t.date(b.from):"\u2026"} \u2013 ${b.to?t.date(b.to):"\u2026"}`,r=b.totals,h=[[i.completions,t.number(r.completions)],[i.totalCost,t.cost(r.totalCost)],[i.avgCost,r.avgCost!=null?t.cost(r.avgCost):i.none],[i.totalTime,r.totalDuration?t.duration(r.totalDuration):i.none]].map(([e,n])=>`<div class="kpi"><div class="k">${o(e)}</div><div class="v">${o(n)}</div></div>`).join(""),u=b.byObject.map(e=>`<tr>
|
||||
<td>${o(e.objectName)}</td>
|
||||
<td class="num">${o(t.number(e.completions))}</td>
|
||||
<td class="num">${o(t.cost(e.cost))}</td>
|
||||
<td class="num">${o(t.share(e.share))}</td>
|
||||
</tr>`).join(""),f=b.buckets.buckets.reduce((e,n)=>Math.max(e,n.cost),0),c=b.buckets.buckets.map(e=>{let n=f>0?Math.round(e.cost/f*100):0;return`<tr>
|
||||
<td class="nowrap">${o(t.bucket(e))}</td>
|
||||
<td class="num">${o(t.number(e.completions))}</td>
|
||||
<td class="bar-cell">${n>0?`<span class="bar" style="width:${n}%"></span>`:""}</td>
|
||||
<td class="num">${o(t.cost(e.cost))}</td>
|
||||
</tr>`}).join(""),m=l.map(e=>{let n=[e.notes,e.completedBy?`${i.completedBy}: ${e.completedBy}`:null].filter(Boolean).join(" \xB7 "),x=e.phaseName?`${e.taskName} \xB7 ${e.phaseName}`:e.taskName;return`<tr>
|
||||
<td class="nowrap">${o(t.date(e.timestamp))}</td>
|
||||
<td>${o(e.objectName)}</td>
|
||||
<td>${o(x)}</td>
|
||||
<td class="num">${e.cost!=null?o(t.cost(e.cost)):o(i.none)}</td>
|
||||
<td class="num">${e.duration!=null?o(t.duration(e.duration)):o(i.none)}</td>
|
||||
<td class="notes">${o(n)||o(i.none)}</td>
|
||||
</tr>`}).join(`
|
||||
`);return`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>${o(i.title)} \u2014 ${o(b.areaName)}</title>
|
||||
<style>
|
||||
/* Printable sheet: it opens as a blob in whatever viewer the OS supplies
|
||||
(Companion = WebView, dark phones paint a dark default canvas), so the
|
||||
document states its own light scheme and paints its background. */
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #fff; margin: 32px; }
|
||||
h1 { font-size: 22px; margin: 0 0 2px; }
|
||||
h2 { font-size: 15px; margin: 24px 0 8px; }
|
||||
.sub { color: #666; margin: 0 0 16px; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0 0 8px; }
|
||||
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px 10px; }
|
||||
.kpi .k { color: #777; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.kpi .v { font-size: 16px; font-weight: 600; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #666; border-bottom: 2px solid #ccc; padding: 6px 8px; }
|
||||
td { border-bottom: 1px solid #e5e5e5; padding: 6px 8px; vertical-align: top; }
|
||||
td.num, th.num { text-align: right; white-space: nowrap; }
|
||||
td.nowrap { white-space: nowrap; }
|
||||
td.notes { color: #444; white-space: pre-wrap; }
|
||||
td.bar-cell { width: 40%; vertical-align: middle; }
|
||||
.bar { display: block; height: 10px; background: #9aa7b8; border-radius: 0 3px 3px 0;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
tfoot td { border-bottom: none; border-top: 2px solid #ccc; font-weight: 600; }
|
||||
tr { break-inside: avoid; }
|
||||
.cap-note { margin-top: 14px; color: #888; font-size: 11px; }
|
||||
@media print { body { margin: 12mm; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${o(i.title)} \u2014 ${o(b.areaName)}</h1>
|
||||
<p class="sub">${o(i.period)}: ${o(d)} \xB7 ${o(i.generated)} ${o(t.date(s))}</p>
|
||||
<div class="kpis">${h}</div>
|
||||
|
||||
<h2>${o(i.costPerObject)}</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${o(i.colObject)}</th><th class="num">${o(i.completions)}</th>
|
||||
<th class="num">${o(i.colCost)}</th><th class="num">${o(i.share)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${u||`<tr><td colspan="4">${o(i.none)}</td></tr>`}</tbody>
|
||||
<tfoot><tr>
|
||||
<td>${o(i.totalCost)}</td><td class="num">${o(t.number(r.completions))}</td>
|
||||
<td class="num">${o(t.cost(r.totalCost))}</td><td></td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
|
||||
<h2>${o(i.costPerBucket)}</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${o(i.period)}</th><th class="num">${o(i.completions)}</th>
|
||||
<th></th><th class="num">${o(i.colCost)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${c||`<tr><td colspan="4">${o(i.none)}</td></tr>`}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>${o(i.historyHeading)} (${o(t.number(l.length))})</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${o(i.colDate)}</th><th>${o(i.colObject)}</th><th>${o(i.colTask)}</th>
|
||||
<th class="num">${o(i.colCost)}</th><th class="num">${o(i.colDuration)}</th><th>${o(i.colNotes)}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${m||`<tr><td colspan="6">${o(i.none)}</td></tr>`}
|
||||
</tbody>
|
||||
<tfoot><tr>
|
||||
<td colspan="3">${o(i.totalCost)}</td>
|
||||
<td class="num">${o(t.cost(r.totalCost))}</td>
|
||||
<td class="num">${r.totalDuration?o(t.duration(r.totalDuration)):""}</td><td></td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
${b.capped?`<p class="cap-note">${o(i.capNote)}</p>`:""}
|
||||
</body>
|
||||
</html>`}var Z=25,V=5,tt={completed:C.ok,missed:C.overdue,reset:C.paused,skipped:"var(--secondary-text-color)"},_=class extends H{constructor(){super(...arguments);this.areaKey="";this.objects=[];this.showArchived=!1;this.currencySymbol="";this.userName=()=>null;this._from="";this._to="";this._entryFilter="";this._taskQuery="";this._expanded=!1;this._printing=!1;this._histories=new X(this);this._localeReady=!1;this._filtersFor=null;this._memo=null}createRenderRoot(){return this}get _lang(){return F(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("areaKey")&&this._filtersFor!==this.areaKey){this._filtersFor=this.areaKey;let s=R();this._from=s.from,this._to=s.to,this._entryFilter="",this._taskQuery="",this._expanded=!1}if(t.has("objects")||t.has("areaKey")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let s=this._areaObjects().flatMap(l=>l.tasks.map(d=>({entryId:l.entry_id,task:d})));this._histories.sync(this.hass,s)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,L(this._lang).then(()=>this.requestUpdate()))}_areaObjects(){return z(this.objects,this.areaKey,this.showArchived).sort((t,s)=>t.object.name.localeCompare(s.object.name))}_entries(t){let s=[this.objects,this.areaKey,this.showArchived,this._histories.version];if(this._memo&&this._memo.deps.every((d,r)=>d===s[r]))return this._memo.entries;let l=Q(t,(d,r)=>this._histories.historyOf(d,r));return this._memo={deps:s,entries:l},l}_figures(){let t=this._areaObjects(),s=this._entries(t),l={from:this._from||null,to:this._to||null},d=U(s,{...l,entryId:this._entryFilter||null,taskQuery:this._taskQuery}),r=this._entryFilter?t.filter(h=>h.entry_id===this._entryFilter):t;return{objects:t,entries:s,filtered:d,totals:Y(d),buckets:G(d,l),byObject:q(d,r)}}_setRange(t){this._from=t.from,this._to=t.to,this._expanded=!1}_openObject(t){this.dispatchEvent(new CustomEvent("open-object",{detail:{entryId:t},bubbles:!0,composed:!0}))}_openTask(t,s){this.dispatchEvent(new CustomEvent("open-task",{detail:{entryId:t,taskId:s},bubbles:!0,composed:!0}))}_bucketLabel(t,s){return t.month==null?String(t.year):`${T(t.month,this._lang,s)} ${t.year}`}_countText(t,s,l){let d=this._lang;return l===1?a(s,d):a(t,d).replace("{n}",g(l,d))}async _print(){if(this._printing)return;let t=K();this._printing=!0;try{await this._histories.settled()}finally{this._printing=!1}let s=this._lang,l=this.currencySymbol,{filtered:d,totals:r,buckets:h,byObject:u}=this._figures(),f={title:a("area_report_title",s),generated:a("report_generated",s),period:a("area_report_period",s),completions:a("area_kpi_completions",s),totalCost:a("total_cost",s),avgCost:a("avg_cost",s),totalTime:a("area_kpi_total_time",s),costPerObject:a("area_cost_per_object",s),costPerBucket:h.unit==="month"?a("area_cost_per_month",s):a("area_cost_per_year",s),share:a("area_cost_share",s),historyHeading:a("area_history_section",s),colDate:a("date",s),colObject:a("object",s),colTask:a("task_name",s),colCost:a("cost",s),colDuration:a("duration",s),colNotes:a("notes_label",s),completedBy:a("completed_by",s),capNote:a("object_history_cap_note",s),none:"\u2014"},c=d.filter(n=>n.type==="completed"),m=d.map(n=>({...n,completedBy:n.completedBy?this.userName(n.completedBy):null,notes:n.notes?B(n.notes,s):null})),e=J({areaName:E(this.areaKey,this.hass?.areas,a("no_area",s)),from:this._from||S(c[c.length-1]?.timestamp)||null,to:this._to||O(),totals:r,byObject:u,buckets:h,entries:m,capped:this._histories.capped},f,{date:n=>N(n,s),cost:n=>v(n,l,s),duration:n=>w(n,s),bucket:n=>this._bucketLabel(n,"long"),share:n=>`${g(n*100,s,0)} %`,number:n=>g(n,s)},new Date().toISOString());P(e,t)}_renderRangeChips(t){let s=this._lang,l=O(),d=R(l),r=u=>this._from===u.from&&this._to===u.to,h=W(t,Number(l.slice(0,4))).slice(0,V);return p`
|
||||
<div class="filter-chips area-range-chips">
|
||||
<button class="filter-chip ${r(d)?"active":""}" @click=${()=>this._setRange(R())}>
|
||||
${a("area_range_12m",s)}
|
||||
</button>
|
||||
${h.map(u=>p`
|
||||
<button class="filter-chip ${r(A(u))?"active":""}" @click=${()=>this._setRange(A(u))}>${u}</button>
|
||||
`)}
|
||||
<button class="filter-chip ${!this._from&&!this._to?"active":""}" @click=${()=>this._setRange({from:"",to:""})}>
|
||||
${a("area_range_all",s)}
|
||||
</button>
|
||||
</div>
|
||||
`}_renderChart(t){let s=this._lang,l=this.currencySymbol,d=t.reduce((c,m)=>Math.max(c,m.cost),0),r=t.reduce((c,m)=>Math.min(c,m.cost),0);if(d<=0&&r>=0)return p`<p class="area-empty">${a("area_no_costs",s)}</p>`;let h=c=>Math.round(c/(d-r)*1e3)/10,u=Math.max(1,Math.ceil(t.length/12)),f=t.map((c,m)=>({b:c,i:m})).filter(({i:c})=>c%u===0);return p`
|
||||
<div class="area-chart">
|
||||
<div class="area-chart-max">${v(d,l,s)}</div>
|
||||
<div class="area-chart-bars">
|
||||
${r<0?p`<div class="area-chart-zero" style="bottom: ${h(-r)}%"></div>`:y}
|
||||
${t.map(c=>{let m=`${this._bucketLabel(c,"long")}: ${v(c.cost,l,s)} \xB7 ${a("area_kpi_completions",s)}: ${g(c.completions,s)}`,e=c.cost<0?h(c.cost-r):h(-r);return p`<div class="area-bar" title=${m} aria-label=${m}>
|
||||
${c.cost!==0?p`<div class="area-bar-fill ${c.cost<0?"credit":""}" style="height: ${h(Math.abs(c.cost))}%${e?`; bottom: ${e}%`:""}"></div>`:y}
|
||||
</div>`})}
|
||||
</div>
|
||||
${r<0?p`<div class="area-chart-min">${v(r,l,s)}</div>`:y}
|
||||
<div class="area-chart-axis ${u>1?"grouped":""}">
|
||||
${f.map(({b:c,i:m})=>p`<span class="area-bar-label" style="flex-grow: ${Math.min(u,t.length-m)}">${c.month==null?String(c.year):p`${T(c.month,s,"short")}${c.month===0||m===0?p`<br />${c.year}`:y}`}</span>`)}
|
||||
</div>
|
||||
</div>
|
||||
`}render(){if(!this.hass||!this.areaKey)return y;let t=this._lang,s=this.currencySymbol,{objects:l,entries:d,filtered:r,totals:h,buckets:u,byObject:f}=this._figures(),c=l.reduce((e,n)=>e+n.tasks.filter(x=>this.showArchived||!x.archived).length,0),m=this._expanded?r:r.slice(0,Z);return p`
|
||||
<div class="detail-section area-view">
|
||||
<div class="detail-header">
|
||||
<h2 class="area-title">
|
||||
<ha-icon .icon=${M(this.areaKey,this.hass.areas)}></ha-icon>
|
||||
${E(this.areaKey,this.hass.areas,a("no_area",t))}
|
||||
</h2>
|
||||
<div class="action-buttons">
|
||||
<ha-button appearance="plain" class="area-print" .disabled=${this._printing} @click=${()=>this._print()}>
|
||||
<ha-icon icon="mdi:printer-outline"></ha-icon>
|
||||
${this._printing?a("loading",t):a("area_report_print",t)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="meta">
|
||||
${this._countText("area_object_count","area_object_count_one",l.length)} ·
|
||||
${this._countText("templates_task_count","templates_task_count_one",c)}
|
||||
${this._histories.loading?p` · <span class="area-loading">${a("loading",t)}</span>`:y}
|
||||
</p>
|
||||
|
||||
${this._renderRangeChips(d)}
|
||||
<div class="filter-bar area-filters">
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${t}
|
||||
.label=${a("date_from",t)}
|
||||
.value=${this._from}
|
||||
@value-changed=${e=>{this._from=e.detail.value}}
|
||||
></ms-date-field>
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${t}
|
||||
.label=${a("date_to",t)}
|
||||
.value=${this._to}
|
||||
@value-changed=${e=>{this._to=e.detail.value}}
|
||||
></ms-date-field>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">${a("object",t)}</span>
|
||||
<select class="area-object-filter" .value=${this._entryFilter}
|
||||
@change=${e=>{this._entryFilter=e.target.value}}>
|
||||
<option value="" ?selected=${!this._entryFilter}>${a("all_objects",t)}</option>
|
||||
${l.map(e=>p`<option value=${e.entry_id} ?selected=${e.entry_id===this._entryFilter}>${e.object.name}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
class="search-input area-task-filter"
|
||||
aria-label=${a("area_task_filter",t)}
|
||||
placeholder=${a("area_task_filter",t)}
|
||||
.value=${this._taskQuery}
|
||||
@input=${e=>{this._taskQuery=e.target.value}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="kpi-bar area-kpis">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${a("area_kpi_completions",t)}</div>
|
||||
<div class="kpi-value-large area-kpi-completions">${g(h.completions,t)}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${a("total_cost",t)}</div>
|
||||
<div class="kpi-value-large area-kpi-cost">${v(h.totalCost,s,t)}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${a("avg_cost",t)}</div>
|
||||
<div class="kpi-value-large">${h.avgCost!=null?v(h.avgCost,s,t):"\u2014"}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${a("area_kpi_total_time",t)}</div>
|
||||
<div class="kpi-value-large">${h.totalDuration?w(h.totalDuration,t):"\u2014"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>${u.unit==="month"?a("area_cost_per_month",t):a("area_cost_per_year",t)}</h3>
|
||||
${this._renderChart(u.buckets)}
|
||||
|
||||
<h3>${a("area_cost_per_object",t)}</h3>
|
||||
<div class="objects-table-wrap">
|
||||
<table class="objects-table area-object-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${a("object",t)}</th>
|
||||
<th class="num">${a("area_kpi_completions",t)}</th>
|
||||
<th class="num">${a("cost",t)}</th>
|
||||
<th class="num">${a("area_cost_share",t)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${f.map(e=>p`
|
||||
<tr class="objects-table-row" @click=${()=>this._openObject(e.entryId)}>
|
||||
<td><span class="objects-table-name">${e.objectName}</span></td>
|
||||
<td class="num">${g(e.completions,t)}</td>
|
||||
<td class="num">${v(e.cost,s,t)}</td>
|
||||
<td class="num">
|
||||
<span class="area-share"><span class="area-share-fill" style="width: ${Math.round(e.share*100)}%"></span></span>
|
||||
${g(e.share*100,t,0)} %
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
${a("area_history_section",t)}
|
||||
<span class="area-count">${g(r.length,t)}</span>
|
||||
</h3>
|
||||
${r.length===0?p`<p class="area-empty">${a("object_history_empty",t)}</p>`:p`
|
||||
<div class="objects-table-wrap">
|
||||
<table class="objects-table area-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${a("date",t)}</th>
|
||||
<th></th>
|
||||
<th>${a("object",t)}</th>
|
||||
<th>${a("task_name",t)}</th>
|
||||
<th class="num">${a("cost",t)}</th>
|
||||
<th class="num">${a("duration",t)}</th>
|
||||
<th>${a("notes_label",t)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${m.map(e=>{let n=e.notes?B(e.notes,t):"";return p`
|
||||
<tr class="objects-table-row area-history-row" @click=${()=>this._openTask(e.entryId,e.taskId)}>
|
||||
<td title=${I(e.timestamp,t)}>${N(e.timestamp,t)}</td>
|
||||
<td>
|
||||
<span class="area-entry-type" style="color: ${tt[e.type]??"inherit"}">
|
||||
<ha-icon .icon=${D[e.type]||"mdi:circle"}></ha-icon>${a(e.type,t)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="area-object-link" @click=${x=>{x.stopPropagation(),this._openObject(e.entryId)}}>
|
||||
${e.objectName}
|
||||
</button>
|
||||
</td>
|
||||
<td><button class="area-task-link">${e.taskName}${e.phaseName?` \xB7 ${e.phaseName}`:""}</button></td>
|
||||
<td class="num">${e.cost!=null?v(e.cost,s,t):"\u2014"}</td>
|
||||
<td class="num">${e.duration!=null?w(e.duration,t):"\u2014"}</td>
|
||||
<td class="oc-notes" title=${n}>${n||"\u2014"}</td>
|
||||
</tr>
|
||||
`})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${r.length>m.length?p`<ha-button appearance="plain" class="area-more" @click=${()=>{this._expanded=!0}}>
|
||||
${a("show_all",t)} (${g(r.length,t)})
|
||||
</ha-button>`:y}
|
||||
`}
|
||||
${this._histories.capped?p`<p class="area-cap-note">${a("object_history_cap_note",t)}</p>`:y}
|
||||
</div>
|
||||
`}};$([k({attribute:!1})],_.prototype,"hass",2),$([k()],_.prototype,"areaKey",2),$([k({attribute:!1})],_.prototype,"objects",2),$([k({type:Boolean})],_.prototype,"showArchived",2),$([k()],_.prototype,"currencySymbol",2),$([k({attribute:!1})],_.prototype,"userName",2),$([j()],_.prototype,"_from",2),$([j()],_.prototype,"_to",2),$([j()],_.prototype,"_entryFilter",2),$([j()],_.prototype,"_taskQuery",2),$([j()],_.prototype,"_expanded",2),$([j()],_.prototype,"_printing",2);customElements.get("maintenance-area-view")||customElements.define("maintenance-area-view",_);export{_ as MaintenanceAreaView};
|
||||
@@ -0,0 +1,54 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{a as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-H44SM3WA.js";import{q as j,r as E,s as L,t as R,u as C}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-FH35SLMS.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-EOMYEBQ4.js";import{a as _,b as S,c as A}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VZUMUVFP.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-MMIPM7EE.js";import{I as g,L as k,M as v,P as w,a as l,c as a,f as m,h as y,m as p,n as b,t as r,v as $,z as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var q=[{key:"name",label:"area",num:!1},{key:"objects",label:"objects",num:!0},{key:"tasks",label:"tasks",num:!0},{key:"overdue",label:"overdue",num:!0},{key:"due_soon",label:"due_soon",num:!0},{key:"cost_year",label:"area_cost_year",num:!0},{key:"cost_total",label:"total_cost",num:!0},{key:"last",label:"last_performed",num:!1}],i=class extends y{constructor(){super(...arguments);this.objects=[];this.showArchived=!1;this.currencySymbol="";this._query="";this._sort=E(S(_.areaSort));this._histories=new O(this);this._localeReady=!1;this._memo=null}createRenderRoot(){return this}get _lang(){return g(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("objects")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let o=this.objects.filter(s=>this.showArchived||!s.object.archived).flatMap(s=>s.tasks.map(n=>({entryId:s.entry_id,task:n})));this._histories.sync(this.hass,o)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,$(this._lang).then(()=>this.requestUpdate()))}_rows(t){let o=this._lang,s=[this.objects,this.showArchived,this._histories.version,this.hass?.areas,o,t];if(this._memo&&this._memo.deps.every((c,h)=>c===s[h]))return this._memo.rows;let n=j(this.objects,{showArchived:this.showArchived,areas:this.hass?.areas,noAreaLabel:r("no_area",o),year:t,historyOf:(c,h)=>this._histories.historyOf(c,h)});return this._memo={deps:s,rows:n},n}_setSort(t){this._sort=L(this._sort,t),A(_.areaSort,`${this._sort.key}:${this._sort.dir}`)}_open(t){this.dispatchEvent(new CustomEvent("open-area",{detail:{areaKey:t},bubbles:!0,composed:!0}))}_toggleArchived(){this.dispatchEvent(new CustomEvent("archived-toggle",{bubbles:!0,composed:!0}))}_headerLabel(t,o,s){return t==="area_cost_year"?r("area_cost_year",s).replace("{year}",String(o)):r(t,s)}render(){if(!this.hass)return m;let t=this._lang,o=Number(f().slice(0,4)),s=this.currencySymbol,n=this._rows(o),c=R(C(n,this._query),this._sort),h=this.objects.filter(e=>e.object.archived).length,d=e=>k(e,t);return a`
|
||||
<div class="filter-bar area-filter-bar">
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
aria-label=${r("areas_filter",t)}
|
||||
placeholder=${r("areas_filter",t)}
|
||||
.value=${this._query}
|
||||
@input=${e=>{this._query=e.target.value}}
|
||||
/>
|
||||
${this._histories.loading?a`<span class="area-loading">${r("loading",t)}</span>`:m}
|
||||
${h>0?a`<ha-button class="archived-toggle ${this.showArchived?"active":""}" @click=${()=>this._toggleArchived()}>
|
||||
<ha-icon icon="mdi:archive-outline"></ha-icon>
|
||||
${this.showArchived?r("hide_archived",t):`${r("show_archived",t)} (${h})`}
|
||||
</ha-button>`:m}
|
||||
</div>
|
||||
${n.length===0?a`<div class="empty-state">${r("no_objects",t)}</div>`:c.length===0?a`<div class="empty-state">${r("areas_filter_none",t)}</div>`:a`
|
||||
<div class="objects-table-wrap">
|
||||
<table class="objects-table areas-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${q.map(e=>{let u=this._sort.key===e.key;return a`<th class=${e.num?"num":""}
|
||||
aria-sort=${u?this._sort.dir==="asc"?"ascending":"descending":"none"}>
|
||||
<button class="area-sort ${u?"active":""}" title=${r("sort_label",t)} @click=${()=>this._setSort(e.key)}>
|
||||
${this._headerLabel(e.label,o,t)}
|
||||
${u?a`<ha-icon icon=${this._sort.dir==="asc"?"mdi:arrow-up":"mdi:arrow-down"}></ha-icon>`:m}
|
||||
</button>
|
||||
</th>`})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${c.map(e=>a`
|
||||
<tr class="objects-table-row" tabindex="0"
|
||||
@click=${()=>this._open(e.key)}
|
||||
@keydown=${u=>{u.key==="Enter"&&this._open(e.key)}}>
|
||||
<td>
|
||||
<ha-icon class="area-row-icon" .icon=${e.icon}></ha-icon>
|
||||
<span class="objects-table-name">${e.name}</span>
|
||||
</td>
|
||||
<td class="num">${d(e.objects)}</td>
|
||||
<td class="num">${d(e.tasks)}</td>
|
||||
<td class="num">${e.overdue?a`<span class="area-count overdue">${d(e.overdue)}</span>`:d(0)}</td>
|
||||
<td class="num">${e.dueSoon?a`<span class="area-count due-soon">${d(e.dueSoon)}</span>`:d(0)}</td>
|
||||
<td class="num">${v(e.costYear,s,t)}</td>
|
||||
<td class="num">${v(e.costTotal,s,t)}</td>
|
||||
<td>${e.lastCompletion?w(e.lastCompletion,t):"\u2014"}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
`}};l([p({attribute:!1})],i.prototype,"hass",2),l([p({attribute:!1})],i.prototype,"objects",2),l([p({type:Boolean})],i.prototype,"showArchived",2),l([p()],i.prototype,"currencySymbol",2),l([b()],i.prototype,"_query",2),l([b()],i.prototype,"_sort",2);customElements.get("maintenance-areas-view")||customElements.define("maintenance-areas-view",i);export{i as MaintenanceAreasView};
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import"/maintenance_supporter_panelfiles/panel-chunks/chunk-5TWB2KD3.js";import{I as u,a as n,b as _,c as o,f as p,h as c,m as g,n as a,t as s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var d=["round_robin","least_completed","random"],v=["low","normal","high"],m=365,i=class extends c{constructor(){super(...arguments);this._open=!1;this._mode="assign";this._count=0;this._users=[];this._present=[];this._assignKind="one";this._person="";this._pool=new Set;this._rotation=d[0];this._addText="";this._remove=new Set;this._setWarning=!1;this._warning=7;this._setPriority=!1;this._priority="normal";this._setMute=!1;this._mute=!1;this._resolve=null}get _lang(){return u(this.hass)}open(t,e,r){return this._resolve?.(null),this._mode=t,this._count=e.length,this._users=[...r].sort((l,h)=>l.name.localeCompare(h.name)),this._present=[...new Set(e.flatMap(l=>l.labels||[]))].sort((l,h)=>l.localeCompare(h)),this._assignKind="one",this._person="",this._pool=new Set,this._rotation=d[0],this._addText="",this._remove=new Set,this._setWarning=!1,this._warning=7,this._setPriority=!1,this._priority="normal",this._setMute=!1,this._mute=!1,this._open=!0,new Promise(l=>{this._resolve=l})}_finish(t){this._open=!1;let e=this._resolve;this._resolve=null,e?.(t)}changes(){if(this._mode==="assign"){if(this._assignKind==="one")return{responsible_user_id:this._person||null,assignee_pool:null,rotation_strategy:null};let e=this._users.map(r=>r.id).filter(r=>this._pool.has(r));return e.length<2?null:{assignee_pool:e,rotation_strategy:this._rotation,responsible_user_id:null}}if(this._mode==="labels"){let e=[...new Set(this._addText.split(",").map(l=>l.trim()).filter(Boolean))],r=[...this._remove];return e.length||r.length?{labels_add:e,labels_remove:r}:null}let t={};return this._setWarning&&(t.warning_days=this._warning),this._setPriority&&(t.priority=this._priority),this._setMute&&(t.notify_enabled=!this._mute),Object.keys(t).length?t:null}_toggle(t,e){let r=new Set(t);return r.has(e)?r.delete(e):r.add(e),r}_renderAssign(t){return o`
|
||||
<div class="hint">${s("bulk_assign_hint",t)}</div>
|
||||
<label class="choice">
|
||||
<input type="radio" name="kind" .checked=${this._assignKind==="one"} @change=${()=>this._assignKind="one"} />
|
||||
${s("bulk_assign_one",t)}
|
||||
</label>
|
||||
${this._assignKind==="one"?o`<select class="person" .value=${this._person} @change=${e=>this._person=e.target.value}>
|
||||
<option value="" ?selected=${!this._person}>${s("unassigned",t)}</option>
|
||||
${this._users.map(e=>o`<option value=${e.id} ?selected=${this._person===e.id}>${e.name}</option>`)}
|
||||
</select>`:p}
|
||||
<label class="choice">
|
||||
<input type="radio" name="kind" .checked=${this._assignKind==="rotate"} @change=${()=>this._assignKind="rotate"} />
|
||||
${s("bulk_assign_rotate",t)}
|
||||
</label>
|
||||
${this._assignKind==="rotate"?o`<div class="pool">
|
||||
${this._users.map(e=>o`<label class="row">
|
||||
<input type="checkbox" .checked=${this._pool.has(e.id)} @change=${()=>this._pool=this._toggle(this._pool,e.id)} />
|
||||
${e.name}
|
||||
</label>`)}
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>${s("rotation_strategy",t)}</span>
|
||||
<select class="rotation" .value=${this._rotation} @change=${e=>this._rotation=e.target.value}>
|
||||
${d.map(e=>o`<option value=${e} ?selected=${this._rotation===e}>${s(`rotation_${e}`,t)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
${this._pool.size<2?o`<div class="hint">${s("bulk_rotation_min_two",t)}</div>`:p}`:p}
|
||||
`}_renderLabels(t){return o`
|
||||
<ms-textfield
|
||||
class="labels-add"
|
||||
label=${s("bulk_labels_add",t)}
|
||||
.helper=${s("labels_help",t)}
|
||||
.value=${this._addText}
|
||||
@input=${e=>this._addText=e.target.value}
|
||||
></ms-textfield>
|
||||
${this._present.length?o`<div class="section-title">${s("bulk_labels_remove",t)}</div>
|
||||
<div class="pool">
|
||||
${this._present.map(e=>o`<label class="row">
|
||||
<input type="checkbox" .checked=${this._remove.has(e)} @change=${()=>this._remove=this._toggle(this._remove,e)} />
|
||||
${e}
|
||||
</label>`)}
|
||||
</div>`:p}
|
||||
`}_renderEdit(t){return o`
|
||||
<div class="hint">${s("bulk_edit_hint",t)}</div>
|
||||
<div class="row edit-row">
|
||||
<input type="checkbox" class="set-warning" .checked=${this._setWarning} @change=${e=>this._setWarning=e.target.checked} />
|
||||
<span class="grow">${s("warning_days",t)}</span>
|
||||
<input type="number" class="warning" min="0" max=${m} .value=${String(this._warning)} ?disabled=${!this._setWarning}
|
||||
@change=${e=>{let r=parseInt(e.target.value,10);this._warning=Number.isInteger(r)?Math.min(Math.max(r,0),m):this._warning}} />
|
||||
</div>
|
||||
<div class="row edit-row">
|
||||
<input type="checkbox" class="set-priority" .checked=${this._setPriority} @change=${e=>this._setPriority=e.target.checked} />
|
||||
<span class="grow">${s("priority",t)}</span>
|
||||
<select class="priority" .value=${this._priority} ?disabled=${!this._setPriority} @change=${e=>this._priority=e.target.value}>
|
||||
${v.map(e=>o`<option value=${e} ?selected=${this._priority===e}>${s(`priority_${e}`,t)}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row edit-row">
|
||||
<input type="checkbox" class="set-mute" .checked=${this._setMute} @change=${e=>this._setMute=e.target.checked} />
|
||||
<span class="grow">${s("bulk_no_notifications",t)}</span>
|
||||
<input type="checkbox" class="mute" .checked=${this._mute} ?disabled=${!this._setMute} @change=${e=>this._mute=e.target.checked} />
|
||||
</div>
|
||||
`}render(){if(!this._open)return o``;let t=this._lang,e=this._mode==="assign"?s("bulk_assign",t):this._mode==="labels"?s("labels",t):s("edit",t),r=this.changes()!==null;return o`
|
||||
<ha-dialog open @closed=${()=>this._finish(null)}>
|
||||
<div class="dialog-title">${e}</div>
|
||||
<div class="content">
|
||||
<div class="count">${s("bulk_n_selected",t).replace("{n}",String(this._count))}</div>
|
||||
${this._mode==="assign"?this._renderAssign(t):this._mode==="labels"?this._renderLabels(t):this._renderEdit(t)}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${()=>this._finish(null)}>${s("cancel",t)}</ha-button>
|
||||
<ha-button class="apply" .disabled=${!r} @click=${()=>this._finish(this.changes())}>${s("save",t)}</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`}};i.styles=_`
|
||||
.dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; }
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: min(360px, calc(100vw - 24px));
|
||||
max-width: 520px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.content { min-width: 0; max-width: none; max-height: none; }
|
||||
}
|
||||
.count, .hint { color: var(--secondary-text-color); font-size: 13px; }
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.choice, .row { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; }
|
||||
.pool { display: flex; flex-direction: column; gap: 4px; padding-left: 26px; }
|
||||
.field { display: flex; align-items: center; gap: 8px; justify-content: space-between; font-size: 14px; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
select, input[type="number"] {
|
||||
font: inherit;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 4px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
max-width: 100%;
|
||||
}
|
||||
input[type="number"] { width: 72px; }
|
||||
select.person { margin-left: 26px; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; }
|
||||
`,n([g({attribute:!1})],i.prototype,"hass",2),n([a()],i.prototype,"_open",2),n([a()],i.prototype,"_mode",2),n([a()],i.prototype,"_count",2),n([a()],i.prototype,"_users",2),n([a()],i.prototype,"_present",2),n([a()],i.prototype,"_assignKind",2),n([a()],i.prototype,"_person",2),n([a()],i.prototype,"_pool",2),n([a()],i.prototype,"_rotation",2),n([a()],i.prototype,"_addText",2),n([a()],i.prototype,"_remove",2),n([a()],i.prototype,"_setWarning",2),n([a()],i.prototype,"_warning",2),n([a()],i.prototype,"_setPriority",2),n([a()],i.prototype,"_priority",2),n([a()],i.prototype,"_setMute",2),n([a()],i.prototype,"_mute",2);customElements.get("maintenance-bulk-edit-dialog")||customElements.define("maintenance-bulk-edit-dialog",i);export{i as MaintenanceBulkEditDialog};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
function r(t){return String(t??"").replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n])}export{r as a};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
var n={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]};function o(e){let r=n[e];if(!r)throw new Error(`not an int-ranged setting: ${e}`);return r}var t=[0,14],_=[0,1440],u=[1,1e4],a=[0,365],d=15,s=[.1,.9],b=[0,365],A=[.01,1e4],E=[.01,1e5],i=[.1,5];export{n as a,o as b,t as c,_ as d,u as e,a as f,d as g,s as h,b as i,A as j,E as k,i as l};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,256 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{a as p}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3LDXEWBF.js";import{I as v,a as r,b as _,c as l,f as o,h,m as c,n,t as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var s=class extends h{constructor(){super(...arguments);this.objects=[];this.placesEnabled=!1;this._open=!1;this._loading=!1;this._error="";this._name="";this._manufacturer="";this._model="";this._serialNumber="";this._areaId="";this._installationDate="";this._warrantyExpiry="";this._documentationUrl="";this._notes="";this._haDeviceId="";this._parentEntryId="";this._place="";this._remindOnSite=!1;this._entryId=null;this._replacing=!1;this._oldDeviceId="";this._replaceDevice="keep"}get _lang(){return v(this.hass)}openCreate(){this._entryId=null,this._name="",this._manufacturer="",this._model="",this._serialNumber="",this._areaId="",this._installationDate="",this._warrantyExpiry="",this._documentationUrl="",this._notes="",this._haDeviceId="",this._parentEntryId="",this._place="",this._remindOnSite=!1,this._replacing=!1,this._error="",this._open=!0}openReplace(e,a){this._entryId=e,this._replacing=!0,this._name=a.name||"",this._oldDeviceId=a.ha_device_id||"",this._haDeviceId="",this._replaceDevice="keep",this._error="",this._open=!0}openEdit(e,a){this._entryId=e,this._name=a.name||"",this._manufacturer=a.manufacturer||"",this._model=a.model||"",this._serialNumber=a.serial_number||"",this._areaId=a.area_id||"",this._installationDate=a.installation_date||"",this._warrantyExpiry=a.warranty_expiry||"",this._documentationUrl=a.documentation_url||"",this._notes=a.notes||"",this._haDeviceId=a.ha_device_id||"",this._parentEntryId=a.parent_entry_id||"",this._place=a.place||"",this._remindOnSite=!!a.remind_on_site,this._replacing=!1,this._error="",this._open=!0}async _replace(){if(this._loading||!this._entryId)return;let e={type:"maintenance_supporter/object/replace",entry_id:this._entryId,name:this._name.trim()||null};this._oldDeviceId?(this._replaceDevice==="other"&&(e.ha_device_id=this._haDeviceId),this._replaceDevice==="none"&&(e.ha_device_id=null)):this._haDeviceId&&(e.ha_device_id=this._haDeviceId);let a=await p(this,e,{busy:t=>{this._loading=t},fallbackKey:"save_error",onError:t=>{this._error=t}});a!==void 0&&(this._open=!1,this.dispatchEvent(new CustomEvent("object-replaced",{detail:{entry_id:a?.entry_id,device_swap:a?.device_swap}})))}_deviceName(e){let t=this.hass?.devices?.[e];return t?.name_by_user||t?.name||e}_deviceSelector(e){return l`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{device:this._haDeviceId||void 0}}
|
||||
.schema=${[{name:"device",selector:{device:{}}}]}
|
||||
.computeLabel=${()=>e}
|
||||
@value-changed=${a=>this._haDeviceId=a.detail.value?.device||""}
|
||||
></ha-form>`}_renderReplace(){let e=this._lang,a=(d,u)=>l`<label class="radio-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="replace-device"
|
||||
.checked=${this._replaceDevice===d}
|
||||
@change=${()=>{this._replaceDevice=d}}
|
||||
/>
|
||||
<span>${u}</span>
|
||||
</label>`,t=this._replaceDevice==="other"&&!this._haDeviceId;return l`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${i("replace_object",e)}</div>
|
||||
<div class="content">
|
||||
${this._error?l`<div class="error">${this._error}</div>`:o}
|
||||
<div class="hint">${i("replace_object_prompt",e)}</div>
|
||||
<ms-textfield
|
||||
label="${i("replace_name_label",e)}"
|
||||
.value=${this._name}
|
||||
@input=${d=>this._name=d.target.value}
|
||||
></ms-textfield>
|
||||
${this._oldDeviceId?l`<div class="radio-group" role="radiogroup" aria-label=${i("replace_device_heading",e)}>
|
||||
<div class="textarea-label">${i("replace_device_heading",e)}</div>
|
||||
${a("keep",i("replace_device_keep",e).replace("{device}",this._deviceName(this._oldDeviceId)))}
|
||||
${this._replaceDevice==="keep"?l`<div class="hint">${i("replace_device_keep_hint",e)}</div>`:o}
|
||||
${a("other",i("replace_device_other",e))}
|
||||
${this._replaceDevice==="other"?l`${this._deviceSelector(i("replace_device_pick",e))}
|
||||
<div class="hint">${i("replace_device_other_hint",e)}</div>`:o}
|
||||
${a("none",i("replace_device_none",e))}
|
||||
</div>`:this._deviceSelector(i("link_device_optional",e))}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${i("cancel",e)}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._replace} .disabled=${this._loading||t}>
|
||||
${this._loading?i("saving",e):i("replace_object",e)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`}_renderPlace(e){let a=!!this._place&&!this.hass?.states?.[this._place];return l`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{place:this._place||void 0}}
|
||||
.schema=${[{name:"place",selector:{entity:{domain:"zone"}}}]}
|
||||
.computeLabel=${()=>i("place_optional",e)}
|
||||
@value-changed=${t=>{this._place=t.detail.value?.place||"",this._place||(this._remindOnSite=!1)}}
|
||||
></ha-form>
|
||||
${a?l`<div class="place-missing">${i("place_missing",e)}</div>`:o}
|
||||
<label class="place-on-site ${this._place?"":"disabled"}">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this._remindOnSite}
|
||||
?disabled=${!this._place}
|
||||
@change=${t=>this._remindOnSite=t.target.checked}
|
||||
/>
|
||||
<span>${i("remind_on_site",e)}</span>
|
||||
</label>
|
||||
${this._place?l`<div class="place-hint">${i("remind_on_site_hint",e)}</div>`:o}
|
||||
`}async _save(){if(this._loading||!this._name.trim())return;this._error="";let e={name:this._name,manufacturer:this._manufacturer||null,model:this._model||null,serial_number:this._serialNumber||null,area_id:this._areaId||null,installation_date:this._installationDate||null,warranty_expiry:this._warrantyExpiry||null,documentation_url:this._documentationUrl.trim()||null,notes:this._notes.trim()||null,ha_device_id:this._haDeviceId||null,parent_entry_id:this._parentEntryId||null,...this.placesEnabled?{place:this._place||null,remind_on_site:!!this._place&&this._remindOnSite}:{}},a=await p(this,this._entryId?{type:"maintenance_supporter/object/update",entry_id:this._entryId,...e}:{type:"maintenance_supporter/object/create",...e},{busy:t=>{this._loading=t},fallbackKey:"save_error",onError:t=>{this._error=t}});a!==void 0&&(this._open=!1,this.dispatchEvent(new CustomEvent("object-saved",{detail:{device_swap:a?.device_swap}})))}_parentChoices(){return(this.objects||[]).filter(e=>e.entry_id!==this._entryId)}_close(){this._open=!1}render(){if(!this._open)return l``;if(this._replacing)return this._renderReplace();let e=this._lang,a=this._entryId?i("edit_object",e):i("new_object",e);return l`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${a}</div>
|
||||
<div class="content">
|
||||
${this._error?l`<div class="error">${this._error}</div>`:o}
|
||||
<ms-textfield
|
||||
label="${i("name",e)}"
|
||||
required
|
||||
.value=${this._name}
|
||||
@input=${t=>this._name=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("manufacturer_optional",e)}"
|
||||
.value=${this._manufacturer}
|
||||
@input=${t=>this._manufacturer=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("model_optional",e)}"
|
||||
.value=${this._model}
|
||||
@input=${t=>this._model=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("serial_number_optional",e)}"
|
||||
.value=${this._serialNumber}
|
||||
@input=${t=>this._serialNumber=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("documentation_url_optional",e)}"
|
||||
type="url"
|
||||
.value=${this._documentationUrl}
|
||||
@input=${t=>this._documentationUrl=t.target.value}
|
||||
></ms-textfield>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
label="${i("area_id_optional",e)}"
|
||||
.value=${this._areaId}
|
||||
@value-changed=${t=>this._areaId=t.detail.value||""}
|
||||
></ha-area-picker>
|
||||
${this.placesEnabled?this._renderPlace(e):o}
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${e}
|
||||
label="${i("installation_date_optional",e)}"
|
||||
.value=${this._installationDate}
|
||||
@value-changed=${t=>this._installationDate=t.detail.value}
|
||||
></ms-date-field>
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${e}
|
||||
label="${i("warranty_expiry_optional",e)}"
|
||||
.value=${this._warrantyExpiry}
|
||||
@value-changed=${t=>this._warrantyExpiry=t.detail.value}
|
||||
></ms-date-field>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{device:this._haDeviceId||void 0}}
|
||||
.schema=${[{name:"device",selector:{device:{}}}]}
|
||||
.computeLabel=${()=>i("link_device_optional",e)}
|
||||
@value-changed=${t=>this._haDeviceId=t.detail.value?.device||""}
|
||||
></ha-form>
|
||||
${this._parentChoices().length?l`<label class="textarea-field">
|
||||
<span class="textarea-label">${i("parent_object_optional",e)}</span>
|
||||
<select
|
||||
class="parent-select"
|
||||
.value=${this._parentEntryId}
|
||||
@change=${t=>this._parentEntryId=t.target.value}
|
||||
>
|
||||
<option value="" ?selected=${!this._parentEntryId}>
|
||||
${i("parent_none",e)}
|
||||
</option>
|
||||
${this._parentChoices().map(t=>l`<option
|
||||
value=${t.entry_id}
|
||||
?selected=${this._parentEntryId===t.entry_id}
|
||||
>${t.object.name}</option>`)}
|
||||
</select>
|
||||
</label>`:o}
|
||||
<label class="textarea-field">
|
||||
<span class="textarea-label">${i("object_notes_optional",e)}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
.value=${this._notes}
|
||||
@input=${t=>this._notes=t.target.value}
|
||||
></textarea>
|
||||
<span class="md-hint">${i("notes_markdown_hint",e)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${i("cancel",this._lang)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${this._loading||!this._name.trim()}
|
||||
>
|
||||
${this._loading?i("saving",this._lang):i("save",this._lang)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`}};s.styles=_`
|
||||
.place-on-site {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.place-on-site.disabled {
|
||||
color: var(--disabled-text-color, var(--secondary-text-color));
|
||||
}
|
||||
.place-hint,
|
||||
.place-missing {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
margin-top: -4px;
|
||||
}
|
||||
.place-missing {
|
||||
color: var(--warning-color, #c77700);
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 300px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
ms-textfield {
|
||||
display: block;
|
||||
}
|
||||
.textarea-field {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.textarea-label {
|
||||
font-size: 12px; color: var(--secondary-text-color, #888); font-weight: 500;
|
||||
}
|
||||
.textarea-field textarea {
|
||||
padding: 8px 10px; font-size: 14px; font-family: inherit;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
resize: vertical;
|
||||
}
|
||||
.textarea-field textarea:focus {
|
||||
outline: none; border-color: var(--primary-color);
|
||||
}
|
||||
.md-hint {
|
||||
font-size: 11px; color: var(--secondary-text-color); font-style: italic;
|
||||
}
|
||||
.parent-select {
|
||||
padding: 8px 10px; font-size: 14px; font-family: inherit;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio-row input {
|
||||
margin: 0;
|
||||
}
|
||||
`,r([c({attribute:!1})],s.prototype,"hass",2),r([c({attribute:!1})],s.prototype,"objects",2),r([c({type:Boolean})],s.prototype,"placesEnabled",2),r([n()],s.prototype,"_open",2),r([n()],s.prototype,"_loading",2),r([n()],s.prototype,"_error",2),r([n()],s.prototype,"_name",2),r([n()],s.prototype,"_manufacturer",2),r([n()],s.prototype,"_model",2),r([n()],s.prototype,"_serialNumber",2),r([n()],s.prototype,"_areaId",2),r([n()],s.prototype,"_installationDate",2),r([n()],s.prototype,"_warrantyExpiry",2),r([n()],s.prototype,"_documentationUrl",2),r([n()],s.prototype,"_notes",2),r([n()],s.prototype,"_haDeviceId",2),r([n()],s.prototype,"_parentEntryId",2),r([n()],s.prototype,"_place",2),r([n()],s.prototype,"_remindOnSite",2),r([n()],s.prototype,"_entryId",2),r([n()],s.prototype,"_replacing",2),r([n()],s.prototype,"_oldDeviceId",2),r([n()],s.prototype,"_replaceDevice",2);customElements.get("maintenance-object-dialog")||customElements.define("maintenance-object-dialog",s);export{s as a};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{a as r,b as l,c as i,f as o,h as n,m as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var e=class extends n{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(s){let a=s.target.value;this.value=a,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:a}}))}render(){return i`
|
||||
<label class="field">
|
||||
${this.label?i`<span class="label">${this.label}${this.required?i`<span class="req">*</span>`:o}</span>`:o}
|
||||
${this.multiline?i`
|
||||
<textarea
|
||||
.value=${this.value??""}
|
||||
rows=${this.rows}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
placeholder=${this.placeholder}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
></textarea>`:i`
|
||||
<input
|
||||
.value=${this.value??""}
|
||||
.type=${this.type}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
placeholder=${this.placeholder}
|
||||
step=${this.step??o}
|
||||
min=${this.min??o}
|
||||
max=${this.max??o}
|
||||
pattern=${this.pattern??o}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
/>`}
|
||||
${this.helper?i`<span class="helper">${this.helper}</span>`:o}
|
||||
</label>
|
||||
`}};e.styles=l`
|
||||
:host { display: block; }
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color, #888);
|
||||
font-weight: 500;
|
||||
}
|
||||
.req { color: var(--error-color, #f44336); margin-left: 2px; }
|
||||
input {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
textarea {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea:focus { border-color: var(--primary-color); }
|
||||
textarea:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.helper {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
`,r([t()],e.prototype,"label",2),r([t()],e.prototype,"value",2),r([t()],e.prototype,"placeholder",2),r([t()],e.prototype,"type",2),r([t({type:Boolean})],e.prototype,"required",2),r([t({type:Boolean})],e.prototype,"disabled",2),r([t()],e.prototype,"step",2),r([t()],e.prototype,"min",2),r([t()],e.prototype,"max",2),r([t()],e.prototype,"pattern",2),r([t()],e.prototype,"helper",2),r([t({type:Boolean})],e.prototype,"multiline",2),r([t({type:Number})],e.prototype,"rows",2);customElements.get("ms-textfield")||customElements.define("ms-textfield",e);
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{c as r,f as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var a=["#c62828","#ad1457","#6a1b9a","#4527a0","#283593","#1565c0","#00838f","#2e7d32","#558b2f","#ef6c00","#6d4c41","#546e7a"];function u(e){let s=(e||"").split(/\s+/).filter(Boolean);return s.length===0?"?":s.length===1?s[0][0].toUpperCase():(s[0][0]+s[s.length-1][0]).toUpperCase()}function c(e){let s=0;for(let n of e)s=s*31+n.charCodeAt(0)>>>0;return a[s%a.length]}function o(e){return e?{id:e.id,name:e.name,initials:e.initials||u(e.name),color:e.color||c(e.id)}:null}function h(e){return e?r`<span class="person-avatar" style="--person-color: ${e.color}" title=${e.name}>${e.initials}</span>`:i}function m(e,s=""){return e?r`<span class="person-chip ${s}" title=${e.name}>${h(e)}<span class="person-name">${e.name}</span></span>`:i}var l=class{constructor(s){this.usersCache=null;this.cacheTimestamp=0;this.CACHE_TTL_MS=6e4;this.hass=s}updateHass(s){this.hass=s}async getUsers(s=!1){let n=Date.now();if(!s&&this.usersCache&&n-this.cacheTimestamp<this.CACHE_TTL_MS)return this.usersCache;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/users/list"});return this.usersCache=t.users,this.cacheTimestamp=n,this.usersCache}catch(t){return console.error("Failed to fetch users:",t),this.usersCache||[]}}async assignUser(s,n,t){await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/assign_user",entry_id:s,task_id:n,user_id:t})}async getTasksByUser(s){return(await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/tasks/by_user",user_id:s})).tasks}getUserName(s){return!s||!this.usersCache?null:this.usersCache.find(t=>t.id===s)?.name||null}getPerson(s){return o(this.getUser(s))}getUser(s){return!s||!this.usersCache?null:this.usersCache.find(n=>n.id===s)||null}getCurrentUserId(){return this.hass.user?.id||null}isCurrentUser(s){return s?s===this.getCurrentUserId():!1}clearCache(){this.usersCache=null,this.cacheTimestamp=0}};export{a,o as b,h as c,m as d,l as e};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{b as s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-IRRUA3DS.js";async function a(o,n,t=300){return(await o.connection.sendMessagePromise({type:"auth/sign_path",path:n,expires:t})).path}async function r(o,n,t=300){return a(o,`/api/maintenance_supporter/document/${n}`,t)}async function p(o,n,t=""){let e=window.open("about:blank","_blank");try{let i=await r(o,n);e&&(e.location.href=new URL(i+t,window.location.origin).href)}catch(i){throw e&&e.close(),i}}async function l(o,n,t){s(await r(o,n,30),t)}function m(o,n=null){let t=URL.createObjectURL(new Blob([o],{type:"text/html"}));n&&!n.closed?n.location.href=t:window.open(t,"_blank"),setTimeout(()=>URL.revokeObjectURL(t),6e4)}function u(){return window.open("about:blank","_blank")}export{a,r as b,p as c,l as d,m as e,u as f};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
var E={\u00DF:"s","\u1E9E":"s",\u00E6:"a",\u0153:"o",\u00F8:"o",\u0111:"d",\u00F0:"d",\u00FE:"t",\u0142:"l",\u0131:"i"},d=10,R=7,g=4,h=2,S=5,x=3,_=/[a-z0-9]+/g;function u(r){let e="";for(let t of r.toLowerCase()){if(t.charCodeAt(0)<128&&t.length===1){e+=t;continue}let n=E[t];if(n){e+=n;continue}let o=t.normalize("NFKD")[0]||t;e+=o.charCodeAt(0)<128?o:t}return e}function m(r){return u(r).match(_)||[]}function C(r){return u(r).replace(/[^a-z0-9]+/g,"")}function b(r){return r.replace(/ue/g,"u").replace(/oe/g,"o").replace(/ae/g,"a").replace(/ss/g,"s")}function F(r){let e=[];for(let t of m(r)){let n=[t],o=b(t);o&&o!==t&&n.push(o),e.push(n)}return e}function p(r,e){if(r===e)return!0;let t=r.length,n=e.length;if(Math.abs(t-n)>1)return!1;if(t===n){let l=[];for(let i=0;i<t;i++)r[i]!==e[i]&&l.push(i);if(l.length===1)return!0;if(l.length===2&&l[1]===l[0]+1){let i=l[0];return r[i]===e[i+1]&&r[i+1]===e[i]}return!1}let[o,s]=t>n?[r,e]:[e,r],c=0,f=0,a=!1;for(;c<o.length&&f<s.length;)if(o[c]===s[f])c++,f++;else{if(a)return!1;a=!0,c++}return!0}function N(r,e){let t=0;for(let n of r)if(n){if(e===n)return d;if(e.startsWith(n)){t=Math.max(t,R);continue}if(n.length>=x&&e.includes(n)){t=Math.max(t,g);continue}n.length>=S&&t<h&&(p(n,e)||e.length>n.length&&p(n,e.slice(0,n.length)))&&(t=h)}return t}function I(r,e){let t=0;for(let n of m(e))if(t=Math.max(t,N(r,n)),t===d)return t;if(t<g){let n=C(e);for(let o of r)if(o.length>=x&&n.includes(o))return g}return t}function O(r,e){if(!r)return!1;let t=F(e);if(!t.length){let n=u(e.trim());return n.length>0&&u(r).includes(n)}return M(t,[{text:r,weight:1}])>0}function M(r,e){if(!r.length)return 0;let t=e.filter(o=>o.text),n=0;for(let o of r){let s=0;for(let c of t){let f=I(o,c.text)*c.weight;f>s&&(s=f)}if(s===0)return 0;n+=s}return n}export{F as a,O as b,M as c};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{B as k,L as R,N as x,a as m,b as h,ba as w,c as g,f as v,h as b,m as y,n as _,t as l}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";function E(i){return`${i.entry_id??""}\0${i.part_id}`}function S(i,t,e,o){let n=!!i.entry_id&&i.entry_id!==t,r=n?i.entry_id:t,s=e.find(p=>p.entry_id===r),a=(s?.parts||[]).find(p=>p.id===i.part_id)||null,c=n&&s?.object?.name||"",u=a?.name||l("shared_part_unknown",o);return{part:a,foreign:n,ownerName:c,label:c?`${u} (${c})`:u}}function N(i,t,e,o){let{part:n,label:r}=S(i,t,e,o),s=n&&n.stock!==null&&n.stock!==void 0?` (${x(n.stock,n.unit,o)})`:"",a=n?.storage_location?` \u2014 ${n.storage_location}`:"";return`${R(i.quantity,o)}\xD7 ${r}${s}${a}`}function V(i,t,e,o){let r=(e.find(a=>a.entry_id===t)?.parts||[]).map(a=>({...a})),s=new Set(r.map(a=>E({part_id:a.id})));for(let a of i?.consumes_parts||[]){if(!a.entry_id||a.entry_id===t)continue;let c=E(a);if(s.has(c))continue;s.add(c);let{part:u,ownerName:p}=S(a,t,e,o);r.push({id:a.part_id,name:u?.name||l("shared_part_unknown",o),unit:u?.unit,stock:u?.stock??null,storage_location:u?.storage_location,entry_id:a.entry_id,owner_name:p})}return r}var L=20;function T(){let i=new Uint8Array(4);return(globalThis.crypto??{getRandomValues:t=>t.map(()=>Math.floor(Math.random()*256))}).getRandomValues(i),Array.from(i,t=>t.toString(16).padStart(2,"0")).join("")}function f(i){let t=i?.reading_values;if(!Array.isArray(t))return[];let e=[],o=new Set;for(let n of t){if(!n||typeof n!="object")continue;let r=n;typeof r.id!="string"||!r.id||o.has(r.id)||typeof r.value!="number"||!Number.isFinite(r.value)||(o.add(r.id),e.push({id:r.id,name:typeof r.name=="string"?r.name:r.id,unit:typeof r.unit=="string"?r.unit:null,value:r.value}))}return e}function P(i){return i.filter(t=>t.type==="completed"&&(t.reading_value!=null||f(t).length>0)).sort((t,e)=>t.timestamp.localeCompare(e.timestamp))}function M(i){return P(i??[]).map(t=>({timestamp:t.timestamp,values:f(t)})).filter(t=>t.values.length>0)}function A(i,t,e){let o;for(let n of i){if(e!==void 0){let s=k(n.timestamp);if(!isNaN(s)&&s>=e)break}let r=n.values.find(s=>s.id===t);r&&(o=r)}return o}function C(i,t,e){let o=f(t).find(r=>r.id===e);if(!o)return null;let n=null;for(let r of P(i)){if(r.timestamp>=t.timestamp)break;let s=f(r).find(a=>a.id===e);s&&(n=s.value)}return n==null?null:o.value-n}function D(i){let t=new Set,e=new Set;for(let o of i){let n=(o.name||"").trim().toLowerCase();n&&(t.has(n)?e.add(o.id):t.add(n))}return e}function F(i){let t=[],e=new Set,o=new Set;for(let n of i){let r=(n.name||"").trim();if(!(!r||e.has(n.id)||o.has(r.toLowerCase()))&&(e.add(n.id),o.add(r.toLowerCase()),t.push({id:n.id,name:r,unit:(n.unit||"").trim()||null}),t.length>=L))break}return t}function j(i){let t=(i??"").trim();if(t==="")return null;let e=Number(t.replace(",","."));return!Number.isFinite(e)||e<0?null:Math.round(e)}var B=["notes","cost","duration","photo","user"],I={notes:"notes_label",cost:"cost",duration:"duration",photo:"photo_label",user:"user_label"};var d=class extends b{constructor(){super(...arguments);this.value="";this.lang="en";this._credit=!1;this._amount=""}willUpdate(e){if(!e.has("value"))return;let o=(this.value??"").trim();if(o===this._signed())return;let n=parseFloat(o);if(o===""||Number.isNaN(n)){this._amount="",this._credit=!1;return}this._credit=n<0,this._amount=String(Math.abs(n))}_signed(){let e=this._amount.trim();return e===""?"":this._credit?`-${e}`:e}_emit(){this.value=this._signed(),this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:this.value},bubbles:!0,composed:!0}))}_setCredit(e){this._credit!==e&&(this._credit=e,this._emit())}_onInput(e){let o=e.target.value;o.startsWith("-")&&(this._credit=!0),this._amount=o.replace(/^-+/,""),this._emit()}render(){let e=this.lang;return g`
|
||||
<div class="wrap">
|
||||
<div class="sign" role="group">
|
||||
<button type="button" class="kind cost ${this._credit?"":"on"}" aria-pressed=${String(!this._credit)}
|
||||
@click=${()=>this._setCredit(!1)}>${l("cost",e)}</button>
|
||||
<button type="button" class="kind credit ${this._credit?"on":""}" aria-pressed=${String(this._credit)}
|
||||
@click=${()=>this._setCredit(!0)}>${l("cost_kind_credit",e)}</button>
|
||||
</div>
|
||||
<input type="number" step="0.01" min="0" inputmode="decimal" class="field-input amount"
|
||||
.value=${this._amount} @input=${this._onInput} />
|
||||
</div>
|
||||
${this._credit?g`<div class="hint">${l("cost_credit_hint",e)}</div>`:v}
|
||||
`}};d.styles=[w,h`
|
||||
:host { display: block; }
|
||||
.wrap { display: flex; gap: 8px; align-items: stretch; }
|
||||
.sign {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kind {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kind.on { background: var(--primary-color); color: var(--text-primary-color, #fff); }
|
||||
.kind.credit.on { background: var(--success-color, #43a047); }
|
||||
.amount { flex: 1; min-width: 0; }
|
||||
.hint { margin-top: 4px; font-size: 12px; color: var(--secondary-text-color); }
|
||||
`],m([y()],d.prototype,"value",2),m([y()],d.prototype,"lang",2),m([_()],d.prototype,"_credit",2),m([_()],d.prototype,"_amount",2);customElements.get("ms-cost-input")||customElements.define("ms-cost-input",d);export{E as a,N as b,V as c,L as d,T as e,f,M as g,A as h,C as i,D as j,F as k,j as l,B as m,I as n};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{t as a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var _=[{key:"name",labelKey:"name",required:!0},{key:"ref_no",labelKey:"ref_number"},{key:"manufacturer",labelKey:"manufacturer"},{key:"model",labelKey:"model"},{key:"serial_number",labelKey:"serial_number_label"},{key:"installation_date",labelKey:"installed"},{key:"warranty_expiry",labelKey:"warranty"},{key:"area_id",labelKey:"area"},{key:"documentation_url",labelKey:"documentation_url_label"},{key:"notes",labelKey:"object_notes_label"},{key:"task_count",labelKey:"tasks"},{key:"actions",labelKey:"actions"}],u=_.map(e=>e.key),i=["name","manufacturer","model","serial_number","installation_date","warranty_expiry","area_id","task_count","actions"];function g(e){if(!Array.isArray(e))return[...i];let n=new Set,r=[];for(let t of e)typeof t=="string"&&u.includes(t)&&!n.has(t)&&(n.add(t),r.push(t));return r.length?(r.includes("name")||r.unshift("name"),r):[...i]}function f(e,n){return a(`home_dwelling_${e==="house"||e==="apartment"?e:"unknown"}`,n)}function m(e,n){if(!e)return"";try{return new Intl.DisplayNames([n],{type:"region"}).of(e)||e}catch{return e}}var l={floors:"home_reason_floors",basement_floor:"home_reason_basement",area_basement:"home_reason_basement",area_garden:"home_reason_garden",area_shed:"home_reason_shed",area_driveway:"home_reason_driveway",area_garage:"home_reason_garage",entity_garage:"home_reason_garage",area_attic:"home_reason_attic",entity_lawn_mower:"home_reason_lawn_mower",entity_gate:"home_reason_gate",area_balcony:"home_reason_balcony",small_home:"home_reason_small_home"},c={ups:"home_reason_ups"};function d(e,n){let r=[];for(let t of e){let o=l[t];if(!o)continue;let s=a(o,n);r.includes(s)||r.push(s)}return r.join(", ")}function b(e,n,r){if(e==="starter")return a("home_reason_starter",n);if(e==="country")return a("home_reason_country",n).replace("{country}",m(r,n));if(e.startsWith("feature_")){let t=e.slice(8),o=l[`area_${t}`]??c[t];return a("home_reason_feature",n).replace("{feature}",o?a(o,n):e)}return a(`home_trait_${e}`,n)}export{_ as a,i as b,g as c,f as d,m as e,d as f,b as g};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{a as y}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-FH35SLMS.js";var l=6;async function g(e,i,n){let r=new Array(e.length),a=0,t=async()=>{for(;a<e.length;){let s=a++;r[s]=await n(e[s],s)}},o=Math.min(Math.max(1,i),e.length);return await Promise.all(Array.from({length:o},t)),r}function u(e){let i=e.history?.length??0;return typeof e.history_count!="number"||e.history_count>i}function d(e){return JSON.stringify([e.history_count??null,e.history??[]])}function h(e,i){return`${e}:${i}`}var c=class{constructor(i){this._host=i;this._cache=new Map;this._inFlight=new Map;this._running=0;this._pending=Promise.resolve();this.version=0;i.addController(this)}hostConnected(){}get loading(){return this._running>0}get capped(){for(let i of this._cache.values())if(i.entries.length>=y)return!0;return!1}historyOf(i,n){return this._cache.get(h(i,n.id))?.entries??n.history??[]}sync(i,n){if(!i?.connection)return this._pending;let r=n.filter(t=>u(t.task)).map(t=>({...t,key:h(t.entryId,t.task.id),sig:d(t.task)})).filter(t=>this._cache.get(t.key)?.sig!==t.sig&&this._inFlight.get(t.key)!==t.sig);if(!r.length)return this._pending;for(let t of r)this._inFlight.set(t.key,t.sig);this._running++,this._host.requestUpdate();let a=g(r,l,async t=>{let o;try{let s=await i.connection.sendMessagePromise({type:"maintenance_supporter/task/history",entry_id:t.entryId,task_id:t.task.id});o=Array.isArray(s?.history)?s.history:t.task.history??[]}catch{o=t.task.history??[]}this._inFlight.get(t.key)===t.sig&&(this._inFlight.delete(t.key),this._cache.set(t.key,{sig:t.sig,entries:o}))}).then(()=>{this._running--,this.version++,this._host.requestUpdate()});return this._pending=Promise.all([this._pending,a]).then(()=>{}),this._pending}settled(){return this._pending}};export{c as a};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
function c(o,t,e){let l=new Blob([o],{type:e}),d=URL.createObjectURL(l),n=document.createElement("a");n.href=d,n.download=t,n.target="_blank",n.rel="noopener",n.style.display="none",document.body.appendChild(n),n.dispatchEvent(new MouseEvent("click")),document.body.removeChild(n),setTimeout(()=>URL.revokeObjectURL(d),6e4)}function r(o,t){let e=document.createElement("a");e.href=o,e.download=t,e.target="_blank",e.rel="noopener",e.style.display="none",document.body.appendChild(e),e.dispatchEvent(new MouseEvent("click")),document.body.removeChild(e)}export{c as a,r as b};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{P as n,t as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var l={"Completed from dashboard button":"hist_note_button","Completed from the To-do list":"hist_note_todo","Completed by voice":"hist_note_voice","Completed from the notification":"hist_note_notification","Completed via NFC tag":"hist_note_nfc","Completed from the shopping list":"hist_note_shopping_list","Completed from a mirrored to-do list":"hist_note_todo_mirror","Skipped from notification":"hist_note_skipped_notification","Skipped from dashboard button":"hist_note_skipped_button","Sensor trigger activated":"hist_note_sensor_triggered","Initial value set during task creation":"hist_note_initial"},m=[[/^Auto-completed: sensor recovered \((.+)\)$/,"hist_note_auto_recovered",t=>({value:t[1]})],[/^Reset to (\d{4}-\d{2}-\d{2})$/,"hist_note_reset",(t,e)=>({date:n(t[1],e)})],[/^Trigger entity replaced: (.+) → (.+)$/,"hist_note_trigger_replaced",t=>({old:t[1],new:t[2]})],[/^Sensor trigger removed \(entity was: (.+)\)\. Schedule converted to (\w+)\.$/,"hist_note_trigger_removed",(t,e)=>({entity:t[1],schedule:o(t[2],e)})],[/^Entity (.+) removed from compound trigger; (\d+) conditions remain\.$/,"hist_note_compound_removed",t=>({entity:t[1],count:t[2]})]];function h(t,e){if(!t)return"";let i=l[t.trim()];if(i)return o(i,e);for(let[s,d,_]of m){let r=t.trim().match(s);if(r){let c=_(r,e);return o(d,e).replace(/\{(\w+)\}/g,(a,g)=>c[g]??a)}}return t}export{h as a};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
function c(n){return n==="use"?"use":"purchase"}var s=n=>typeof n=="number"&&Number.isFinite(n)?n:null;function a(n){let t=s(n?.cost);if(t===null)return null;let o=s(n?.package_size);return o&&o>0?t/o:t}function p(n,t){let o=s(t)&&t>0?t:s(n.restock_quantity)||1,e=s(n.package_size);return Math.round(o*(e&&e>0?e:1)*100)/100}function l(n){let t=s(n.cost);if(n.cost_basis!=="use")return t;let o=s(n.parts_cost),e=n.purchase?null:t;return e===null&&o===null?null:(e??0)+(o??0)}function _(n){if(!n)return[];let t=[],o=n.photo_doc_id;typeof o=="string"&&t.push(o);let e=n.photo_doc_ids;Array.isArray(e)&&t.push(...e);let r=[];for(let u of t){if(typeof u!="string")continue;let i=u.trim();if(!(!i||r.includes(i))&&(r.push(i),r.length>=10))break}return r}export{_ as a,c as b,a as c,p as d,l as e};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
||||
/*! maintenance_supporter frontend 2.103.0 */
|
||||
import{b as r,c as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var d=r`
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: var(--ms-modal-z, 100);
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: var(--ms-modal-max-width, 480px);
|
||||
max-height: var(--ms-modal-max-height, 92vh); overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: var(--ms-modal-gap, 14px);
|
||||
z-index: calc(var(--ms-modal-z, 100) + 1);
|
||||
}
|
||||
/* Focused programmatically on open (so Escape works at once) — no ring. */
|
||||
.dialog:focus { outline: none; }
|
||||
/* The one part that shrinks and scrolls (min-height: 0); the header and the
|
||||
buttons around it keep their content's height. */
|
||||
.dialog-scroll {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
overflow-y: auto; overscroll-behavior: contain;
|
||||
display: flex; flex-direction: column; gap: var(--ms-modal-gap, 14px);
|
||||
}
|
||||
`;function l(o,a){o.key!=="Escape"||o.defaultPrevented||o.composedPath().some(e=>e instanceof Element&&e.localName==="ms-camera-capture")||(o.preventDefault(),o.stopPropagation(),a())}function i(o,a){return t`
|
||||
<div class="backdrop" @click=${o}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true" tabindex="-1"
|
||||
@keydown=${e=>l(e,o)}>${a}</div>
|
||||
`}function c(o){o?.querySelector(".dialog")?.focus({preventScroll:!0})}export{d as a,i as b,c};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user