Compare commits

...
Author SHA1 Message Date
Aidan Timson d5a2dbe30f Keep the ZHA groups table search in the settings controller 2026-10-22 16:33:38 +01:00
Aidan Timson 22b6d7c20e Add query params to the ZHA groups table 2026-10-22 16:08:30 +01:00
Aidan Timson 9285ef824f Add query params to the tags table (#55133)
* Keep the tags table search and sorting in the URL

* Add query params to the tags My redirect

* Use the data table settings controller on the tags page
2026-10-22 14:45:14 +00:00
WendelinandClaude Opus 5.5 3e99aa1817 Add generic action bar and paste bar components (#55189)
* Make paste bar a generic ha-paste-bar component

Move ha-automation-add-element-paste to src/components/ha-paste-bar and
use it in the add automation element, create card and create badge
dialogs. The caller passes the full localized button label, because
"Paste action", "Paste card" etc. can't be built from one shared string
in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NYDqLWMHjPkhtbWCf9EA1M

* Add generic ha-action-bar and build ha-paste-bar on it

ha-action-bar takes its actions through the default slot and exposes
--ha-action-bar-border-top so the border can match the parent, with the
divider border as default. ha-paste-bar now extends it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NYDqLWMHjPkhtbWCf9EA1M

---------

Co-authored-by: Claude <[email protected]>
2026-10-22 14:37:49 +00:00
Paul Bottein 5ec6f5a08d Make the max sections setting of sections views easier to understand (#54962)
* Make the max sections setting of sections views easier to understand

* Simplify the max sections form handling

* Fall back to 5 sections when the custom max sections input is cleared

* Use a dropdown with a custom number for the max sections setting

* Keep the custom max sections value as typed
2026-10-22 14:37:33 +00:00
Mason Liu 6272ee6eed Allow picture and source elements in markdown (#55155)
* Allow picture and source elements in markdown

The markdown sanitizer escaped <picture> and <source>, so release notes using the light/dark image pattern showed the raw tags as text. Allow them, including <source> inside <video> and <audio>, and apply the same URL protocol check to srcset that xss applies to src.

* Harden srcset validation and rewrite source URLs in READMEs

Split srcset strictly at commas and whitespace, allow only plain width or
density descriptors, check every URL with the same safeAttrValue check as
src, and write the value back with & escaped so the browser reads exactly
what was checked. This closes bypasses with commas without spaces, encoded
commas, nested entities and parentheses in descriptors.

rewriteHtmlUrls now rewrites source[src] and every URL in source[srcset]
like img[src], so README pictures resolve to the repository and cannot
point at the Home Assistant server.

* Simplify srcset rewriting
2026-10-22 16:36:26 +02:00
Paulus SchoutsenandClaude Opus 5.5 debc9685fb Show integration brand icons in the LoRaWAN devices table (#55187)
* Use the default row height for LoRaWAN devices on mobile

The devices table set a 132px row height on screens up to 600px wide.
Narrow screens show only the two-line device name column, and the
group headers also take the row height, so rows and group headers were
much too tall. Narrow screens now use the same 72px row height as wide
screens and the other config tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011gT7Yr2jEnpJ3VmKcCV7gy

* Show the integration brand icon of LoRaWAN devices

The devices table now has a first column with the brand icon of the
integration that owns the linked Home Assistant device. The column
also shows on narrow screens. Devices without a linked device show no
icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011gT7Yr2jEnpJ3VmKcCV7gy

* Update LoRaWAN device brand icons when the theme mode changes

The columns are memoized, so the icon template kept the light or dark
variant that was active on the first render. Dark mode is now an input
of the memoized columns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011gT7Yr2jEnpJ3VmKcCV7gy

---------

Co-authored-by: Claude <[email protected]>
2026-10-22 16:18:31 +02:00
Aidan Timson fd47b899b4 Add query params to the blueprints table (#55166) 2026-10-22 14:16:51 +00:00
d0e8ff1184 Don't cache signed URLs in the service worker (#55079)
Signed URLs (media files under /media/ carry an authSig) are different on
every request, so the catch-all file-cache route stored a new entry for
each one without ever serving it again. A photo slideshow filled Cache
Storage with one full-size image per slide until the origin quota ran out.
Fetch these from the network only.

Co-authored-by: Gudui <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-22 16:14:02 +02:00
7aad9aa344 Polish chart zoom on timelines and the zoom slider (#55178)
* Polish chart zoom on timelines and the zoom slider

- Keep timelines from jumping when the zoom slider appears
- Match the slider gap on timelines to line charts
- Double click or double tap the zoom slider to zoom back out
- Show a line at the tapped time on timelines, like on line charts

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Ignore drags for slider double tap and follow theme for timeline line

- Only count a press released where it started as the first tap, so a
  quick drag followed by another press no longer resets the zoom
- Color a time axis's own pointer line in the chart base, so it follows
  theme changes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Update src/components/chart/ha-chart-base.ts

Co-authored-by: Petar Petrov <[email protected]>

* Color the timeline axis pointer from the chart theme

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-22 17:01:08 +03:00
23 changed files with 845 additions and 219 deletions

No files matched your search

+23
View File
@@ -2,8 +2,23 @@
const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
] as const;
// The markdown worker drops a srcset unless every part between commas and
// spaces is a safe URL or a descriptor, so a plain split is enough: the srcset
// is written back from the rewritten URLs.
const splitSrcsetCandidates = (srcset: string) =>
srcset
.split(",")
.map((candidate) => candidate.trim().split(/\s+/))
.filter(([url]) => url);
const rewriteSrcset = (srcset: string, rewrite: (url: string) => string) =>
splitSrcsetCandidates(srcset)
.map(([url, ...descriptors]) => [rewrite(url), ...descriptors].join(" "))
.join(", ");
/**
* Rewrite the address of every link and image in a piece of HTML.
*
@@ -29,5 +44,13 @@ export const rewriteHtmlUrls = (
}
}
// Rewrite each address in a <picture> source like an image
for (const element of template.content.querySelectorAll("source[srcset]")) {
element.setAttribute(
"srcset",
rewriteSrcset(element.getAttribute("srcset")!, rewrite)
);
}
return template.innerHTML;
};
+43 -4
View File
@@ -79,6 +79,8 @@ const DEFAULT_TOOLTIP_TRIGGER_ON = "mousemove|click|mousewheel";
const ZOOM_SLIDER_BOTTOM = 4;
// Room the plot gives up at its bottom while the zoom slider shows
const ZOOM_SLIDER_SPACE = ZOOM_SLIDER_BOTTOM + ZOOM_SLIDER_HEIGHT + 8;
// Grid bottom that just fits the x-axis labels, as in line charts
const X_AXIS_LABEL_SPACE = 20;
const ZOOM_RESET_SIZE = 24;
const ZOOM_RESET_OFFSET = ZOOM_RESET_SIZE + 4;
// A narrower drag selection is a sloppy click rather than a zoom
@@ -524,7 +526,17 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const room = this._showZoomSlider();
if (this.chart && room !== this._zoomSliderRoom) {
this._zoomSliderRoom = room;
this.chart.setOption({ grid: this._getGridOption() });
if (this.growForZoomSlider) {
// The host grows by the room the grid gives up, so apply both in one
// render to keep the plot still instead of shrinking and regrowing it
this.chart.setOption(
{ grid: this._getGridOption() },
{ lazyUpdate: true }
);
this.chart.resize();
} else {
this.chart.setOption({ grid: this._getGridOption() });
}
}
}
@@ -539,7 +551,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
style=${styleMap({
height:
showZoomSlider && this.growForZoomSlider && this.height
? `calc(${this.height} + ${ZOOM_SLIDER_SPACE}px)`
? `calc(${this.height} + ${this._getZoomSliderSpace()}px)`
: this.height,
})}
>
@@ -1051,6 +1063,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
.start=${this._zoomRange[0]}
.end=${this._zoomRange[1]}
@zoom-slider-changed=${this._handleZoomSliderChanged}
@zoom-slider-reset=${this._handleZoomSliderReset}
@pointerdown=${this._holdZoomSlider}
@focusin=${this._holdZoomSlider}
@pointerup=${this._releaseZoomSlider}
@@ -1066,6 +1079,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._setZoomWindow(ev.detail.start, ev.detail.end);
}
// A held slider stays at the full range, so release it to hide it
private _handleZoomSliderReset() {
this._zoomSliderHeld = false;
this._handleZoomReset();
}
private _holdZoomSlider() {
this._zoomSliderHeld = true;
}
@@ -1084,8 +1103,25 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
return grid;
}
const [first, ...rest] = ensureArray(grid);
const bottom = typeof first.bottom === "number" ? first.bottom : 0;
return [{ ...first, bottom: bottom + ZOOM_SLIDER_SPACE }, ...rest];
return [
{ ...first, bottom: this._getGridBottom() + this._getZoomSliderSpace() },
...rest,
];
}
private _getGridBottom() {
const bottom =
this.options?.grid && ensureArray(this.options.grid)[0].bottom;
return typeof bottom === "number" ? bottom : 0;
}
// Charts that grow for the slider let it take their padding below the
// x-axis labels, so it sits as close to the labels as in other charts
private _getZoomSliderSpace() {
return this.growForZoomSlider
? ZOOM_SLIDER_SPACE -
Math.max(0, this._getGridBottom() - X_AXIS_LABEL_SPACE)
: ZOOM_SLIDER_SPACE;
}
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
@@ -1330,6 +1366,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
},
},
timeAxis: {
axisPointer: {
lineStyle: { color: style.getPropertyValue("--info-color") },
},
axisLine: {
show: true,
lineStyle: { color: style.getPropertyValue("--divider-color") },
+45 -2
View File
@@ -20,6 +20,10 @@ const THUMB_INSET = (ZOOM_SLIDER_HEIGHT - THUMB_SIZE) / 2;
const RANGE_MIN_WIDTH = 2 * (THUMB_INSET + THUMB_SIZE) + ZOOM_SLIDER_HEIGHT;
// The smallest visible range the thumbs can be dragged to, in percent
const MIN_RANGE = 1;
// A press this soon after a tap's release shows the full range again
const DOUBLE_PRESS_TIME = 300;
// A press that moves further than this is a drag, not a tap
const TAP_MAX_DISTANCE = 4;
type SliderPart = "start" | "end" | "range";
@@ -33,6 +37,10 @@ export class HaChartZoomSlider extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
private _pressX?: number;
private _lastTapTime?: number;
private _drag?: {
pointerId: number;
part: SliderPart;
@@ -54,6 +62,8 @@ export class HaChartZoomSlider extends LitElement {
<div
class="track"
@pointerdown=${this._handleTrackPointerDown}
@pointerup=${this._handleTrackPointerUp}
@pointercancel=${this._handleTrackPointerUp}
@selectstart=${preventDefault}
>
<div
@@ -122,11 +132,29 @@ export class HaChartZoomSlider extends LitElement {
return 100 / Math.max(this.clientWidth - RANGE_MIN_WIDTH, 1);
}
// Pressing the track beside the visible range centers the range there
// Pressing the track beside the visible range centers the range there.
// Double tapping anywhere on the slider resets it to the full range; this
// times taps itself, as touch browsers do not reliably fire dblclick.
private _handleTrackPointerDown(
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
if (ev.button !== 0 || ev.target !== ev.currentTarget) {
if (ev.button !== 0 || !ev.isPrimary) {
return;
}
if (
this._lastTapTime !== undefined &&
ev.timeStamp - this._lastTapTime < DOUBLE_PRESS_TIME
) {
this._lastTapTime = undefined;
this._pressX = undefined;
this._drag = undefined;
// The press ends here, so the chart does not keep holding the slider open
ev.stopPropagation();
fireEvent(this, "zoom-slider-reset");
return;
}
this._pressX = ev.clientX;
if (ev.target !== ev.currentTarget) {
return;
}
const position = (ev.offsetX - RANGE_MIN_WIDTH / 2) * this._getScale();
@@ -137,6 +165,20 @@ export class HaChartZoomSlider extends LitElement {
);
}
// Only a press released where it started counts as a tap, so a quick drag
// followed by another press does not reset the zoom
private _handleTrackPointerUp(ev: PointerEvent) {
if (!ev.isPrimary) {
return;
}
const tapped =
ev.type === "pointerup" &&
this._pressX !== undefined &&
Math.abs(ev.clientX - this._pressX) <= TAP_MAX_DISTANCE;
this._lastTapTime = tapped ? ev.timeStamp : undefined;
this._pressX = undefined;
}
private _handlePointerDown(
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
@@ -287,6 +329,7 @@ export class HaChartZoomSlider extends LitElement {
declare global {
interface HASSDomEvents {
"zoom-slider-changed": { start: number; end: number };
"zoom-slider-reset": undefined;
}
interface HTMLElementTagNameMap {
"ha-chart-zoom-slider": HaChartZoomSlider;
@@ -230,6 +230,13 @@ export class StateHistoryChartTimeline extends LitElement {
splitLine: {
show: false,
},
// The item tooltip draws no axis line, so mark the pointed time as
// line charts do, also where a tap opens the tooltip
axisPointer: {
show: true,
triggerTooltip: false,
label: { show: false },
},
},
yAxis: {
type: "category",
+37
View File
@@ -0,0 +1,37 @@
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators";
/**
* @element ha-action-bar
*
* @summary Bar with actions, placed below a scrolling list or content area.
*
* @slot - Actions of the bar, e.g. `ha-button` elements.
*
* @cssprop --ha-action-bar-border-top - Top border of the bar, set it to match the border of the parent. Defaults to `var(--ha-border-width-sm) solid var(--divider-color)`.
*/
@customElement("ha-action-bar")
export class HaActionBar extends LitElement {
protected render() {
return html`<slot></slot>`;
}
static styles = css`
:host {
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-3);
border-top: var(
--ha-action-bar-border-top,
var(--ha-border-width-sm) solid var(--divider-color)
);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-action-bar": HaActionBar;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { mdiContentPaste } from "@mdi/js";
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { HaActionBar } from "./ha-action-bar";
import "./ha-button";
import "./ha-svg-icon";
/**
* @element ha-paste-bar
*
* @summary Action bar with a paste button, placed below a scrolling list, e.g.
* in the add automation element, create card and create badge dialogs.
* Only render it when there is something on the clipboard.
*
* @attr label - Localized label of the paste button, e.g. "Paste action".
*
* @cssprop --ha-action-bar-border-top - Top border of the bar. Defaults to `var(--ha-border-width-sm) solid var(--divider-color)`.
*
* @fires paste-element - Fired when the paste button is clicked.
*/
@customElement("ha-paste-bar")
export class HaPasteBar extends HaActionBar {
@property() public label = "";
protected render() {
return html`<ha-button appearance="filled" size="s" @click=${this._paste}>
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
${this.label}
</ha-button>`;
}
private _paste() {
fireEvent(this, "paste-element");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-paste-bar": HaPasteBar;
}
interface HASSDomEvents {
"paste-element": undefined;
}
}
+10
View File
@@ -190,6 +190,16 @@ const initRouting = () => {
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
// Signed URLs (such as media files under /media/ with an authSig) differ on
// every request, so caching them never produces a hit. With the catch-all
// route below, a photo slideshow would store one full-size image per slide
// until the browser's storage quota runs out.
registerRoute(
({ url }) =>
url.origin === location.origin && url.searchParams.has("authSig"),
new NetworkOnly()
);
// Get manifest and onboarding from network.
registerRoute(/\/(?:manifest\.json|onboarding\.html)/, new NetworkOnly());
@@ -46,6 +46,7 @@ import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import "../../../components/ha-icon-next";
import "../../../components/ha-paste-bar";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-section-title";
import "../../../components/ha-service-icon";
@@ -123,7 +124,6 @@ import { haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import "./add-automation-element/ha-automation-add-element-paste";
import "./add-automation-element/ha-automation-add-from-target";
import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
@@ -863,12 +863,13 @@ class DialogAddAutomationElement
`
}
${
showPasteFooter
? html`<ha-automation-add-element-paste
.automationElementType=${automationElementType}
.clipboardItem=${this._params!.clipboardItem}
showPasteFooter && this._params!.clipboardItem
? html`<ha-paste-bar
.label=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste_element`
)}
@paste-element=${this._paste}
></ha-automation-add-element-paste>`
></ha-paste-bar>`
: nothing
}
</div>`
@@ -922,12 +923,13 @@ class DialogAddAutomationElement
@value-changed=${this._selected}
>
${
this._tab === "blocks"
? html`<ha-automation-add-element-paste
.automationElementType=${automationElementType}
.clipboardItem=${this._params!.clipboardItem}
this._tab === "blocks" && this._params!.clipboardItem
? html`<ha-paste-bar
.label=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste_element`
)}
@paste-element=${this._paste}
></ha-automation-add-element-paste>`
></ha-paste-bar>`
: nothing
}
</ha-automation-add-items>
@@ -2552,13 +2554,10 @@ class DialogAddAutomationElement
--ha-bottom-sheet-surface-background: var(--card-background-color);
}
ha-bottom-sheet
.list-column:has(> ha-automation-add-element-paste[clipboard-item]) {
ha-bottom-sheet .list-column:has(> ha-paste-bar) {
margin-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
}
ha-bottom-sheet
ha-automation-add-items
> ha-automation-add-element-paste {
ha-bottom-sheet ha-automation-add-items > ha-paste-bar {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
}
@@ -2726,13 +2725,10 @@ class DialogAddAutomationElement
.groups {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
}
.list-column:has(> ha-automation-add-element-paste[clipboard-item])
> .groups {
.list-column:has(> ha-paste-bar) > .groups {
padding-bottom: var(--ha-space-3);
}
ha-automation-add-items:has(
> ha-automation-add-element-paste[clipboard-item]
) {
ha-automation-add-items:has(> ha-paste-bar) {
--ha-automation-add-items-padding-bottom: var(--ha-space-3);
}
@@ -1,72 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiContentPaste } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../../../common/decorators/consume";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-svg-icon";
import { internationalizationContext } from "../../../../data/context";
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
/**
* @element ha-automation-add-element-paste
*
* @summary Paste bar at the bottom of the add automation element dialog's list pane.
* Place it below the scrolling list.
*
* @attr clipboard-item - Type of the element on the clipboard. Nothing is shown when empty.
*
* @fires paste-element - Fired when the paste button is clicked.
*/
@customElement("ha-automation-add-element-paste")
export class HaAutomationAddElementPaste extends LitElement {
@property({ attribute: "clipboard-item", reflect: true })
public clipboardItem?: string;
@property({ attribute: "automation-element-type" })
public automationElementType!: AddAutomationElementDialogParams["type"];
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
protected render() {
if (!this.clipboardItem) {
return nothing;
}
return html`<ha-button appearance="filled" size="s" @click=${this._paste}>
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
${this._i18n.localize(
`ui.panel.config.automation.editor.${this.automationElementType}s.paste_element`
)}
</ha-button>`;
}
private _paste() {
fireEvent(this, "paste-element");
}
static styles = css`
:host {
display: flex;
align-items: center;
padding: var(--ha-space-3);
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
:host(:not([clipboard-item])) {
display: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-element-paste": HaAutomationAddElementPaste;
}
interface HASSDomEvents {
"paste-element": undefined;
}
}
@@ -12,21 +12,23 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import { storage } from "../../../common/decorators/storage";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { mainWindow } from "../../../common/dom/get_main_window";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { currentPath } from "../../../common/url/current-path";
import { extractSearchParam } from "../../../common/url/search-params";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
@@ -59,6 +61,10 @@ import { showToast } from "../../../util/toast";
import { configSections } from "../config-sections";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
const BARE_PATH = "/config/blueprint";
const DASHBOARD_PATH = `${BARE_PATH}/dashboard`;
const IMPORT_PATH = `${DASHBOARD_PATH}/import`;
type BlueprintMetaDataPath = BlueprintMetaData & {
path: string;
error: boolean;
@@ -97,11 +103,11 @@ class HaBlueprintOverview extends LitElement {
Blueprints
>;
@storage({ key: "blueprint-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "blueprint-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
private _tableSettings = new DataTableSettingsController(this, {
search: "blueprint-table-search",
sorting: "blueprint-table-sort",
grouping: "blueprint-table-grouping",
});
@storage({
key: "blueprint-table-collapsed",
@@ -124,15 +130,6 @@ class HaBlueprintOverview extends LitElement {
})
private _activeHiddenColumns?: string[];
@state()
@storage({
storage: "sessionStorage",
key: "blueprint-table-search",
state: true,
subscribe: false,
})
private _filter = "";
@state() private _usageCounts: Record<string, number> = {};
private _usageCountRequest = 0;
@@ -209,6 +206,7 @@ class HaBlueprintOverview extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "type",
},
path: {
title: localize("ui.panel.config.blueprint.overview.headers.file_name"),
@@ -319,16 +317,38 @@ class HaBlueprintOverview extends LitElement {
})
);
protected async firstUpdated(changedProps: PropertyValues<this>) {
public connectedCallback() {
// Move to the dashboard path before the URL controllers connect, they
// keep the path they connected on. The router only gets there from the
// bare path after this, and the import path is left for the dashboard.
// Reads the URL, as a cached config panel reconnects this page with its
// old route on the way to another page.
const path = currentPath().replace(/\/$/, "");
const importUrl =
path === IMPORT_PATH
? (extractSearchParam("blueprint_url") ?? "")
: undefined;
if (path === BARE_PATH || importUrl !== undefined) {
const search = importUrl === undefined ? mainWindow.location.search : "";
replaceCurrentUrl(
__DEMO__
? `${mainWindow.location.pathname}${search}#${DASHBOARD_PATH}`
: `${DASHBOARD_PATH}${search}`
);
fireEvent(mainWindow, "location-changed", { replace: true });
}
super.connectedCallback();
if (importUrl) {
this._addBlueprint(importUrl);
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadUsageCounts();
if (this.route.path === "/import") {
const url = extractSearchParam("blueprint_url");
await navigate("/config/blueprint/dashboard", { replace: true });
if (url) {
this._addBlueprint(url);
}
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
@@ -388,17 +408,18 @@ class HaBlueprintOverview extends LitElement {
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-button>
</div>`}
.initialGroupColumn=${this._activeGrouping}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.initialSorting=${this._tableSettings.sorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@sorting-changed=${this._tableSettings.sortingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
>
<ha-icon-button
slot="toolbar-icon"
@@ -789,22 +810,10 @@ class HaBlueprintOverview extends LitElement {
});
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
@@ -1,8 +1,10 @@
import type { ContextType } from "@lit/context";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { mdiPowerPlug } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../../../../common/decorators/consume";
import { QueryParamsController } from "../../../../../common/controllers/query-params-controller";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { fireEvent } from "../../../../../common/dom/fire_event";
@@ -17,6 +19,7 @@ import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-relative-time";
import "../../../../../components/ha-svg-icon";
import { configEntriesContext } from "../../../../../data/context";
import type {
LoRaWANDevice,
LoRaWANConnection,
@@ -29,6 +32,7 @@ import "../../../../../layouts/hass-tabs-subpage-data-table";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import { loadVirtualizer } from "../../../../../resources/virtualizer";
import type { HomeAssistant, Route } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
import "./lorawan-connection-link";
import { connectionLabels } from "./connection-labels";
@@ -36,6 +40,7 @@ interface DeviceRow extends LoRaWANDevice, DataTableRowData {
id: string;
rssi: number | null;
connection_title: string;
domain?: string;
}
@customElement("lorawan-devices")
@@ -65,6 +70,10 @@ export class LoRaWANDevices extends LitElement {
@state() private _error = false;
@state() private _connections: LoRaWANConnection[] = [];
@state()
@consume({ context: configEntriesContext, subscribe: true })
private _configEntries?: ContextType<typeof configEntriesContext>;
private _unsubscribe?: Promise<UnsubscribeFunc>;
private _connectionLabels = memoizeOne(connectionLabels);
@@ -73,18 +82,30 @@ export class LoRaWANDevices extends LitElement {
(
devices: LoRaWANDevice[] | undefined,
connection: string | null,
labels: Map<string, string>
): DeviceRow[] =>
(devices ?? [])
labels: Map<string, string>,
registryDevices: HomeAssistant["devices"],
configEntries: ContextType<typeof configEntriesContext> | undefined
): DeviceRow[] => {
const entryDomains = new Map(
(configEntries ?? []).map((entry) => [entry.entry_id, entry.domain])
);
return (devices ?? [])
.filter(
(device) => !connection || device.connection.entry_id === connection
)
.map((device) => ({
...device,
id: `${device.connection.entry_id}:${device.dev_eui}`,
rssi: strongestLoRaWANReception(device.receptions)?.rssi ?? null,
connection_title: labels.get(device.connection.entry_id)!,
}))
.map((device) => {
const entryId =
device.device_id &&
registryDevices[device.device_id]?.primary_config_entry;
return {
...device,
id: `${device.connection.entry_id}:${device.dev_eui}`,
rssi: strongestLoRaWANReception(device.receptions)?.rssi ?? null,
connection_title: labels.get(device.connection.entry_id)!,
domain: entryId ? entryDomains.get(entryId) : undefined,
};
});
}
);
public connectedCallback() {
@@ -126,8 +147,32 @@ export class LoRaWANDevices extends LitElement {
private _columns = memoizeOne(
(
localize: LocalizeFunc,
narrow: boolean
narrow: boolean,
darkMode: boolean
): DataTableColumnContainer<DeviceRow> => ({
icon: {
title: "",
label: localize("ui.panel.config.lorawan.icon"),
type: "icon",
moveable: false,
showNarrow: true,
template: (device) =>
device.domain
? html`<img
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: device.domain,
type: "icon",
darkOptimized: darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: "",
},
name: {
title: localize("ui.panel.config.lorawan.device"),
main: true,
@@ -233,8 +278,18 @@ export class LoRaWANDevices extends LitElement {
.isWide=${this.isWide}
.route=${this.route}
.tabs=${this._tabs}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${this._rows(this._devices, this._connectionId, labels)}
.columns=${this._columns(
this.hass.localize,
this.narrow,
this.hass.themes.darkMode
)}
.data=${this._rows(
this._devices,
this._connectionId,
labels,
this.hass.devices,
this._configEntries
)}
.loading=${this._devices === undefined && !this._error}
.loadError=${this._error}
.noDataText=${this.hass.localize("ui.panel.config.lorawan.no_devices")}
@@ -296,11 +351,6 @@ export class LoRaWANDevices extends LitElement {
ha-select {
margin: var(--ha-space-4);
}
@media (max-width: 600px) {
hass-tabs-subpage-data-table {
--data-table-row-height: 132px;
}
}
`;
}
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
@@ -51,6 +52,8 @@ export class ZHAGroupsDashboard extends LitElement {
private _firstUpdatedCalled = false;
private _tableSettings = new DataTableSettingsController(this, {});
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && this._firstUpdatedCalled) {
@@ -126,9 +129,12 @@ export class ZHAGroupsDashboard extends LitElement {
: undefined
}
@retry-load=${this._retryFetchGroups}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
@row-click=${this._handleRowClicked}
clickable
has-fab
sync-url
>
<ha-button href="/config/zha/group-add" slot="fab" size="l">
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
+9 -15
View File
@@ -9,7 +9,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
@@ -76,14 +76,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
return this.hass.auth.external?.config.canWriteTag;
}
@state()
@storage({
storage: "sessionStorage",
key: "tags-table-search",
state: true,
subscribe: false,
})
private _filter = "";
private _tableSettings = new DataTableSettingsController(this, {
search: "tags-table-search",
});
private _columns = memoizeOne((localize: LocalizeFunc) => {
const columns: DataTableColumnContainer<TagRowData> = {
@@ -99,6 +94,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
title: localize("ui.panel.config.tag.headers.name"),
main: true,
sortable: true,
urlName: "name",
filterable: true,
flex: 2,
},
@@ -111,6 +107,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
last_scanned_datetime: {
title: localize("ui.panel.config.tag.headers.last_scanned"),
sortable: true,
urlName: "last_scanned",
direction: "desc",
template: (tag) => html`
${
@@ -218,8 +215,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
: undefined
}
@retry-load=${this._retryFetchTags}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
@@ -417,10 +415,6 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
}
});
};
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
}
declare global {
+1
View File
@@ -346,6 +346,7 @@ class HaPanelHistory extends LitElement {
.start=${this._zoomRange[0]}
.end=${this._zoomRange[1]}
@zoom-slider-changed=${this._zoomSliderChanged}
@zoom-slider-reset=${this._resetZoom}
></ha-chart-zoom-slider>
</ha-card>`
: nothing
@@ -1,14 +1,13 @@
import { mdiClose, mdiContentPaste } from "@mdi/js";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-paste-bar";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
@@ -165,12 +164,12 @@ export class HuiCreateDialogBadge
return nothing;
}
return html`<div class="paste-bar">
<ha-button appearance="filled" size="s" @click=${this._paste}>
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
${this.hass.localize("ui.panel.lovelace.editor.badge_picker.paste_badge")}
</ha-button>
</div>`;
return html`<ha-paste-bar
.label=${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.paste_badge"
)}
@paste-element=${this._paste}
></ha-paste-bar>`;
}
private _ignoreKeydown(ev: KeyboardEvent) {
@@ -200,24 +199,17 @@ export class HuiCreateDialogBadge
ha-dialog::part(body) {
overflow: hidden;
}
.paste-bar {
display: flex;
align-items: center;
padding: var(--ha-space-3);
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--safe-area-inset-bottom: 0px;
}
.paste-bar {
ha-paste-bar {
padding-bottom: max(
var(--dialog-safe-area-inset-bottom),
var(--ha-space-3)
);
}
hui-badge-picker:not(:has(> .paste-bar)) {
hui-badge-picker:not(:has(> ha-paste-bar)) {
--ha-badge-picker-content-padding-bottom: var(
--dialog-safe-area-inset-bottom
);
@@ -227,7 +219,7 @@ export class HuiCreateDialogBadge
--dialog-safe-area-inset-bottom
);
}
hui-badge-suggestion-picker:not(:has(> .paste-bar)) {
hui-badge-suggestion-picker:not(:has(> ha-paste-bar)) {
--ha-suggestion-picker-tree-padding-bottom: var(
--dialog-safe-area-inset-bottom
);
@@ -1,14 +1,13 @@
import { mdiClose, mdiContentPaste } from "@mdi/js";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-paste-bar";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
@@ -187,12 +186,12 @@ export class HuiCreateDialogCard
return nothing;
}
return html`<div class="paste-bar">
<ha-button appearance="filled" size="s" @click=${this._paste}>
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
${this.hass.localize("ui.panel.lovelace.editor.cardpicker.paste_card")}
</ha-button>
</div>`;
return html`<ha-paste-bar
.label=${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.paste_card"
)}
@paste-element=${this._paste}
></ha-paste-bar>`;
}
private _ignoreKeydown(ev: KeyboardEvent) {
@@ -222,24 +221,17 @@ export class HuiCreateDialogCard
ha-dialog::part(body) {
overflow: hidden;
}
.paste-bar {
display: flex;
align-items: center;
padding: var(--ha-space-3);
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--safe-area-inset-bottom: 0px;
}
.paste-bar {
ha-paste-bar {
padding-bottom: max(
var(--dialog-safe-area-inset-bottom),
var(--ha-space-3)
);
}
hui-card-picker:not(:has(> .paste-bar)) {
hui-card-picker:not(:has(> ha-paste-bar)) {
--ha-card-picker-content-padding-bottom: var(
--dialog-safe-area-inset-bottom
);
@@ -249,7 +241,7 @@ export class HuiCreateDialogCard
--dialog-safe-area-inset-bottom
);
}
hui-suggestion-picker:not(:has(> .paste-bar)) {
hui-suggestion-picker:not(:has(> ha-paste-bar)) {
--ha-suggestion-picker-tree-padding-bottom: var(
--dialog-safe-area-inset-bottom
);
@@ -32,6 +32,9 @@ declare global {
const VALID_PATH_REGEX = /^[a-zA-Z0-9_-]+$/;
const INTEGER_REGEX = /^[0-9]+$/;
const MAX_COLUMNS_PRESETS = [1, 2, 3, 4] as const;
const MAX_COLUMNS_CUSTOM = "custom";
@customElement("hui-view-editor")
export class HuiViewEditor extends LitElement {
@consumeLocalize()
@@ -43,6 +46,13 @@ export class HuiViewEditor extends LitElement {
@state() private _error: Record<string, string> | undefined;
// Once the custom field is used, it keeps its own value apart from the
// config: a first digit matching a preset does not switch the list, and a
// cleared or invalid number stays as typed.
@state() private _customMaxColumns?: boolean;
@state() private _customMaxColumnsDraft?: number;
private _suggestedPath = false;
private _schema = memoizeOne(
@@ -101,13 +111,37 @@ export class HuiViewEditor extends LitElement {
schema: [
{
name: "max_columns",
default: DEFAULT_MAX_COLUMNS,
required: true,
selector: {
select: {
mode: "dropdown",
options: [
...MAX_COLUMNS_PRESETS.map((count) => ({
value: String(count),
label: localize(
count === DEFAULT_MAX_COLUMNS
? "ui.panel.lovelace.editor.edit_view.max_columns_option_default"
: "ui.panel.lovelace.editor.edit_view.max_columns_option",
{ count }
),
})),
{
value: MAX_COLUMNS_CUSTOM,
label: localize(
"ui.panel.lovelace.editor.edit_view.max_columns_custom_option"
),
},
],
},
},
},
{
name: "max_columns_custom",
visible: { field: "max_columns", value: MAX_COLUMNS_CUSTOM },
selector: {
number: {
min: 1,
max: 10,
mode: "slider",
slider_ticks: true,
mode: "box",
},
},
},
@@ -132,6 +166,16 @@ export class HuiViewEditor extends LitElement {
this._config = config;
}
private get _maxColumns(): number {
return this._config.max_columns ?? DEFAULT_MAX_COLUMNS;
}
private get _isCustomMaxColumns(): boolean {
return (
this._customMaxColumns ?? this._maxColumns > MAX_COLUMNS_PRESETS.length
);
}
get _type(): string {
return getViewType(this._config);
}
@@ -139,6 +183,12 @@ export class HuiViewEditor extends LitElement {
protected render() {
const data = {
...this._config,
max_columns: this._isCustomMaxColumns
? MAX_COLUMNS_CUSTOM
: String(this._maxColumns),
max_columns_custom: this._customMaxColumns
? this._customMaxColumnsDraft
: this._maxColumns,
type: this._type,
theme:
this._config.theme?.toLowerCase() === "backend-selected"
@@ -160,7 +210,26 @@ export class HuiViewEditor extends LitElement {
}
private _valueChanged(ev: CustomEvent): void {
const config = ev.detail.value as LovelaceViewConfig;
const {
max_columns: maxColumnsChoice,
max_columns_custom: maxColumnsCustom,
...rest
} = ev.detail.value as Omit<LovelaceViewConfig, "max_columns"> & {
max_columns: string;
max_columns_custom?: number;
};
const config: LovelaceViewConfig = rest;
this._customMaxColumns = maxColumnsChoice === MAX_COLUMNS_CUSTOM;
this._customMaxColumnsDraft = maxColumnsCustom;
const maxColumns = this._customMaxColumns
? maxColumnsCustom
: Number(maxColumnsChoice);
if (maxColumns && maxColumns >= 1 && maxColumns !== this._maxColumns) {
config.max_columns = maxColumns;
} else if (this._config.max_columns !== undefined) {
config.max_columns = this._config.max_columns;
}
if (config.type !== SECTIONS_VIEW_LAYOUT) {
delete config.max_columns;
@@ -211,6 +280,7 @@ export class HuiViewEditor extends LitElement {
case "show_icon_and_title":
case "subview":
case "max_columns":
case "max_columns_custom":
case "dense_section_placement":
case "top_margin":
case "section_specifics":
@@ -231,6 +301,7 @@ export class HuiViewEditor extends LitElement {
case "path":
case "show_icon_and_title":
case "subview":
case "max_columns":
case "dense_section_placement":
case "top_margin":
return this._localize(
+17
View File
@@ -109,6 +109,21 @@ const scriptsTableParams = {
blueprint: "string?",
} satisfies Redirect["params"];
// The blueprints table reads these from the link
const blueprintsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
} satisfies Redirect["params"];
// The tags table reads these from the link
const tagsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -334,6 +349,7 @@ export const getMyRedirects = (): Redirects => ({
blueprints: {
component: "blueprint",
redirect: "/config/blueprint/dashboard",
params: blueprintsTableParams,
},
automations: {
component: "automation",
@@ -363,6 +379,7 @@ export const getMyRedirects = (): Redirects => ({
tags: {
component: "tag",
redirect: "/config/tags",
params: tagsTableParams,
},
voice_assistants: {
redirect: "/config/voice-assistants",
+42 -1
View File
@@ -2,11 +2,46 @@ import { expose } from "comlink";
import type { MarkedOptions } from "marked";
import { marked } from "marked";
import type { IWhiteList } from "xss";
import { filterXSS, getDefaultWhiteList } from "xss";
import {
escapeAttrValue,
filterXSS,
friendlyAttrValue,
getDefaultWhiteList,
safeAttrValue,
} from "xss";
let whiteListNormal: IWhiteList | undefined;
let whiteListSvg: IWhiteList | undefined;
// The CSS filter is only used for `style` attributes
const cssFilter = { process: (css: string) => css };
// xss only checks the protocol of `src` and `href`, so check every URL in
// `srcset` the same way. Returns the attribute to render, or "" to drop it.
const sanitizeSrcset = (value: string): string => {
// Decode entities like xss does, and `&amp;` so query strings keep working
const decoded = friendlyAttrValue(value).replace(/&amp;/g, "&");
// Split at every comma and space. That is stricter than the browser (which
// allows commas inside a URL), so every URL the browser reads starts with
// one of these parts.
const allowed = decoded.split(/[\s,]+/).every(
(part) =>
!part ||
// A width or density descriptor such as `800w` or `2x`
/^\d+(?:\.\d+)?[wx]$/.test(part) ||
// `safeAttrValue` decodes again, so a part with entities left over
// (`&#38;#58;` leaves `&#58;`) would be checked as text the browser
// never sees. Drop it instead.
(friendlyAttrValue(part) === part &&
safeAttrValue("img", "src", part, cssFilter) !== "")
);
// xss would write the value back with `&` unescaped, so the browser would
// decode it a second time. Escape it so the browser reads what was checked.
return allowed
? `srcset="${escapeAttrValue(decoded.replace(/&/g, "&amp;"))}"`
: "";
};
const renderMarkdown = async (
content: string,
markedOptions: MarkedOptions,
@@ -21,6 +56,9 @@ const renderMarkdown = async (
...getDefaultWhiteList(),
table: [...(getDefaultWhiteList().table ?? []), "role"],
input: ["type", "disabled", "checked"],
// Allows <picture>, and <video>/<audio> that have <source> children.
picture: [],
source: ["src", "srcset", "sizes", "media", "type"],
"ha-icon": ["icon"],
"ha-svg-icon": ["path"],
"ha-alert": ["alert-type", "title"],
@@ -91,6 +129,9 @@ const renderMarkdown = async (
}
return "";
}
if (tag === "source" && name === "srcset") {
return sanitizeSrcset(value);
}
if (
hassOptions.allowDataUrl &&
tag === "a" &&
+7 -1
View File
@@ -9735,6 +9735,7 @@
},
"caption": "LoRaWAN",
"description": "Devices across your LoRaWAN connections",
"icon": "[%key:ui::panel::config::devices::data_table::icon%]",
"device": "Device",
"connection": "Connection",
"dev_eui": "DevEUI",
@@ -10210,7 +10211,12 @@
},
"show_icon_and_title": "Show icon and title",
"subview": "Subview",
"max_columns": "Max number of sections wide",
"max_columns": "Max sections side by side",
"max_columns_helper": "Smaller screens fit fewer side by side, and phones stack them one under the other.",
"max_columns_option": "{count} {count, plural,\n one {section}\n other {sections}\n}",
"max_columns_option_default": "{count} sections (default)",
"max_columns_custom_option": "Custom",
"max_columns_custom": "Number of sections",
"section_specifics": "Sections view specific settings",
"dense_section_placement": "Dense section placement",
"dense_section_placement_helper": "Will try to fill gaps with sections that fit the gap. This may make section placement less predictable.",
+35
View File
@@ -47,4 +47,39 @@ describe("rewriteHtmlUrls", () => {
'<a href="https://example.com/repo/docs/setup.md"><img src="https://example.com/repo/badge.svg"></a>'
);
});
it.each([
{
name: "a source of a video",
input: '<video controls><source src="clip.mp4" type="video/mp4"></video>',
output:
'<video controls=""><source src="https://example.com/repo/clip.mp4" type="video/mp4"></video>',
},
{
name: "a source of an audio",
input: '<audio controls><source src="./sound.ogg"></audio>',
output:
'<audio controls=""><source src="https://example.com/repo/./sound.ogg"></audio>',
},
{
name: "the srcset of a picture source",
input: '<picture><source srcset="dark.png"></picture>',
output:
'<picture><source srcset="https://example.com/repo/dark.png"></picture>',
},
{
name: "every address in a srcset and keeps the descriptors",
input:
'<source srcset="small.png 480w,large.png 1080w ,, huge.png 2x" sizes="50vw">',
output:
'<source srcset="https://example.com/repo/small.png 480w, https://example.com/repo/large.png 1080w, https://example.com/repo/huge.png 2x" sizes="50vw">',
},
{
name: "a srcset with an entity in it, as the browser reads it",
input: '<source srcset="img&#47;a.png 1x">',
output: '<source srcset="https://example.com/repo/img/a.png 1x">',
},
])("rewrites $name", ({ input, output }) => {
expect(rewriteHtmlUrls(input, toRepository)).toBe(output);
});
});
+85 -1
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
import { expose } from "comlink";
import { describe, expect, it, vi } from "vitest";
import { rewriteHtmlUrls } from "../../../src/common/dom/rewrite-html-urls";
import type { RepositoryInfo } from "../../../src/data/marketplace/repository";
import {
markdownWithRepositoryContext,
repositoryUrl,
} from "../../../src/panels/marketplace/tools/markdown";
import type { Api } from "../../../src/resources/markdown-worker";
vi.mock("comlink", () => ({ expose: vi.fn() }));
const repository = {
id: "42",
@@ -317,3 +322,82 @@ describe("markdownWithRepositoryContext", () => {
expect(performance.now() - start).toBeLessThan(1000);
});
});
describe("rewriteHtmlUrls with the README of a repository", () => {
const toReadme = repositoryUrl(repository);
const raw = "https://raw.githubusercontent.com/owner/repo/v1.0.0";
const srcsetOf = (input: string) =>
rewriteHtmlUrls(`<source srcset="${input}">`, toReadme).match(
/srcset="([^"]*)"/
)?.[1];
it.each([
{
name: "relative addresses into the repository",
input: "./images/logo.png 1x, images/[email protected] 2x",
output: `${raw}/images/logo.png 1x, ${raw}/images/[email protected] 2x`,
},
{
name: "a file shown on GitHub to raw",
input:
"https://github.com/owner/repo/blob/main/logo.png, https://github.com/other/repo/blob/v2/[email protected] 2x",
output:
"https://raw.githubusercontent.com/owner/repo/main/logo.png, https://raw.githubusercontent.com/other/repo/v2/[email protected] 2x",
},
{
// Never requested from Home Assistant, same as the src of an image
name: "an address at the root of the host into the repository",
input: "/api/states 1x, /local/x.png 2x",
output: `${raw}/api/states 1x, ${raw}/local/x.png 2x`,
},
{
name: "relative and absolute addresses together",
input: "a.png 100w, https://example.com/b.png 200w, /c.png 300w",
output: `${raw}/a.png 100w, https://example.com/b.png 200w, ${raw}/c.png 300w`,
},
])("points $name", ({ input, output }) => {
expect(srcsetOf(input)).toBe(output);
expect(
rewriteHtmlUrls(`<img src="${input.split(" ")[0]}">`, toReadme)
).toBe(`<img src="${output.split(" ")[0]}">`);
});
it("points the src of the source of a video", () => {
expect(
rewriteHtmlUrls(
'<video><source src="/api/camera_proxy/camera.x"></video>',
toReadme
)
).toBe(`<video><source src="${raw}/api/camera_proxy/camera.x"></video>`);
});
it("shows the dark logo of a README from the repository", async () => {
await import("../../../src/resources/markdown-worker");
const { renderMarkdown } = vi.mocked(expose).mock.calls[0][0] as Api;
const html = (
await renderMarkdown(
[
"<picture>",
' <source media="(prefers-color-scheme: dark)" srcset="./images/logo-dark.png">',
' <img src="./images/logo-light.png" alt="Logo">',
"</picture>",
].join("\n"),
{}
)
).join("");
const template = document.createElement("template");
template.innerHTML = rewriteHtmlUrls(html, toReadme);
const picture = template.content.querySelector("picture")!;
expect(picture.querySelector("source")?.getAttribute("media")).toBe(
"(prefers-color-scheme: dark)"
);
expect(picture.querySelector("source")?.getAttribute("srcset")).toBe(
`${raw}/images/logo-dark.png`
);
expect(picture.querySelector("img")?.getAttribute("src")).toBe(
`${raw}/images/logo-light.png`
);
});
});
+199
View File
@@ -0,0 +1,199 @@
import { expose } from "comlink";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { Api } from "../../src/resources/markdown-worker";
vi.mock("comlink", () => ({ expose: vi.fn() }));
let renderMarkdown: Api["renderMarkdown"];
const render = async (content: string, allowSvg = false) =>
(await renderMarkdown(content, {}, { allowSvg })).join("");
// Parse HTML like the browser does, so tests see decoded attribute values
const parse = (html: string) => {
const template = document.createElement("template");
template.innerHTML = html;
return template.content;
};
beforeAll(async () => {
await import("../../src/resources/markdown-worker");
renderMarkdown = vi.mocked(expose).mock.calls[0][0].renderMarkdown;
});
describe("renderMarkdown", () => {
describe("picture", () => {
it("renders picture and source elements", async () => {
const html = await render(
[
"<picture>",
' <source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.jpg">',
' <img src="https://example.com/light.jpg" alt="Chart" width="800">',
"</picture>",
].join("\n")
);
const source = parse(html).querySelector("picture > source");
expect(source?.getAttribute("media")).toBe(
"(prefers-color-scheme: dark)"
);
expect(source?.getAttribute("srcset")).toBe(
"https://example.com/dark.jpg"
);
expect(
parse(html).querySelector("picture > img")?.getAttribute("src")
).toBe("https://example.com/light.jpg");
expect(html).not.toContain("&lt;");
});
it("keeps type and sizes on source", async () => {
const source = parse(
await render(
'<picture><source type="image/webp" sizes="50vw" srcset="https://example.com/a.webp 1x, https://example.com/b.webp 2x"><img src="https://example.com/a.png"></picture>'
)
).querySelector("picture > source");
expect(source?.getAttribute("type")).toBe("image/webp");
expect(source?.getAttribute("sizes")).toBe("50vw");
expect(source?.getAttribute("srcset")).toBe(
"https://example.com/a.webp 1x, https://example.com/b.webp 2x"
);
});
it("does not allow other attributes on source", async () => {
const source = parse(
await render(
'<picture><source srcset="https://example.com/a.jpg" onerror="alert(1)"></picture>'
)
).querySelector("picture > source");
expect(source?.getAttributeNames()).toEqual(["srcset"]);
});
});
describe.each([false, true])("srcset (allowSvg: %s)", (allowSvg) => {
// The <source> the browser gets for a srcset
const renderSource = async (srcset: string) => {
const source = parse(
await render(
`<picture><source srcset="${srcset}"><img src="https://example.com/fallback.png"></picture>`,
allowSvg
)
).querySelector("picture > source");
// Don't let a test pass because the whole element was escaped
expect(source).not.toBeNull();
return source!;
};
it.each([
"https://example.com/dark.png",
"https://example.com/a.jpg 1x, https://example.com/b.jpg 2x",
"https://example.com/a.jpg 1x,https://example.com/b.jpg 2x",
"https://example.com/a.jpg 400w,https://example.com/b.jpg 800w",
"https://example.com/a.jpg 1x,, https://example.com/b.jpg 2x",
"https://example.com/a.jpg 1.5x",
"/local/a.jpg 1x,./b.jpg 2x",
"//example.com/a.jpg 1x,../b.jpg 2x",
"https://example.com/a.jpg?x=1&amp;y=2 1x, https://example.com/b.jpg?x=1&y=2 2x",
"https://example.com/a.jpg 1x&#44; https://example.com/b.jpg 2x",
"https://example.com/javascript:logo.svg 1x",
])("keeps %s", async (srcset) => {
// What the browser reads from the markup as written
const expected = parse(`<source srcset="${srcset}">`)
.querySelector("source")!
.getAttribute("srcset");
expect((await renderSource(srcset)).getAttribute("srcset")).toBe(
expected
);
});
it.each([
// eslint-disable-next-line no-script-url
"javascript:alert(1)",
" JaVaScRiPt:alert(1)",
"&#106;avascript:alert(1)",
"vbscript:msgbox(1)",
"https://example.com/a.jpg 1x, javascript:alert(1) 2x",
"https://example.com/a.jpg 1x,javascript:alert(1) 2x",
"https://example.com/a.jpg 1x&#44; javascript:alert(1) 2x",
"https://example.com/a.jpg 1x,&#1;javascript:alert(1) 2x",
"https://example.com/a.jpg 1x,blob:https://example.com/id 2x",
// xss decodes these once and the browser would decode them again
"https://example.com/a.jpg 1x&#38;#44;java&#38;#115;cript:alert(1) 2x",
"https://example.com/a.jpg 1x&comma;java&#38;#115;cript:alert(1) 2x",
"https://example.com/a.jpg 1x&comma;data:text/html&comma;x 2x",
"data:&#38;#105;mage/png 1x",
// A comma inside parentheses does not end a descriptor
"https://example.com/a.jpg 1x(,https://example.com/b)x,data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg== 2x",
])("drops srcset with a disallowed URL: %s", async (srcset) => {
expect((await renderSource(srcset)).hasAttribute("srcset")).toBe(false);
});
// Splitting at every comma and space is stricter than the browser, so
// these valid but rare forms are dropped and the <img> fallback shows.
it.each([
// A comma inside a URL
"https://res.cloudinary.com/demo/image/upload/w_400,c_fill/a.jpg 1x",
"data:image/png;base64,aGVsbG8= 1x",
// A comma inside a parenthesized descriptor
"https://example.com/a.jpg (ignored, x),https://example.com/b.jpg 2x",
// Named references other than `&amp;` are not decoded
"https://example.com/a.jpg 1x&comma;https://example.com/b.jpg 2x",
])("drops srcset the strict check can't split: %s", async (srcset) => {
expect((await renderSource(srcset)).hasAttribute("srcset")).toBe(false);
});
it("keeps an undecoded named reference as text", async () => {
// If `&comma;` became a comma here, the browser would see two URLs
expect(
(
await renderSource(
"https://example.com/a.jpg&comma;javascript:alert(1) 1x"
)
).getAttribute("srcset")
).toBe("https://example.com/a.jpg&comma;javascript:alert(1) 1x");
});
it("keeps quotes and angle brackets inside the attribute", async () => {
const content = parse(
await render(
'<picture><source srcset="https://example.com/a.jpg?q=&quot;&#62;&#60;b&#62; 1x"></picture>',
allowSvg
)
);
const source = content.querySelector("picture > source");
expect(source?.getAttributeNames()).toEqual(["srcset"]);
expect(source?.getAttribute("srcset")).toBe(
'https://example.com/a.jpg?q="><b> 1x'
);
expect(content.querySelector("b")).toBeNull();
});
it("does not let an encoded quote add an attribute", async () => {
const source = await renderSource(
"https://example.com/a.jpg&quot; onerror=&quot;alert(1)"
);
expect(source.hasAttribute("onerror")).toBe(false);
});
});
describe.each(["audio", "video"])("source src in %s", (tag) => {
const renderSrc = async (src: string) =>
parse(await render(`<${tag} controls><source src="${src}"></${tag}>`))
.querySelector(`${tag} > source`)
?.getAttribute("src");
it("keeps an https src", async () => {
expect(await renderSrc("https://example.com/a.mp4")).toBe(
"https://example.com/a.mp4"
);
});
it.each([
// eslint-disable-next-line no-script-url
"javascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html,x",
"blob:https://example.com/id",
])("empties a disallowed src: %s", async (src) => {
expect(await renderSrc(src)).toBe("");
});
});
});