Compare commits

..
29 changed files with 760 additions and 1458 deletions
-21
View File
@@ -42,20 +42,7 @@ jobs:
- name: Bump version
run: script/version_bump.js nightly
# Warm the shared compression cache so releases reuse it (see release.yaml).
- name: Restore compression cache
id: compress-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
compress-cache-${{ runner.os }}-
- name: Build nightly Python wheels
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
run: |
pip install build
yarn install
@@ -64,14 +51,6 @@ jobs:
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
- name: Save compression cache
if: always() && steps.compress-cache.outcome == 'success'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Archive translations
run: tar -czvf translations.tar.gz translations
-24
View File
@@ -49,36 +49,12 @@ jobs:
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
# Restore the content-addressed compression cache. Unchanged chunks and
# static assets are then reused instead of re-run through brotli/zopfli.
- name: Restore compression cache
id: compress-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
compress-cache-${{ runner.os }}-
- name: Build and release package
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
run: |
python3 -m pip install build
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
script/release
# The build prunes the cache to this dist's files, so saving keeps it
# bounded. A unique key always writes; restore-keys picks the newest.
- name: Save compression cache
if: always() && steps.compress-cache.outcome == 'success'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
-1
View File
@@ -6,7 +6,6 @@ build/
dist/
/hass_frontend/
/translations/
/.compress-cache/
# Composite action source, not build output
!/.github/actions/build/
+5 -15
View File
@@ -12,8 +12,7 @@
import { availableParallelism } from "node:os";
import { buffer as readStream } from "node:stream/consumers";
import { promisify } from "node:util";
import { brotliCompress, constants } from "node:zlib";
import { withCache } from "./compress-cache.mjs";
import { brotliCompress } from "node:zlib";
import { ParallelTransform } from "./parallel-transform.mjs";
const EXTENSION = ".br";
@@ -25,13 +24,8 @@ const compress = promisify(brotliCompress);
* @param {boolean} [options.skipLarger] Drop files that compression grows.
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
*/
export default ({ skipLarger = false, params } = {}) => {
// Isolate cache entries by anything that changes the output bytes: the brotli
// quality, and the node major that produced them.
const quality = params?.[constants.BROTLI_PARAM_QUALITY] ?? "default";
const namespace = `brotli-q${quality}-node${process.versions.node.split(".")[0]}`;
return new ParallelTransform(availableParallelism(), async (file) => {
export default ({ skipLarger = false, params } = {}) =>
new ParallelTransform(availableParallelism(), async (file) => {
if (file.isNull()) {
return file;
}
@@ -39,13 +33,10 @@ export default ({ skipLarger = false, params } = {}) => {
file.contents = await readStream(file.contents);
}
const compressed = await withCache(namespace, file.contents, async () => {
const out = await compress(file.contents, { params });
const compressed = await compress(file.contents, { params });
if (skipLarger && compressed.length >= file.contents.length) {
// Dropped rather than passed through, as gulp-brotli did: the
// uncompressed file is already in the output directory.
return skipLarger && out.length >= file.contents.length ? undefined : out;
});
if (compressed === undefined) {
return undefined;
}
@@ -53,4 +44,3 @@ export default ({ skipLarger = false, params } = {}) => {
file.path += EXTENSION;
return file;
});
};
-193
View File
@@ -1,193 +0,0 @@
// Content-addressed cache for compression output.
//
// brotli (quality 11) and zopfli are the slowest part of a production build,
// and they redo every file from scratch on every run. But production chunks are
// content-hashed in their filenames, so a chunk that didn't change produces
// byte-identical input here. Keying the compressed output by a hash of the
// input bytes lets an unchanged file skip compression entirely, and lets a
// nightly build warm the cache a release reuses the next day.
//
// Disabled unless COMPRESS_CACHE_DIR points at a directory. Local builds set
// nothing and behave exactly as before. The compressed bytes are unchanged
// either way; only whether they were recomputed differs.
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import {
mkdir,
readFile,
readdir,
rename,
rm,
stat,
utimes,
writeFile,
} from "node:fs/promises";
import path from "node:path";
const rootDir = process.env.COMPRESS_CACHE_DIR;
export const cacheEnabled = Boolean(rootDir);
// Total cache size to keep across builds, least-recently-used first when over.
// The cache is shared between branches (a dev nightly warms it for a release,
// which builds from rc/master), so pruning must NOT drop everything the current
// build didn't touch — that would make each branch evict the other's
// branch-specific files every run. Instead the current build is pinned and the
// rest is kept up to this budget, so nothing is dropped unless the cache is
// genuinely too large. Override with COMPRESS_CACHE_MAX_BYTES.
const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB
const maxBytes = () =>
Number(process.env.COMPRESS_CACHE_MAX_BYTES) || DEFAULT_MAX_BYTES;
// Every cache key touched this build, hits and writes alike, so a prune can pin
// the current dist and never evict a file this build depends on.
const touched = new Set();
// Per-namespace directory, created lazily and only once.
const dirs = new Map();
let tmpCounter = 0;
const sha256 = (contents) =>
createHash("sha256").update(contents).digest("hex");
const namespaceDir = (namespace) => {
let dir = dirs.get(namespace);
if (!dir) {
const dirPath = path.join(rootDir, namespace);
dir = { path: dirPath, ready: undefined };
dirs.set(namespace, dir);
}
return dir;
};
const ensureDir = (dir) => {
dir.ready ??= mkdir(dir.path, { recursive: true });
return dir.ready;
};
// Written via a unique temp file and renamed into place so a concurrent reader
// in the same build never sees a half-written entry (rename is atomic on the
// same filesystem).
const writeAtomic = async (dir, name, contents) => {
await ensureDir(dir);
tmpCounter += 1;
const tmp = path.join(dir.path, `.${process.pid}-${tmpCounter}.tmp`);
await writeFile(tmp, contents);
await rename(tmp, path.join(dir.path, name));
};
/**
* Return the cached compression result for `contents`, or run `compute` and
* cache what it returns. `compute` resolves to a Buffer for compressed output,
* or `undefined` when the file should be dropped (brotli skipLarger); both
* outcomes are cached, so a dropped file is not recompressed on the next build.
*
* @param {string} namespace Isolates entries by algorithm, parameters and tool
* version, so a change to any of them can never return a stale result.
* @param {Buffer} contents Uncompressed input bytes, used as the cache key.
* @param {() => Promise<Buffer | undefined>} compute Runs on a cache miss.
* @returns {Promise<Buffer | undefined>}
*/
export const withCache = async (namespace, contents, compute) => {
if (!cacheEnabled) {
return compute();
}
const dir = namespaceDir(namespace);
const hash = sha256(contents);
touched.add(`${namespace}/${hash}`);
const dataPath = path.join(dir.path, hash);
const dropName = `${hash}.drop`;
try {
return await readFile(dataPath);
} catch {
// Miss (or unreadable) — fall through and compute.
}
if (existsSync(path.join(dir.path, dropName))) {
return undefined;
}
const result = await compute();
if (result === undefined) {
await writeAtomic(dir, dropName, "");
} else {
await writeAtomic(dir, hash, result);
}
return result;
};
/**
* Keep the cache under `maxBytes`, evicting least-recently-used entries first.
* Files this build touched are always kept (and their timestamp refreshed, so
* shared files stay warm across branches); the remainder — including another
* branch's entries — is kept up to the budget. No-op when the cache is disabled
* or nothing was compressed, so it never wipes a warm cache on a build that
* skipped compression.
*/
export const pruneCache = async () => {
if (!cacheEnabled || touched.size === 0 || !existsSync(rootDir)) {
return;
}
const now = new Date();
const pinned = [];
const others = [];
// Scan every namespace on disk, not just the ones this build used, so entries
// from an old tool version (a different namespace) are eligible for eviction.
const namespaces = await readdir(rootDir, { withFileTypes: true });
await Promise.all(
namespaces.map(async (ns) => {
if (!ns.isDirectory()) {
return;
}
const nsDir = path.join(rootDir, ns.name);
const entries = await readdir(nsDir);
await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(nsDir, entry);
// Stray temp files from an interrupted write are never valid entries.
if (entry.endsWith(".tmp")) {
await rm(entryPath, { force: true });
return;
}
const hash = entry.endsWith(".drop") ? entry.slice(0, -5) : entry;
const info = await stat(entryPath);
if (touched.has(`${ns.name}/${hash}`)) {
pinned.push({ entryPath, size: info.size });
} else {
others.push({ entryPath, size: info.size, mtimeMs: info.mtimeMs });
}
})
);
})
);
// Pinned files stay and are refreshed so they rank as most-recently-used for
// future prunes; they always count against the budget first.
let kept = 0;
await Promise.all(
pinned.map(async ({ entryPath, size }) => {
kept += size;
// A failed timestamp refresh only affects future LRU ordering, not
// correctness, so it is safe to ignore.
await utimes(entryPath, now, now).catch(() => undefined);
})
);
// Keep the most-recently-used others until the budget is spent; drop the rest.
others.sort((a, b) => b.mtimeMs - a.mtimeMs);
const budget = maxBytes();
const toDelete = [];
for (const entry of others) {
if (kept + entry.size <= budget) {
kept += entry.size;
} else {
toDelete.push(entry.entryPath);
}
}
await Promise.all(toDelete.map((p) => rm(p, { force: true })));
};
+1 -3
View File
@@ -50,9 +50,7 @@ gulp.task(
"rspack-prod-app",
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
// Don't compress running tests
...(env.isTestBuild() || env.isStatsBuild()
? []
: ["compress-app", "prune-compress-cache"])
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
)
);
-5
View File
@@ -3,7 +3,6 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "../brotli.mjs";
import { pruneCache } from "../compress-cache.mjs";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
@@ -58,7 +57,3 @@ gulp.task(
compressAppOtherZopfli
)
);
// Drop cache entries this build didn't use, keeping the compression cache
// bounded. No-op unless COMPRESS_CACHE_DIR is set.
gulp.task("prune-compress-cache", () => pruneCache());
+1 -12
View File
@@ -5,23 +5,14 @@
// blocks the event loop for the whole compression step. Running one WASM
// instance per worker parallelises it; the output bytes are unchanged.
import { createRequire } from "node:module";
import { availableParallelism } from "node:os";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
import { withCache } from "./compress-cache.mjs";
import { ParallelTransform } from "./parallel-transform.mjs";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
// Cache namespace tied to the zopfli version, since a different version can
// produce different bytes for the same input.
const ZOPFLI_VERSION = createRequire(import.meta.url)(
"@gfx/zopfli/package.json"
).version;
const NAMESPACE = `gzip-zopfli${ZOPFLI_VERSION}`;
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
// what gulp-zopfli-green did, so compressed output stays byte-identical.
const ZOPFLI_OPTIONS = {};
@@ -139,9 +130,7 @@ export default ({ threshold = 0 } = {}) => {
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
return file;
}
file.contents = await withCache(NAMESPACE, file.contents, () =>
compress(file.contents)
);
file.contents = await compress(file.contents);
file.path += EXTENSION;
return file;
});
-1
View File
@@ -228,7 +228,6 @@ export default [
"entity-state",
"ha-markdown",
"integration-card",
"cloud-account",
"box-shadow",
"util-long-press",
"remove-delete-add-create",
@@ -106,17 +106,6 @@ export class DemoHaSelectBox extends LitElement {
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<label>Disabled with a selected option</label>
<ha-select-box
.value=${"card"}
.options=${fullOptions}
.disabled=${true}
>
</ha-select-box>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Column layout</b></p>
@@ -1,8 +0,0 @@
---
title: Cloud account
---
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
mocked cloud data. The controls at the top flip the mocked subscription, remote,
backup, onboarding, and feature state so every UI state can be previewed here
without a real cloud account.
-568
View File
@@ -1,568 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
import type { HaSwitch } from "../../../../src/components/ha-switch";
import type { BackupConfig } from "../../../../src/data/backup";
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
import type {
CloudStatusLoggedIn,
RemoteCertificateStatus,
SubscriptionInfo,
} from "../../../../src/data/cloud";
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
import type { Webhook } from "../../../../src/data/webhook";
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
import type { HomeAssistant } from "../../../../src/types";
// This demo renders the self-contained cloud account cards (overview +
// onboarding) directly, driven by mocked data, with controls to flip the
// subscription, remote, backup, onboarding, and feature state so every UI state
// can be previewed. It intentionally does NOT render the <cloud-account> panel
// wrapper, which adds a hass-subpage toolbar/back button and route links that
// have no home in the gallery. See src/panels/config/cloud/account.
// The five PaymentSubscriptionState values.
type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups run, but only to the local agent (no cloud copy).
// "none": no automatic backups at all.
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
const DEFAULT_SCENARIO: CloudDemoScenario = {
account: "active",
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
// card shows by default with streaming as the remaining step.
onboarded: false,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: false,
webhooks: true,
};
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "trialing", label: "Trialing" },
{ value: "canceled", label: "Canceled" },
{ value: "expired", label: "Expired" },
{ value: "unknown", label: "Unknown" },
];
const REMOTE_STATUS_OPTIONS: {
value: RemoteCertificateStatus;
label: string;
}[] = [
{ value: "ready", label: "Ready" },
{ value: "generating", label: "Preparing" },
{ value: "loading", label: "Loading" },
{ value: "loaded", label: "Loaded" },
{ value: "error", label: "Error" },
];
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
{ value: "fresh", label: "Recent" },
{ value: "stale", label: "Old" },
{ value: "failed", label: "Failed" },
{ value: "local", label: "Local only" },
{ value: "none", label: "None" },
];
const TOGGLES: [keyof CloudDemoScenario, string][] = [
["onboarded", "Onboarded"],
["postponed", "Onboarding postponed"],
["remote", "Remote access"],
["alexa", "Alexa linked"],
["google", "Google linked"],
["webrtc", "Cameras (WebRTC)"],
["webhooks", "Has webhooks"],
];
const CLOUD_AGENT = "cloud.cloud";
const emptyFilter = () => ({
include_domains: [],
include_entities: [],
exclude_domains: [],
exclude_entities: [],
});
const demoWebhooks: Webhook[] = [
{
webhook_id: "demo_front_door",
domain: "automation",
name: "Front door motion",
local_only: false,
},
{
webhook_id: "demo_companion_app",
domain: "mobile_app",
name: "Companion app",
local_only: false,
},
];
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
subscription: { status: scenario.account },
});
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
const active =
scenario.account !== "canceled" && scenario.account !== "expired";
const cloudhooks = scenario.webhooks
? Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
)
: {};
return {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: scenario.google,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: scenario.alexa,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
remote_connected: scenario.remote,
remote_certificate: {
common_name: "demo-instance.ui.nabu.casa",
expire_date: "2099-01-01T00:00:00+00:00",
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
alternative_names: ["demo-instance.ui.nabu.casa"],
},
remote_certificate_status: scenario.remoteStatus,
http_use_ssl: false,
active_subscription: active,
onboarding_postponed: scenario.postponed,
onboarding_completed: scenario.onboarded,
prefs: {
google_enabled: scenario.google,
alexa_enabled: scenario.alexa,
remote_enabled: scenario.remote,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: scenario.webrtc,
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
onboarding_postponed_until: scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null,
},
};
};
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
const cloudEnabled =
scenario.backup === "fresh" ||
scenario.backup === "stale" ||
scenario.backup === "failed";
let configured = true;
let lastCompleted: string | null = null;
let lastAttempted: string | null = null;
let next: string | null = null;
switch (scenario.backup) {
case "fresh":
case "local":
lastCompleted = recent;
lastAttempted = recent;
next = future;
break;
case "stale":
lastCompleted = old;
lastAttempted = old;
next = overdue;
break;
case "failed":
lastCompleted = old;
lastAttempted = recent;
next = future;
break;
case "none":
configured = false;
break;
}
return {
automatic_backups_configured: configured,
last_attempted_automatic_backup: lastAttempted,
last_completed_automatic_backup: lastCompleted,
next_automatic_backup: next,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"],
include_addons: [],
include_all_addons: true,
include_database: true,
include_folders: [],
name: null,
password: null,
},
retention: { copies: 3, days: null },
schedule: {
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
},
agents: {
"backup.local": { protected: true, retention: null },
"cloud.cloud": { protected: true, retention: null },
},
};
};
@customElement("demo-misc-cloud-account")
export class DemoMiscCloudAccount
extends LitElement
implements ProvideHassElement
{
@state() private hass!: HomeAssistant;
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
@state() private _cloudStatus!: CloudStatusLoggedIn;
@state() private _subscription!: SubscriptionInfo;
@state() private _backupConfig!: BackupConfig;
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.updateHass({
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"cloud",
"backup",
"webhook",
],
},
});
this._registerMocks(hass);
this._applyScenario();
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._showDialog);
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
// The overview and onboarding dialog contain real <a href="/config/..">
// links to panel routes that do not exist in the gallery. Keep them inert.
this.addEventListener("click", this._neutralizeNavigation);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._showDialog);
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
this.removeEventListener("click", this._neutralizeNavigation);
}
protected render() {
if (!this.hass) {
return nothing;
}
const showOnboarding =
this._cloudStatus.active_subscription &&
!this._cloudStatus.onboarding_completed &&
!this._cloudStatus.onboarding_postponed &&
!onboardingComplete(this._cloudStatus, this._backupConfig);
return html`
<div class="options">
<div class="selects">
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
${this._select("Backups", "backup", BACKUP_OPTIONS)}
</div>
<div class="switches">
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
</div>
</div>
<div class="preview">
${
showOnboarding
? html`
<cloud-account-onboarding
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.backupConfig=${this._backupConfig}
></cloud-account-onboarding>
`
: nothing
}
<cloud-account-overview
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.subscription=${this._subscription}
.backupConfig=${this._backupConfig}
.webhooks=${demoWebhooks}
></cloud-account-overview>
</div>
`;
}
private _select(
label: string,
field: keyof CloudDemoScenario,
options: { value: string; label: string }[]
) {
return html`
<ha-select
.label=${label}
.value=${String(this._scenario[field])}
.options=${options}
data-field=${field}
@selected=${this._selectChanged}
></ha-select>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
`;
}
private _selectChanged(ev: HaSelectSelectEvent) {
const field = (ev.currentTarget as HTMLElement).dataset
.field as keyof CloudDemoScenario;
this._setField(field, ev.detail.value as string);
}
private _switchChanged(ev: Event) {
const target = ev.target as HaSwitch;
this._setField(
target.dataset.field as keyof CloudDemoScenario,
target.checked
);
}
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
if (this._scenario[field] === value) {
return;
}
this._scenario = { ...this._scenario, [field]: value };
this._applyScenario();
}
private _applyScenario() {
this._cloudStatus = buildCloudStatus(this._scenario);
this._subscription = buildSubscription(this._scenario);
this._backupConfig = buildBackupConfig(this._scenario);
}
private _openOnboarding = () => {
showCloudOnboardingDialog(this, {
cloudStatus: this._cloudStatus,
backupConfig: this._backupConfig,
onChanged: () => this._refreshFromMocks(),
});
};
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
ev.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
private _refreshFromMocks = () => {
this._cloudStatus = {
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
};
this._backupConfig = { ...this._backupConfig };
const cloudBackup =
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
this._scenario = {
...this._scenario,
onboarded: this._cloudStatus.onboarding_completed,
postponed: this._cloudStatus.onboarding_postponed,
remote: this._cloudStatus.prefs.remote_enabled,
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
backup: !this._backupConfig.automatic_backups_configured
? "none"
: cloudBackup
? this._scenario.backup === "fresh" ||
this._scenario.backup === "stale" ||
this._scenario.backup === "failed"
? this._scenario.backup
: "fresh"
: "local",
};
};
private _neutralizeNavigation = (ev: MouseEvent) => {
const anchor = ev
.composedPath()
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
if (anchor?.getAttribute("href")?.startsWith("/")) {
ev.preventDefault();
}
};
private _registerMocks(hass: MockHomeAssistant) {
hass.mockWS("cloud/status", () => ({
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
}));
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
return { success: true };
});
hass.mockWS("cloud/onboarding/postpone", () => {
this._cloudStatus.onboarding_postponed = true;
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
});
hass.mockWS("cloud/remote/connect", () => {
this._cloudStatus.remote_connected = true;
this._cloudStatus.prefs.remote_enabled = true;
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
this._cloudStatus.remote_connected = false;
this._cloudStatus.prefs.remote_enabled = false;
return null;
});
hass.mockWS("backup/config/info", () => ({
config: { ...this._backupConfig },
}));
hass.mockWS("backup/config/update", (msg) => {
const { type, ...update } = msg;
if (update.create_backup) {
this._backupConfig.create_backup = {
...this._backupConfig.create_backup,
...update.create_backup,
};
}
if (update.automatic_backups_configured !== undefined) {
this._backupConfig.automatic_backups_configured =
update.automatic_backups_configured;
}
return null;
});
}
static styles = css`
.options {
max-width: 600px;
margin: 16px auto 0;
padding: 0 16px 16px;
border-bottom: 1px solid var(--divider-color);
display: flex;
flex-direction: column;
gap: 16px;
}
.selects {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.selects ha-select {
min-width: 160px;
flex: 1;
}
.switches {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.preview {
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: var(--ha-space-6, 24px);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-cloud-account": DemoMiscCloudAccount;
}
}
+6 -4
View File
@@ -91,6 +91,7 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"chart2music": "1.20.0",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.49.0",
@@ -101,6 +102,7 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.6.16",
@@ -114,7 +116,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.9",
"marked": "18.0.7",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -152,8 +154,8 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@rspack/core": "2.1.7",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -210,7 +212,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.65.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+11 -11
View File
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
return undefined;
}
let url: URL;
try {
// anchor.href is always absolute; an empty or unparseable value throws.
url = new URL(anchor.href);
} catch {
let href = anchor.href;
if (!href || href.indexOf("mailto:") !== -1) {
return undefined;
}
// Only intercept same-origin links. A different scheme, host, or port is a
// different origin (e.g. another port like ":8123") and must trigger a full
// browser navigation instead of an in-app route change. Non-http(s) schemes
// such as mailto: resolve to a null origin and are excluded here too.
if (url.origin !== window.location.origin) {
const location = window.location;
const origin = location.origin || location.protocol + "//" + location.host;
if (!href.startsWith(origin)) {
return undefined;
}
href = href.slice(origin.length);
if (href === "#") {
return undefined;
}
if (preventDefault) {
e.preventDefault();
}
return url.pathname + url.search + url.hash;
return href;
};
+231
View File
@@ -0,0 +1,231 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { EChartsType } from "echarts/core";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { ensureArray } from "../../common/array/ensure-array";
import { formatDateTime } from "../../common/datetime/format_date_time";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HaECSeries,
HaECSeriesItem,
} from "../../resources/echarts/echarts";
export interface ChartSonification {
update: () => void;
dispose: () => void;
}
// Series types the Chart2Music ECharts extension can turn into data points. Our
// other series (custom timelines, sankey, network graphs) have no equivalent, and
// the extension refuses the whole chart if a single series is unsupported.
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
// Languages shipped by chart2music. Anything else falls back to its English.
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// Mirrors the extension's own reading of a point: it drops anything whose y is
// not a real number, so a series of nothing but nulls converts to an empty group
// and makes Chart2Music throw. Short-circuits on the first usable point.
const hasNumericPoint = (data: unknown): boolean =>
Array.isArray(data) &&
data.some((raw) => {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
}
return typeof y === "number" && !Number.isNaN(y);
});
export const canSonifyChart = (data: HaECSeries): boolean => {
const series = ensureArray(data);
return (
// Cards commonly push empty placeholder series, so only require that
// something is plottable — but every type has to be convertible.
series.some((s) => hasNumericPoint(s.data)) &&
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
);
};
interface SonifyChartOptions {
cc: HTMLElement;
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
onError: (error: string) => void;
}
// Chart2Music appends its help and options dialogs straight to document.body, so
// they can only be themed from a document-level stylesheet.
let stylesAppended = false;
const appendSonificationStyles = () => {
if (stylesAppended) {
return;
}
stylesAppended = true;
const style = document.createElement("style");
style.textContent = `
dialog.chart2music-dialog {
box-sizing: border-box;
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: var(--ha-space-6);
border: none;
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
box-shadow: var(--ha-box-shadow-l);
}
dialog.chart2music-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog.chart2music-dialog h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin-block: 0 var(--ha-space-4);
padding-inline-end: var(--ha-space-8);
}
dialog.chart2music-dialog table {
border-collapse: collapse;
width: 100%;
}
dialog.chart2music-dialog th,
dialog.chart2music-dialog td {
text-align: start;
padding: var(--ha-space-1) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
dialog.chart2music-dialog a {
color: var(--primary-color);
}
dialog.chart2music-dialog > button {
/* The extension inlines "right", which does not mirror in RTL, and inline
styles can only be beaten with !important. */
inset-inline-end: var(--ha-space-4) !important;
inset-inline-start: auto !important;
top: var(--ha-space-4);
min-width: 32px;
min-height: 32px;
cursor: pointer;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
background-color: transparent;
color: var(--primary-text-color);
font: inherit;
}
`;
document.head.append(style);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
): Promise<ChartSonification | null> => {
const { connect } = await import("echarts-extension-chart2music");
const { localize, locale, config } = options;
appendSonificationStyles();
// ECharts nulls its model on dispose, and the instance can be disposed while
// the chunk is in flight.
const chartOptions = chart.getOption() as ReturnType<
EChartsType["getOption"]
> | null;
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
// the series carrying at least one point it can read.
const seriesIndex: number[] = [];
ensureArray(chartOptions.series).forEach((s, index) => {
if (hasNumericPoint((s as HaECSeriesItem | undefined)?.data)) {
seriesIndex.push(index);
}
});
if (!seriesIndex.length) {
return null;
}
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
const isTimeAxis = xAxis?.type === "time";
const x = {
label:
xAxis?.name ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
? (value: number) => formatDateTime(new Date(value), locale, config)
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
try {
connection = connect(chart, {
cc: options.cc,
seriesIndex,
title: localize("ui.components.history_charts.chart"),
lang: SONIFICATION_LANGUAGES.has(locale.language)
? locale.language
: "en",
errorCallback: options.onError,
axes: { x, y },
});
} catch (err: any) {
options.onError(err?.message ?? String(err));
return null;
}
if (!connection) {
return null;
}
// Chart2Music bails out silently on mobile user agents, returning an instance
// that never wired anything up. Turning the caption container into a live
// region is the last thing it does, so use that as the "really connected" test
// rather than leaving a focus stop that does nothing.
if (!options.cc.hasAttribute("aria-live")) {
connection.dispose();
return null;
}
const connected = connection;
// The extension re-reads the chart from ECharts' own "finished" event. Run that
// through a guard of our own so a conversion failure cannot escape into
// ECharts' event dispatch and leave the chart half-rendered.
const update = () => {
try {
connected.update();
} catch (_err) {
// Keep whatever Chart2Music last read successfully.
}
};
chart.off("finished", connected.update);
chart.on("finished", update);
return {
update,
dispose: () => {
chart.off("finished", update);
connected.dispose();
},
};
};
+118 -1
View File
@@ -22,6 +22,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -146,6 +149,17 @@ export class HaChartBase extends LitElement {
@query(".chart") private _chartContainer?: HTMLDivElement;
@query(".sonification-output")
private _sonificationOutput?: HTMLDivElement;
private _sonification?: ChartSonification;
private _sonificationLoading = false;
@state() private _sonificationUnavailable = false;
@state() private _sonificationFocusHeld = false;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -198,6 +212,7 @@ export class HaChartBase extends LitElement {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._disposeSonification();
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
@@ -312,6 +327,17 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
// last set was not.
this._sonificationUnavailable = false;
// The connection is built from the series that had data at the time, so
// drop it and let the next focus rebuild it against the current set.
if (
this._sonification &&
(changedProps.has("_hiddenDatasets") || !canSonifyChart(this.data))
) {
this._disposeSonification();
}
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -337,6 +363,8 @@ export class HaChartBase extends LitElement {
}
protected render() {
const sonifiable =
!this._sonificationUnavailable && canSonifyChart(this.data);
return html`
<div
class="container ${classMap({ "has-height": !!this.height })}"
@@ -348,8 +376,22 @@ export class HaChartBase extends LitElement {
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
<div
class="chart"
role=${ifDefined(sonifiable ? "application" : undefined)}
tabindex=${ifDefined(
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
)}
aria-label=${ifDefined(
sonifiable
? this.hass.localize("ui.components.history_charts.chart")
: undefined
)}
@focus=${this._handleChartFocus}
@blur=${this._handleChartBlur}
></div>
</div>
<div class="sonification-output"></div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
@@ -521,6 +563,61 @@ export class HaChartBase extends LitElement {
</div>`;
}
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
// moves keyboard focus into a chart.
private async _handleChartFocus() {
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
try {
const sonification = await sonifyChart(this.chart, {
cc: this._sonificationOutput!,
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
},
});
if (!this.isConnected || !this.chart) {
sonification?.dispose();
return;
}
if (!sonification) {
// Nothing came back, so stop offering a focus stop that leads nowhere.
// Stay programmatically focusable for as long as we hold focus though:
// dropping tabindex off the active element resets focus to the document
// and costs the user their place in the tab order.
this._sonificationFocusHeld =
this.shadowRoot?.activeElement === this._chartContainer;
this._sonificationUnavailable = true;
return;
}
this._sonification = sonification;
if (this.shadowRoot?.activeElement === this._chartContainer) {
// Chart2Music reads its summary and key hints on focus, which already
// happened while it was still being fetched.
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
}
} catch (_err) {
// Never let a failure here escape a focus handler. The tab stop stays, so
// focusing the chart again retries.
} finally {
this._sonificationLoading = false;
}
}
private _handleChartBlur() {
this._sonificationFocusHeld = false;
}
private _disposeSonification() {
this._sonification?.dispose();
this._sonification = undefined;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
@@ -533,6 +630,9 @@ export class HaChartBase extends LitElement {
if (this._loading) return;
this._loading = true;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
}
@@ -1450,6 +1550,23 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
/* Chart2Music renders its announcements here. It must stay in the layout for
screen readers to pick up the live region, so hide it visually only. */
.sonification-output {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
+1 -2
View File
@@ -56,7 +56,6 @@ export class HaSelectBox extends LitElement {
class="list"
style=${styleMap({ "--columns": columns })}
.value=${this.value}
?disabled=${this.disabled}
@change=${this._radioChanged}
>
${this.options.map((option) => this._renderOption(option))}
@@ -101,7 +100,7 @@ export class HaSelectBox extends LitElement {
)}
aria-labelledby=${`label-${option.value}`}
.value=${option.value}
.disabled=${option.disabled || false}
.disabled=${disabled}
></ha-radio-option>
<div class="text">
<span id=${`label-${option.value}`} class="label"
@@ -53,6 +53,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
value: behavior,
label: this._localizeOption(behavior, "label"),
description: this._localizeOption(behavior, "description"),
disabled: this.disabled,
...(isTrigger && {
image: {
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
@@ -65,7 +66,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value ?? ""}
.disabled=${this.disabled}
max_columns="1"
?stacked_image=${isTrigger}
@value-changed=${this._valueChanged}
@@ -104,7 +104,6 @@ export class HaSelectSelector extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value as string | undefined}
.disabled=${this.disabled}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
></ha-select-box>
-73
View File
@@ -1,11 +1,7 @@
import type {
HassEntity,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { stateActive } from "../common/entity/state_active";
import { navigate } from "../common/navigate";
import type { HomeAssistant, ServiceCallResponse } from "../types";
@@ -70,75 +66,6 @@ export type SceneMetaData = Record<
{ entity_only?: boolean | undefined }
>;
// Hand-edited scenes.yaml is parsed as YAML 1.1 by the backend, so unquoted
// on/off arrive here as booleans. The backend applies boolean states as
// on/off; mirror that so the badge reflects what activating the scene will
// actually do. Everything else the backend rejects (numbers, null, arrays,
// objects) yields undefined.
const normalizeSceneEntityState = (sceneState: unknown): string | undefined => {
if (typeof sceneState === "boolean") {
return sceneState ? "on" : "off";
}
if (typeof sceneState === "string") {
return sceneState;
}
return undefined;
};
// Builds a state object from the scene's stored target state, so the scene
// editor can render the icon the entity will have once the scene is applied
// rather than its current live icon. The parameter is typed unknown because
// the scene config API returns raw YAML: booleans, numbers, nulls, and
// malformed shapes occur in hand-edited files beyond what the SceneEntities
// union declares. Entries that hold no usable target state (an entity left
// without a value in the YAML editor parses as null, a dict may lack a state
// key) yield undefined so the caller renders no badge instead of guessing.
//
// The result is a partial HassEntity meant for badge rendering only - it has
// no last_changed, last_updated, or context.
export const sceneEntityStateObj = (
entityId: string,
sceneEntity: unknown
): HassEntity | undefined => {
if (
typeof sceneEntity !== "object" ||
sceneEntity === null ||
Array.isArray(sceneEntity)
) {
const state = normalizeSceneEntityState(sceneEntity);
return state === undefined
? undefined
: ({ entity_id: entityId, state, attributes: {} } as HassEntity);
}
const { state: sceneState, ...attributes } = sceneEntity as Record<
string,
unknown
>;
const state = normalizeSceneEntityState(sceneState);
if (state === undefined) {
return undefined;
}
// Media-derived entity pictures are snapshotted with an access token that
// is stale by the time review mode renders, which would leave the badge
// showing a broken image instead of an icon. Stable pictures on other
// domains are kept - the same policy createHistoricState applies for the
// logbook.
if (DOMAINS_WITH_DYNAMIC_PICTURE.has(computeDomain(entityId))) {
delete attributes.entity_picture;
delete attributes.entity_picture_local;
}
const stateObj = { entity_id: entityId, state, attributes } as HassEntity;
// A live entity never carries color attributes while off, and state-badge
// applies rgb_color and brightness without checking activity; drop them for
// inactive targets so a scene that turns a light off does not render an
// active-looking colored icon.
if (!stateActive(stateObj)) {
delete attributes.rgb_color;
delete attributes.brightness;
}
return stateObj;
};
export const activateScene = (
hass: HomeAssistant,
entityId: string
+13 -26
View File
@@ -102,12 +102,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkDataBaseMigration();
}
// Wait for `hass.user` to first populate so the admin guard can run; it
// arrives asynchronously after `hass.config`. `hass.user` also gets a fresh
// reference at runtime (reconnect, profile refresh via subscribeUser), so
// only trigger on the initial population (null -> user). Reconnect re-checks
// come from connection-mixin, the launch-screen swap re-check from update().
if (changedProps.has("hass") && this.hass?.user && !oldHass?.user) {
// Wait for `hass.user` to populate so the admin guard can run; it arrives
// asynchronously after `hass.config`.
if (
changedProps.has("hass") &&
this.hass?.user &&
oldHass?.user !== this.hass.user
) {
this.checkHttpPendingConfig();
}
if (
@@ -124,12 +125,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected update(changedProps: PropertyValues<this>) {
const removingLaunchScreen =
!!this.hass?.states &&
!!this.hass.config &&
!!this.hass.services &&
this._databaseMigration === false;
if (removingLaunchScreen) {
if (
this.hass?.states &&
this.hass.config &&
this.hass.services &&
this._databaseMigration === false
) {
this.render = this.renderHass;
this.update = super.update;
// partial-panel-resolver removes the launch screen after the first panel
@@ -137,13 +138,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
if (removingLaunchScreen) {
// Surface the HTTP pending config dialog only after super.update() has
// committed the render swap above, which clears the launch screen from
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -262,13 +256,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (__DEMO__ || this._httpPendingDialogOpen) {
return;
}
// Only show once the main UI is rendered. During startup the root swaps
// the launch screen for the app, which clears its shadow root and would
// tear the freshly-appended dialog straight back out of the DOM (closing
// it). When called too early we skip; the swap in update() re-runs this.
if (this.render !== this.renderHass) {
return;
}
if (!this.hass?.user?.is_admin) {
return;
}
@@ -128,12 +128,8 @@ class DialogAutomationSave
`;
}
private get _isDiscardDialog(): boolean {
return this._params?.onDiscard !== undefined;
}
protected _renderDiscard() {
if (!this._isDiscardDialog) {
if (!this._params?.onDiscard) {
return nothing;
}
return html`
@@ -328,14 +324,10 @@ class DialogAutomationSave
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${
!!this._params.config.alias &&
!this._isDiscardDialog &&
!this.isDirtyState
}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
>
${this.hass.localize(
this._params.config.alias && !this._isDiscardDialog
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
)}
@@ -462,7 +462,7 @@ class HaConfigHttpForm extends LitElement {
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} target="_blank" rel="noreferrer noopener">${url}</a>
<a href=${url} rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
+14 -48
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistEdit,
mdiTag,
} from "@mdi/js";
import type { HassEntity, HassEvent } from "home-assistant-js-websocket";
import type { HassEvent } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -67,7 +67,6 @@ import {
getSceneEditorInitData,
saveScene,
SCENE_IGNORED_DOMAINS,
sceneEntityStateObj,
showSceneEditor,
} from "../../../data/scene";
import {
@@ -448,15 +447,13 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
const integrationName = platform
? domainToName(this.hass.localize, platform)
: undefined;
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
hasMeta
?twoline=${!!secondary}
graphic="icon"
.graphic=${
this._mode === "live" ? "icon" : undefined
}
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -466,10 +463,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
badgeStateObj
this._mode === "live"
? html`
<state-badge
.stateObj=${badgeStateObj}
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>
`
@@ -555,16 +552,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this.hass.localize,
computeDomain(entityId)
);
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
class="entity"
hasMeta
?twoline=${!!secondary}
graphic="icon"
.graphic=${
this._mode === "live" ? "icon" : undefined
}
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -574,13 +569,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
badgeStateObj
? html`
<state-badge
.stateObj=${badgeStateObj}
slot="graphic"
></state-badge>
`
this._mode === "live"
? html` <state-badge
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>`
: nothing
}
${primary}
@@ -1195,33 +1188,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return { ...stateObj.attributes, state: stateObj.state };
}
// Memoized per config so re-renders reuse the same object references and
// the state badges skip work when nothing changed.
private _sceneStateObjs = memoizeOne((config?: SceneConfig) => {
const objs: Record<string, HassEntity | undefined> = {};
for (const entityId of Object.keys(config?.entities ?? {})) {
objs[entityId] = sceneEntityStateObj(
entityId,
config!.entities[entityId]
);
}
return objs;
});
// Picks the state the row's icon should reflect: the live state in live
// mode, the scene's stored target in review mode. Undefined in review mode
// when the scene holds no usable target for the entity - the row then
// renders no badge rather than a live state that could be mistaken for a
// target.
private _badgeStateObj(
entityId: string,
entityStateObj: HassEntity
): HassEntity | undefined {
return this._mode === "live"
? entityStateObj
: this._sceneStateObjs(this._config)[entityId];
}
private _generateConfigFromLive() {
this._config = {
...this._config!,
+7 -3
View File
@@ -1129,7 +1129,11 @@
"zoom_reset": "Reset zoom",
"expand_legend": "More",
"collapse_legend": "Less",
"toggle_visibility": "Toggle visibility"
"toggle_visibility": "Toggle visibility",
"chart": "Chart",
"time": "Time",
"value": "Value",
"category": "Category"
},
"map": {
"error": "Unable to load map"
@@ -3854,7 +3858,7 @@
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."
"description": "Disable animated view transitions to prevent browser crash when using browser dev tools."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
@@ -3899,7 +3903,7 @@
},
"actions": {
"title": "Actions",
"description": "The actions tool allows you to perform any action available in Home Assistant.",
"description": "The actions dev tool allows you to perform any action available in Home Assistant.",
"call_service": "Perform action",
"response": "Response",
"column_parameter": "Parameter",
-136
View File
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { isNavigationClick } from "../../../src/common/dom/is-navigation-click";
const createAnchor = (
href: string,
attributes: Record<string, string> = {}
): HTMLAnchorElement => {
const anchor = document.createElement("a");
anchor.href = href;
for (const [name, value] of Object.entries(attributes)) {
anchor.setAttribute(name, value);
}
return anchor;
};
const clickEvent = (
anchor: HTMLAnchorElement,
overrides: Partial<MouseEvent> = {}
): MouseEvent =>
({
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
composedPath: () => [anchor],
preventDefault: vi.fn(),
...overrides,
}) as unknown as MouseEvent;
describe("isNavigationClick", () => {
it("returns the path for same-origin links", () => {
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}/config`)))
).toEqual("/config");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/energy?historyBack=1`))
)
).toEqual("/energy?historyBack=1");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config/areas#section`))
)
).toEqual("/config/areas#section");
});
it("does not intercept a link on a different port", () => {
// The current origin has no port, so a ported URL shares its string prefix
// but is a different origin — it must not be treated as internal.
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}:8123/`)))
).toBeUndefined();
});
it("does not intercept a host that merely starts with the origin", () => {
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}.example.com/`))
)
).toBeUndefined();
});
it("does not intercept other origins", () => {
expect(
isNavigationClick(clickEvent(createAnchor("https://example.com/")))
).toBeUndefined();
});
it("calls preventDefault for intercepted navigations", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
isNavigationClick(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it("does not preventDefault when disabled", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
expect(isNavigationClick(event, false)).toEqual("/config");
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("ignores clicks with a target, download, or rel=external", () => {
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { target: "_blank" })
)
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config`, { download: "" }))
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { rel: "external" })
)
)
).toBeUndefined();
});
it("ignores modified clicks and non-left buttons", () => {
const anchor = createAnchor(`${location.origin}/config`);
expect(
isNavigationClick(clickEvent(anchor, { button: 1 }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { metaKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { ctrlKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { shiftKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { defaultPrevented: true }))
).toBeUndefined();
});
it("ignores mailto links and clicks without an anchor", () => {
expect(
isNavigationClick(clickEvent(createAnchor("mailto:[email protected]")))
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(undefined as any, {
composedPath: () => [document.createElement("div")],
})
)
).toBeUndefined();
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
const series = (items: { type: string; data?: unknown[] }[]) =>
items as unknown as HaECSeries;
describe("canSonifyChart", () => {
it("accepts line and bar series that carry data", () => {
expect(canSonifyChart(series([{ type: "line", data: [[0, 1]] }]))).toBe(
true
);
expect(canSonifyChart(series([{ type: "bar", data: [[0, 1]] }]))).toBe(
true
);
});
it("accepts a chart whose empty placeholder series sits beside real data", () => {
expect(
canSonifyChart(
series([
{ type: "bar", data: [] },
{ type: "bar", data: [[0, 1]] },
])
)
).toBe(true);
});
it("rejects series types the extension cannot convert", () => {
expect(canSonifyChart(series([{ type: "custom", data: [[0, 1]] }]))).toBe(
false
);
expect(
canSonifyChart(
series([
{ type: "line", data: [[0, 1]] },
{ type: "sankey", data: [[0, 1]] },
])
)
).toBe(false);
});
it("rejects charts with nothing plotted", () => {
expect(canSonifyChart(series([]))).toBe(false);
expect(canSonifyChart(series([{ type: "line", data: [] }]))).toBe(false);
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("rejects a series whose points are all gaps", () => {
// Chart2Music drops any point without a numeric y, so a series of nothing
// but nulls converts to an empty group and makes it throw.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, null],
],
},
])
)
).toBe(false);
});
it("accepts a series that only becomes numeric partway through", () => {
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, 21.5],
],
},
])
)
).toBe(true);
});
it("reads the y out of object-form points", () => {
expect(
canSonifyChart(series([{ type: "bar", data: [{ value: [0, 0.28] }] }]))
).toBe(true);
expect(
canSonifyChart(series([{ type: "bar", data: [{ value: [0, null] }] }]))
).toBe(false);
});
});
-116
View File
@@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import { sceneEntityStateObj } from "../../src/data/scene";
describe("sceneEntityStateObj", () => {
it("builds a state object from string shorthand", () => {
expect(sceneEntityStateObj("light.kitchen", "on")).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("builds a state object from the dict form", () => {
expect(
sceneEntityStateObj("light.kitchen", {
state: "on",
brightness: 180,
rgb_color: [255, 64, 112],
})
).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: { brightness: 180, rgb_color: [255, 64, 112] },
});
});
it("strips media-derived entity pictures but keeps other attributes", () => {
const sceneEntity = {
state: "playing",
entity_picture: "/api/media_player_proxy/x?token=stale",
entity_picture_local: "/local/x.jpg",
friendly_name: "Player",
};
expect(sceneEntityStateObj("media_player.x", sceneEntity)).toEqual({
entity_id: "media_player.x",
state: "playing",
attributes: { friendly_name: "Player" },
});
// The input scene config must not be mutated.
expect(sceneEntity.entity_picture).toBe(
"/api/media_player_proxy/x?token=stale"
);
});
it("keeps a stable entity picture on other domains", () => {
expect(
sceneEntityStateObj("vacuum.robot", {
state: "cleaning",
entity_picture: "/local/robot.png",
})?.attributes.entity_picture
).toBe("/local/robot.png");
});
it("returns undefined for null and undefined values", () => {
// An entity left without a value in the YAML editor parses as null.
expect(sceneEntityStateObj("light.kitchen", null)).toBeUndefined();
expect(sceneEntityStateObj("light.kitchen", undefined)).toBeUndefined();
});
it("returns undefined for an array value", () => {
expect(sceneEntityStateObj("light.kitchen", [255, 100])).toBeUndefined();
});
it("returns undefined for a numeric value", () => {
// The backend only accepts string and boolean states, so a number never
// becomes a target the scene can apply.
expect(sceneEntityStateObj("input_number.x", 23)).toBeUndefined();
expect(
sceneEntityStateObj("input_number.x", { state: 23 })
).toBeUndefined();
});
it("returns undefined when the dict holds no usable state", () => {
expect(
sceneEntityStateObj("light.kitchen", { brightness: 100 })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: null })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: { brightness: 100 } })
).toBeUndefined();
});
it("normalizes boolean shorthand to on/off like the backend does", () => {
// YAML 1.1 parses unquoted on/off in scenes.yaml as booleans.
expect(sceneEntityStateObj("light.kitchen", true)?.state).toBe("on");
expect(sceneEntityStateObj("light.kitchen", false)?.state).toBe("off");
});
it("normalizes a boolean state in the dict form", () => {
expect(sceneEntityStateObj("light.kitchen", { state: true })).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("drops color attributes for an inactive target", () => {
// A live entity never carries these while off, and state-badge applies
// them without checking activity.
expect(
sceneEntityStateObj("light.kitchen", {
state: "off",
brightness: 180,
rgb_color: [255, 0, 0],
friendly_name: "Kitchen",
})
).toEqual({
entity_id: "light.kitchen",
state: "off",
attributes: { friendly_name: "Kitchen" },
});
});
});
+256 -162
View File
@@ -2561,13 +2561,13 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/core@npm:1.11.3":
version: 1.11.3
resolution: "@emnapi/core@npm:1.11.3"
"@emnapi/core@npm:1.11.2":
version: 1.11.2
resolution: "@emnapi/core@npm:1.11.2"
dependencies:
"@emnapi/wasi-threads": "npm:1.2.3"
"@emnapi/wasi-threads": "npm:1.2.2"
tslib: "npm:^2.4.0"
checksum: 10/6ed871d5bbd0f6e25a09fe91583e886e4b90ae9344fa9292c78f0891b0b2a7d84899ea65a7d576450b9ac50440c698dad1907907e3d686ec9460ed876a9363a2
checksum: 10/b3e9693f79ca1d8bb90cb8a950150c7738f54eca0ae1849d3d5103a87ce4e388c2669fb253cc123d3449ad2ae12583435455dbdc0270b1e9efb0cfd502c952b4
languageName: node
linkType: hard
@@ -2589,12 +2589,12 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/runtime@npm:1.11.3":
version: 1.11.3
resolution: "@emnapi/runtime@npm:1.11.3"
"@emnapi/runtime@npm:1.11.2":
version: 1.11.2
resolution: "@emnapi/runtime@npm:1.11.2"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/ec834cc0fe248e06dd84f6dee10162133f8a692636189e99aa992303c791d4be1679536ee91aeee6661c4478609768cee9c085acd858caa92784c67acaab44a5
checksum: 10/280a219d3bf302615dabaaa248223b0e5fe9c49bacf0987dd958ca37ab425b62cf05287053cb188e711681418aaa5e447eaed6b62a0848c0a1303b2707864600
languageName: node
linkType: hard
@@ -2616,15 +2616,6 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/wasi-threads@npm:1.2.3":
version: 1.2.3
resolution: "@emnapi/wasi-threads@npm:1.2.3"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/d9fef5c321a6df1988e813e0b621ed1e01d274c23d3028ff9511af97f8935adc921ae60a35167ccf4e0414fecc040b66b9b13458e7c00cfaad622457e1b03529
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1":
version: 4.9.1
resolution: "@eslint-community/eslint-utils@npm:4.9.1"
@@ -2765,6 +2756,27 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:2.3.4":
version: 2.3.4
resolution: "@formatjs/ecma402-abstract@npm:2.3.4"
dependencies:
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/intl-localematcher": "npm:0.6.1"
decimal.js: "npm:^10.4.3"
tslib: "npm:^2.8.0"
checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:2.2.7":
version: 2.2.7
resolution: "@formatjs/fast-memoize@npm:2.2.7"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/e7e6efc677d63a13d99a854305db471b69f64cbfebdcb6dbe507dab9aa7eaae482ca5de86f343c856ca0a2c8f251672bd1f37c572ce14af602c0287378097d43
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.7":
version: 3.1.7
resolution: "@formatjs/fast-memoize@npm:3.1.7"
@@ -2772,6 +2784,17 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:2.11.2":
version: 2.11.2
resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/icu-skeleton-parser": "npm:1.8.14"
tslib: "npm:^2.8.0"
checksum: 10/e919eb2a132ac1d54fb1a7e3a3254007649b55196d3818090df92a4268dcddf20cbdf863c06039fbbe7a35a8a3f17bdc172dade99d1f17c1d8a95dcec444c3e3
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.16":
version: 3.5.16
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
@@ -2781,6 +2804,16 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:1.8.14":
version: 1.8.14
resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
tslib: "npm:^2.8.0"
checksum: 10/2fbe3155c310358820b118d8c9844f314eff3500a82f1c65402434a3095823e1afeaab8d1762b4a59cc5679d82dc4c8c134683565d7cdae4daace23251f46a47
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.11":
version: 2.1.11
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
@@ -2843,6 +2876,15 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.6.1":
version: 0.6.1
resolution: "@formatjs/intl-localematcher@npm:0.6.1"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.13":
version: 0.8.13
resolution: "@formatjs/intl-localematcher@npm:0.8.13"
@@ -2890,6 +2932,24 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/intl@npm:3.1.6"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
intl-messageformat: "npm:10.7.16"
tslib: "npm:^2.8.0"
peerDependencies:
typescript: ^5.6.0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/10ebdce088898ad7de59c10890f7c02fa9f5aa50518ed3fae7e0ae1c392f3973da00464d2a42229cce97916c2eff1e41630e95c18bab01713d2b6ef5c7fd80c1
languageName: node
linkType: hard
"@fullcalendar/core@npm:6.1.21":
version: 6.1.21
resolution: "@fullcalendar/core@npm:6.1.21"
@@ -4765,110 +4825,110 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-arm64@npm:2.1.8"
"@rspack/binding-darwin-arm64@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-darwin-arm64@npm:2.1.7"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-x64@npm:2.1.8"
"@rspack/binding-darwin-x64@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-darwin-x64@npm:2.1.7"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.8"
"@rspack/binding-linux-arm64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.7"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.8"
"@rspack/binding-linux-arm64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.7"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.8"
"@rspack/binding-linux-riscv64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.7"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.8"
"@rspack/binding-linux-riscv64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.7"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.8"
"@rspack/binding-linux-x64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.7"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.8"
"@rspack/binding-linux-x64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.7"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.8"
"@rspack/binding-wasm32-wasi@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.7"
dependencies:
"@emnapi/core": "npm:1.11.3"
"@emnapi/runtime": "npm:1.11.3"
"@emnapi/core": "npm:1.11.2"
"@emnapi/runtime": "npm:1.11.2"
"@napi-rs/wasm-runtime": "npm:1.1.6"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.8"
"@rspack/binding-win32-arm64-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.7"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.8"
"@rspack/binding-win32-ia32-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.7"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.8"
"@rspack/binding-win32-x64-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.7"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding@npm:2.1.8"
"@rspack/binding@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding@npm:2.1.7"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.1.8"
"@rspack/binding-darwin-x64": "npm:2.1.8"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.8"
"@rspack/binding-linux-arm64-musl": "npm:2.1.8"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.8"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.8"
"@rspack/binding-linux-x64-gnu": "npm:2.1.8"
"@rspack/binding-linux-x64-musl": "npm:2.1.8"
"@rspack/binding-wasm32-wasi": "npm:2.1.8"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.8"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.8"
"@rspack/binding-win32-x64-msvc": "npm:2.1.8"
"@rspack/binding-darwin-arm64": "npm:2.1.7"
"@rspack/binding-darwin-x64": "npm:2.1.7"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.7"
"@rspack/binding-linux-arm64-musl": "npm:2.1.7"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.7"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.7"
"@rspack/binding-linux-x64-gnu": "npm:2.1.7"
"@rspack/binding-linux-x64-musl": "npm:2.1.7"
"@rspack/binding-wasm32-wasi": "npm:2.1.7"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.7"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.7"
"@rspack/binding-win32-x64-msvc": "npm:2.1.7"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4894,15 +4954,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/74c73ac543059fa62365557ca949ef7a84e1cebc42fe51a343126c875d90f557b35a52b33bacd9498d9231e8fdb8ab13907988e1fd39131ffbd56becb4260785
checksum: 10/1400d8553d2122850fc43a45b69828bcdcbccf7715381a627802e160da6f93e90ca41d0280a69d175894bc29a428a374c80157b874883363b998090dc0aff33c
languageName: node
linkType: hard
"@rspack/core@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/core@npm:2.1.8"
"@rspack/core@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/core@npm:2.1.7"
dependencies:
"@rspack/binding": "npm:2.1.8"
"@rspack/binding": "npm:2.1.7"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -4911,7 +4971,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/c818257225a24feb22af81eb3ed7d02d4b74d6e6cb70689c8cbcd34e3facfcfe833dfe4078e0a35277e926f431cae9fd7f2101c3944b396a72baa7cf3ce99d19
checksum: 10/84526df889fa2813cfa7dfbed71b713c47c2010b1f55da26db698a87ea11f3f433a931ec29616ff300cb63d0ea27cbe7d2b40df64abf45f9b1a484b59c94ca1a
languageName: node
linkType: hard
@@ -4927,9 +4987,9 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-server@npm:2.2.0":
version: 2.2.0
resolution: "@rspack/dev-server@npm:2.2.0"
"@rspack/dev-server@npm:2.1.0":
version: 2.1.0
resolution: "@rspack/dev-server@npm:2.1.0"
dependencies:
"@rspack/dev-middleware": "npm:^2.0.3"
peerDependencies:
@@ -4938,7 +4998,7 @@ __metadata:
peerDependenciesMeta:
selfsigned:
optional: true
checksum: 10/edfc57223fad64c43c96be95b0b6650194ac2e02d929bef649d524faa9c618233afe8f970ac2e21c77e1b92f6c65dac20f06817e73a9bd661f43c73057ae6b6b
checksum: 10/402d4f96c60beaba354081a36b053c3cf7c1dda7fddab791031dc4206b9873b98437895d5a6a68247e07cb8a5922a1770143c560772dfdaa00ba90a5396251d8
languageName: node
linkType: hard
@@ -5771,105 +5831,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.66.0"
"@typescript-eslint/eslint-plugin@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.65.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/type-utils": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/type-utils": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.66.0
"@typescript-eslint/parser": ^8.65.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/bb144ff0c27592b6a53d964fe1e2145dd0737f5fe50fe7dcd88217ca5ac4e470d7965d97745a63de16252ac2880d00732a472b9cec14d7d6929ebda97e7bcbb1
checksum: 10/20b1e5fd0c01d450c345750582e4911affef8ba934b2b78953ff593102d2a01f21c5f6469e13239fdd6a0e30152d6122906e7623f53aa8c937c6faae9407be47
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/parser@npm:8.66.0"
"@typescript-eslint/parser@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/parser@npm:8.65.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a0451abe17d2eeff6ff2e1bf637d5ea19b400378d98f4511672e0f30dc5a7971d03352e32764927176c3632f245d5f85e8b28a0a3e66d020effcf0c61624e9b6
checksum: 10/55f68666953c02c8adae35a46076848da6456181b1849a28ec836a5866ca37b902f475fb4c32ba7aa3a6a7e5d66828df8859edec297da09181fb937aeea30f8e
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/project-service@npm:8.66.0"
"@typescript-eslint/project-service@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/project-service@npm:8.65.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.66.0"
"@typescript-eslint/types": "npm:^8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.65.0"
"@typescript-eslint/types": "npm:^8.65.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e8a71f69ee7f4bd9ca1c6a0a5110361b9927d17df2f41cdf6d042203b49aabfbddece79dec7882fde8dc016b6278671a2ba3f7bf08e5ae241582627b68be3a8b
checksum: 10/915662449a66d90f03661a805f7c62a5efaa8f7887671272e08b684b41edab51a9021f47aac4d78001a0f87960e8587adc037424d56f13de883e0ce699e7ca55
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/scope-manager@npm:8.66.0"
"@typescript-eslint/scope-manager@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/scope-manager@npm:8.65.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
checksum: 10/f2024cd2819dc3b967f32089ea1a0b9099acabf12860699432f0ba802d75b01ed35ea488a50576cae544813d5cbe9bd9ee5815bc4f11cefc89fe2fb24559238f
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
checksum: 10/038e208c907aa45fe5bb7168e1dccf89c1fc6678d1715a9c04f4b332d4e79ab719b5b43a4e0c2d5a183ea863813ff59fda040b2717fe5517468453af6b99a50a
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.66.0, @typescript-eslint/tsconfig-utils@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.66.0"
"@typescript-eslint/tsconfig-utils@npm:8.65.0, @typescript-eslint/tsconfig-utils@npm:^8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.65.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/271b28660edc9e6272ae07a91f8da2c69e8a6b009e8588f1009c485c11fe4e66e09356d50dbeabf8e6a44c95c8adeb630f1b6089bdd68aec2512d0036cef7704
checksum: 10/f88253a4df1d599a1bebeeb403611538485e22c52213f2f2b9c435bde8ebce64645d6bb985c900b01ef221032835fcd4f6fea4b94d178220c18de5d93af905c4
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/type-utils@npm:8.66.0"
"@typescript-eslint/type-utils@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/type-utils@npm:8.65.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6d3c55a591b96cbac4ac845ad1d281bac54df12a631ca550370d266aed30760fa9d498e44b938e454941930e3decda820c6e544e0748254c5089425d120e1ed1
checksum: 10/d52e0c341c9731d8f3bfd2f475bbcd5a5632434e11fc39e8e21acb706145bedb8c6c1998b8ced73e132b43179dd95f2c318effc36c9a1dd61f14546b07b25852
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.66.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/types@npm:8.66.0"
checksum: 10/de1ea79e7056c11e38e4001a899b88511793a0aa1702508af42ee179bc7303bed2eb02f177a7ae438f20eab00aee9407c7e842bec6fc5b7bb1fdcab4a7cd5c4b
"@typescript-eslint/types@npm:8.65.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/types@npm:8.65.0"
checksum: 10/a6fc10a733adbb98bbb9c312c8d99791e1a2fd3efef5c2a5b51da1c166aac3db4427c30bf32059808abe305a289f820bf610683914e897baeb18315af6a1d16c
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/typescript-estree@npm:8.66.0"
"@typescript-eslint/typescript-estree@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/typescript-estree@npm:8.65.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/project-service": "npm:8.65.0"
"@typescript-eslint/tsconfig-utils": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5877,32 +5937,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/0834efcdd4468c0dd954089daf26b40175e204402bd4075b06cd910786bb6de7a15aec4e79e40f0980f39fce5bfd9c871ec8fcc6871a8895a34a273bb187fd26
checksum: 10/711fdb5eff67ff34437c56a1a661b16a2e1d6bcd96c9f96196c4242b8d4ddaea8e835ca8badd340c703e043d8dfe8cfca90b32eca60069a4f1d2880afdb670fb
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/utils@npm:8.66.0"
"@typescript-eslint/utils@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/utils@npm:8.65.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e7a24ee0f35d58b4392daa03fb1ea37ab7e4519949a034be89151df1b0e9fc416831f50cb69f54150272e252cba350964632ee9bfbdb7e62ac599cc731de56e1
checksum: 10/c1dcd555b58aef1e066164978335e521809acac36b56bd6a6dae62cffae80f3ea5f43527506be76dfef0fe3d4c8382a24355a28867c4904b0a7729691ba45656
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/visitor-keys@npm:8.66.0"
"@typescript-eslint/visitor-keys@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/visitor-keys@npm:8.65.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.65.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/d47e936b10ec94a96f69f77a62463f4634fe72d12d42dd302d7777f7b98689468e0b67c538a52232b723695208f1b3fdeae5745eb3e71a70a90818fdd827a0bc
checksum: 10/e7f86d21f0bf03ca7cff9fa9428aab98620564d15fb06c9f56a294c13cee324624d42f9115f3d76fbad92266220479cca334b5c66562f885da5c4d2bda3d87b3
languageName: node
linkType: hard
@@ -7501,6 +7561,15 @@ __metadata:
languageName: node
linkType: hard
"chart2music@npm:1.20.0, chart2music@npm:^1.20.0":
version: 1.20.0
resolution: "chart2music@npm:1.20.0"
dependencies:
"@formatjs/intl": "npm:3.1.6"
checksum: 10/ed421708740e3644a72356c9f313d3bcb7cac26d94a7fb209fa4bc8f9bc24061ed63f98da89277684e71f9402b5414cd45bb60f01c01e576803ba3dca755f4ba
languageName: node
linkType: hard
"chokidar@npm:^3.5.3":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
@@ -8098,7 +8167,7 @@ __metadata:
languageName: node
linkType: hard
"decimal.js@npm:^10.6.0":
"decimal.js@npm:^10.4.3, decimal.js@npm:^10.6.0":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69
@@ -8364,6 +8433,17 @@ __metadata:
languageName: node
linkType: hard
"echarts-extension-chart2music@npm:0.1.0":
version: 0.1.0
resolution: "echarts-extension-chart2music@npm:0.1.0"
dependencies:
chart2music: "npm:^1.20.0"
peerDependencies:
echarts: ">=5.0.0 <7"
checksum: 10/55a82c770355228b2a899007e488e5b60171cbaf5d83363a298c4214d9e4a5df1afd1c7805495f42f2ad6edd7c062c8b366f1f8ba7b1a902f2db7eb47fbfcccf
languageName: node
linkType: hard
"echarts@npm:6.1.0":
version: 6.1.0
resolution: "echarts@npm:6.1.0"
@@ -9936,8 +10016,8 @@ __metadata:
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.8"
"@rspack/dev-server": "npm:2.2.0"
"@rspack/core": "npm:2.1.7"
"@rspack/dev-server": "npm:2.1.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.3.2"
@@ -9967,6 +10047,7 @@ __metadata:
barcode-detector: "npm:3.2.1"
browserslist-useragent-regexp: "npm:4.1.4"
cally: "npm:0.9.2"
chart2music: "npm:1.20.0"
color-name: "npm:2.1.1"
comlink: "npm:4.4.2"
core-js: "npm:3.49.0"
@@ -9978,6 +10059,7 @@ __metadata:
del: "npm:8.0.1"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.8.0"
eslint-config-prettier: "npm:10.1.8"
@@ -10017,7 +10099,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:18.0.9"
marked: "npm:18.0.7"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
@@ -10040,7 +10122,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.66.0"
typescript-eslint: "npm:8.65.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
@@ -10349,6 +10431,18 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:10.7.16":
version: 10.7.16
resolution: "intl-messageformat@npm:10.7.16"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
tslib: "npm:^2.8.0"
checksum: 10/c19b77c5e495ce8b0d1aa0d95444bf3a4f73886805f1e08d7159b364abcf2f63686b2ccf202eaafb0e39a0e9fde61848b8dd2db1679efd4f6ec8f6a3d0e77928
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.13":
version: 11.2.13
resolution: "intl-messageformat@npm:11.2.13"
@@ -11660,12 +11754,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.9":
version: 18.0.9
resolution: "marked@npm:18.0.9"
"marked@npm:18.0.7":
version: 18.0.7
resolution: "marked@npm:18.0.7"
bin:
marked: bin/marked.js
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
checksum: 10/7ea7b8556a9e8cab2881b194815a7550c61d77639c6b8f8d9022f96790fd2b289b2ea28969cea6e01c24c3b8ec822373288ee02b7c50f178bc3ec79aa42d05f1
languageName: node
linkType: hard
@@ -14892,18 +14986,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.66.0":
version: 8.66.0
resolution: "typescript-eslint@npm:8.66.0"
"typescript-eslint@npm:8.65.0":
version: 8.65.0
resolution: "typescript-eslint@npm:8.65.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.66.0"
"@typescript-eslint/parser": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/eslint-plugin": "npm:8.65.0"
"@typescript-eslint/parser": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e76da9c684e4fe012b49a5120dd57553df0788cf1cffcbd5dc72c98de33e549f306495e710292f111ee058366b1e5d8d603c2bb14385b12bb35edc7bbf13ad55
checksum: 10/5b2242f59005afdd57190849be7df2e86ce33b007fa0bf605f700ad0e38ea14fc14c48deafdf4a039614c1f012b71c61a19edb27f76d52fdc924cde476a100d1
languageName: node
linkType: hard