Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
145803bc59 |
@@ -1,7 +1,3 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="design/logos/NestQuest-NoBG.png" alt="NestQuest logo" width="200">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
# NestQuest - House Chaos Coordination
|
# NestQuest - House Chaos Coordination
|
||||||
|
|
||||||
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
|
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
|
||||||
@@ -73,12 +69,12 @@ actions:
|
|||||||
|
|
||||||
## Automation Blueprints
|
## Automation Blueprints
|
||||||
|
|
||||||
NestQuest ships five automation blueprints in
|
NestQuest ships four automation blueprints in
|
||||||
`custom_components/nestquest/blueprints/automation/`. Import them in
|
`custom_components/nestquest/blueprints/automation/`. Import them in
|
||||||
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
||||||
(or place the files in your `config/blueprints/automation/` folder),
|
(or place the files in your `config/blueprints/automation/` folder),
|
||||||
then create automations from the blueprints, filling the inputs with your
|
then create one automation per blueprint, filling the inputs with your
|
||||||
household's choices. The UniFi blueprint needs one automation per child.
|
household's choices.
|
||||||
|
|
||||||
### Wiring to the integration's notification settings
|
### Wiring to the integration's notification settings
|
||||||
|
|
||||||
@@ -100,7 +96,7 @@ The end-of-day report deliberately runs BEFORE the midnight missed
|
|||||||
sweep and reads the same day's data — do not schedule it at the
|
sweep and reads the same day's data — do not schedule it at the
|
||||||
day-rollover time.
|
day-rollover time.
|
||||||
|
|
||||||
### The five blueprints
|
### The four blueprints
|
||||||
|
|
||||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||||
@@ -128,36 +124,10 @@ day-rollover time.
|
|||||||
not re-fire, but a second child clearing the same day still
|
not re-fire, but a second child clearing the same day still
|
||||||
celebrates.
|
celebrates.
|
||||||
|
|
||||||
5. **UniFi quest access** (`unifi_quest_access.yaml`) — create one
|
|
||||||
automation per child. Select that child's quests-remaining-today
|
|
||||||
sensor, present-today sensor, and all of their UniFi client access
|
|
||||||
switches. At 18:25 local
|
|
||||||
time Monday-Friday or 14:00 Saturday-Sunday, clients with open
|
|
||||||
quests lose all network access; access returns as soon as the count
|
|
||||||
reaches zero. When the child is **On travels** (`present today` is off),
|
|
||||||
access stays on regardless of open quests. An unavailable presence sensor
|
|
||||||
also leaves access on; verify that sensor before relying on enforcement.
|
|
||||||
Returning after the cutoff reapplies the rule immediately. At midnight
|
|
||||||
the old day's quests stop affecting access.
|
|
||||||
The automation also reconciles after Home Assistant starts and every
|
|
||||||
five minutes. If the quest sensor is unavailable after the cutoff,
|
|
||||||
it keeps the child blocked until a valid zero is observed.
|
|
||||||
|
|
||||||
First configure the [Home Assistant UniFi Network integration](https://www.home-assistant.io/integrations/unifi/)
|
|
||||||
with a local account that can manage clients. Under its **Network
|
|
||||||
access controlled clients** option, add each child's devices by MAC
|
|
||||||
address, then verify the resulting client access switches manually.
|
|
||||||
Those switches are **on = allowed** and **off = blocked**. Do not
|
|
||||||
select gateway, access-point, Home Assistant, or adult-device
|
|
||||||
switches. To override in an emergency, disable that child's
|
|
||||||
automation and turn their client switches on; re-enable the
|
|
||||||
automation to resume the quest rule. Device MAC randomization or
|
|
||||||
cellular data can bypass a MAC-based Wi-Fi block.
|
|
||||||
|
|
||||||
### Example package
|
### Example package
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Create automations from blueprints — Settings → Automations & Scenes →
|
# One automation per blueprint — Settings → Automations & Scenes →
|
||||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||||
#
|
#
|
||||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||||
|
|||||||
@@ -1,148 +1,5 @@
|
|||||||
# NestQuest Release Notes
|
# NestQuest Release Notes
|
||||||
|
|
||||||
## Version 0.10.3 — 2026-10-06
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
Patch release carrying the admin PWA desktop-icon update onto `main` (PR #301,
|
|
||||||
task 55e1e687).
|
|
||||||
|
|
||||||
### Behaviour
|
|
||||||
|
|
||||||
- The admin PWA's installable icons (192, 512, and 512 maskable) are now
|
|
||||||
generated from `design/logos/NestQuest_Logo.png` instead of placeholder
|
|
||||||
graphics, so a home-screen install of the admin app shows the NestQuest logo.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- Home Assistant floor 2024.6.0 (unchanged).
|
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
|
|
||||||
- None. Schema unchanged at version 9; no migration, no database backup
|
|
||||||
required.
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
- No schema change: rolling back to 0.10.2 requires no database restore.
|
|
||||||
|
|
||||||
## Version 0.10.2 — 2026-10-06
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
Patch release carrying the panel SSE idle-timeout fix onto `main` (PR #298, task
|
|
||||||
6c4b0fa4).
|
|
||||||
|
|
||||||
### Behaviour
|
|
||||||
|
|
||||||
- The panel event-stream request is no longer subject to aiohttp's default
|
|
||||||
~300 s total timeout; it now uses an unbounded total with a bounded connect,
|
|
||||||
so a healthy stream stays open instead of reconnecting every ~5 minutes.
|
|
||||||
Because the API does not replay transitions onto the Home Assistant event bus,
|
|
||||||
this also shrinks the windows in which a transition could be missed.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- Home Assistant floor 2024.6.0 (unchanged).
|
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
|
|
||||||
- None. Schema unchanged at version 9; no migration, no database backup
|
|
||||||
required.
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
- No schema change: rolling back to 0.10.1 requires no database restore.
|
|
||||||
|
|
||||||
## Version 0.10.1 — 2026-10-06
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
Patch release carrying the panel SSE stream fix onto `main` (PR #295, task
|
|
||||||
4cbf2e96).
|
|
||||||
|
|
||||||
### Behaviour
|
|
||||||
|
|
||||||
- The Home Assistant integration now reads the panel event stream from the
|
|
||||||
response body (`response.content`), fixing the logged failure
|
|
||||||
`'async for' requires an object with __aiter__ method, got HassClientResponse`
|
|
||||||
that logged ~935 times and prevented live panel events (completions,
|
|
||||||
un-completions, day-complete, missed) from re-firing from the API's SSE
|
|
||||||
stream. SSE line-buffering and JSON-frame parsing are unchanged.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- Home Assistant floor 2024.6.0 (unchanged).
|
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
|
|
||||||
- None. Schema unchanged at version 9; no migration, no database backup
|
|
||||||
required.
|
|
||||||
|
|
||||||
### Known limitations
|
|
||||||
|
|
||||||
- Intermittent `GET /api/v1/panel/events failed with HTTP 502` during API
|
|
||||||
restarts/redeploys is expected (the integration reconnects with backoff).
|
|
||||||
- The event stream still reconnects periodically because Home Assistant's
|
|
||||||
shared aiohttp session applies a default total timeout — tracked as a
|
|
||||||
follow-up; it does not affect correctness.
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
- No schema change: rolling back to 0.10.0 requires no database restore.
|
|
||||||
|
|
||||||
## Version 0.10.0 — 2026-09-30
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
Feature release: choose task icons by typing any Font Awesome Free **solid**
|
|
||||||
icon name, not just the curated subset. The shared icon registry now embeds the
|
|
||||||
full Font Awesome Free solid set offline (no CDN — the panel may be offline),
|
|
||||||
the admin icon picker is searchable with free-text entry and validation, and the
|
|
||||||
kids' panel renders any Free Solid icon. (Feature `6c681b17`; PRs #289–#292.)
|
|
||||||
|
|
||||||
### Behaviour
|
|
||||||
|
|
||||||
- **Full Free Solid set**: the panel and admin bundles now carry ~1,422 Font
|
|
||||||
Awesome Free solid glyphs as inline SVG path data, in addition to the existing
|
|
||||||
curated quick-pick lists (Lucide and Font Awesome). `fa:<name>` resolves any
|
|
||||||
Free Solid icon; unknown names still render the fallback glyph.
|
|
||||||
- **Searchable picker**: the admin definition icon picker's Font Awesome tab has
|
|
||||||
a search input. Empty shows the curated quick picks; typing filters the full
|
|
||||||
set into a bounded (≤40 per page) result grid, with Previous / Show more.
|
|
||||||
- **Free-text entry**: typing an exact Free Solid name selects and stores
|
|
||||||
`fa:<name>`. A partial, misspelled, Pro, or Brands name is rejected with an
|
|
||||||
inline message and nothing is saved.
|
|
||||||
- Stored format is unchanged: `lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`,
|
|
||||||
or a legacy bare name.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- Home Assistant floor 2024.6.0 (unchanged).
|
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
|
|
||||||
- None to the database: schema unchanged at version 9, no migration and no
|
|
||||||
database backup required.
|
|
||||||
- Font Awesome **Pro** and **Brands/Regular** icons are NOT included — only the
|
|
||||||
Free Solid set. A Pro/Brands name is rejected in the picker (and an unknown
|
|
||||||
`fa:` name falls back on the panel).
|
|
||||||
|
|
||||||
### Known limitations
|
|
||||||
|
|
||||||
- **Bundle size**: the embedded icon set increases the committed panel bundle to
|
|
||||||
~1.06 MB raw (~276 kB gzip) and the admin PWA bundle similarly. This is
|
|
||||||
expected for the full Free Solid set.
|
|
||||||
- Alias names (e.g. Font Awesome's `fa:home` alias of `fa:house`) are
|
|
||||||
de-duplicated to the canonical name, so `fa:house` resolves but `fa:home`
|
|
||||||
falls back.
|
|
||||||
- The backend still validates only the `fa:<kebab-name>` format; unknown names
|
|
||||||
are caught in the picker and render as the fallback on the panel.
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
- No schema change: rolling back to 0.9.8 requires no database restore.
|
|
||||||
|
|
||||||
## Version 0.9.8 — 2026-09-30
|
## Version 0.9.8 — 2026-09-30
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
NestQuest bundles glyph data from the icon sets below. The curated Lucide names and the
|
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
|
||||||
Font Awesome quick-pick names are listed in `tools/icons/curated-icons.json`;
|
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
|
||||||
`tools/icons/generate.mjs` reads each glyph's SVG path data from the locally installed packages and generates it into both bundles
|
data from the locally installed packages and generates it into both bundles
|
||||||
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
|
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
|
||||||
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
|
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
|
||||||
in the header of each generated file.
|
in the header of each generated file.
|
||||||
@@ -15,8 +15,8 @@ in the header of each generated file.
|
|||||||
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
|
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
|
||||||
- Code: [MIT](https://opensource.org/licenses/MIT)
|
- Code: [MIT](https://opensource.org/licenses/MIT)
|
||||||
- Use in NestQuest: the full Free **solid** icon set, embedded as SVG path data (a curated
|
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
|
||||||
subset doubles as the admin picker's quick picks). No fonts are bundled.
|
data. No fonts are bundled.
|
||||||
|
|
||||||
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
|
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -1,12 +1,14 @@
|
|||||||
// Generates the committed PWA icons under public/icons/ from the brand logo
|
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
|
||||||
// (design/logos/NestQuest_Logo.png), box-filter downscaled.
|
|
||||||
// Pure Node (zlib only) — no network, no image dependencies.
|
// Pure Node (zlib only) — no network, no image dependencies.
|
||||||
// Run: node scripts/generate-icons.mjs
|
// Run: node scripts/generate-icons.mjs
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import { deflateSync, inflateSync } from "node:zlib";
|
import { deflateSync } from "node:zlib";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
|
||||||
|
const BLUE = [0x3b, 0x3a, 0xb8];
|
||||||
|
const PURPLE = [0xa0, 0x35, 0xcc];
|
||||||
|
const WHITE = [0xff, 0xff, 0xff];
|
||||||
|
|
||||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||||
let c = n;
|
let c = n;
|
||||||
@@ -42,129 +44,37 @@ function encodePng(size, pixel) {
|
|||||||
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
|
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
|
||||||
}
|
}
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
SIG,
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
chunk("IHDR", ihdr),
|
chunk("IHDR", ihdr),
|
||||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
|
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
|
||||||
// Anything else fails loudly rather than producing a wrong icon.
|
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
|
||||||
function decodePng(buf) {
|
// safe zone (inner 80% circle).
|
||||||
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
|
function brandMark(size, scale) {
|
||||||
let width, height;
|
const c = size / 2;
|
||||||
const idat = [];
|
const outer = size * 0.34 * scale;
|
||||||
for (let off = 8; off < buf.length; ) {
|
const inner = size * 0.24 * scale;
|
||||||
const len = buf.readUInt32BE(off);
|
const dot = size * 0.1 * scale;
|
||||||
const type = buf.toString("ascii", off + 4, off + 8);
|
|
||||||
const data = buf.subarray(off + 8, off + 8 + len);
|
|
||||||
if (type === "IHDR") {
|
|
||||||
width = data.readUInt32BE(0);
|
|
||||||
height = data.readUInt32BE(4);
|
|
||||||
const [depth, colour, , , interlace] = data.subarray(8);
|
|
||||||
if (depth !== 8 || colour !== 2 || interlace !== 0) {
|
|
||||||
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
|
|
||||||
}
|
|
||||||
} else if (type === "IDAT") idat.push(data);
|
|
||||||
else if (type === "IEND") break;
|
|
||||||
off += 12 + len;
|
|
||||||
}
|
|
||||||
const raw = inflateSync(Buffer.concat(idat));
|
|
||||||
const stride = width * 3;
|
|
||||||
const px = Buffer.alloc(stride * height);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
const filter = raw[y * (stride + 1)];
|
|
||||||
const src = y * (stride + 1) + 1;
|
|
||||||
const dst = y * stride;
|
|
||||||
for (let i = 0; i < stride; i++) {
|
|
||||||
const a = i >= 3 ? px[dst + i - 3] : 0;
|
|
||||||
const b = y > 0 ? px[dst - stride + i] : 0;
|
|
||||||
const c = i >= 3 && y > 0 ? px[dst - stride + i - 3] : 0;
|
|
||||||
let p;
|
|
||||||
if (filter === 0) p = 0;
|
|
||||||
else if (filter === 1) p = a;
|
|
||||||
else if (filter === 2) p = b;
|
|
||||||
else if (filter === 3) p = (a + b) >> 1;
|
|
||||||
else if (filter === 4) {
|
|
||||||
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
|
|
||||||
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
||||||
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
|
|
||||||
px[dst + i] = (raw[src + i] + p) & 0xff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { width, height, px };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box-filter (area-average) resize along one axis: each output sample is the
|
|
||||||
// coverage-weighted mean of the source samples it spans.
|
|
||||||
function boxWeights(srcLen, dstLen) {
|
|
||||||
const scale = srcLen / dstLen;
|
|
||||||
return Array.from({ length: dstLen }, (_, o) => {
|
|
||||||
const start = o * scale, end = start + scale;
|
|
||||||
const taps = [];
|
|
||||||
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
|
|
||||||
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
|
|
||||||
}
|
|
||||||
return taps;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize({ width, height, px }, size) {
|
|
||||||
const wx = boxWeights(width, size);
|
|
||||||
const wy = boxWeights(height, size);
|
|
||||||
const tmp = new Float64Array(size * height * 3); // horizontal pass
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
for (const [s, w] of wx[x]) {
|
|
||||||
for (let k = 0; k < 3; k++) tmp[(y * size + x) * 3 + k] += px[(y * width + s) * 3 + k] * w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const out = new Uint8Array(size * size * 3); // vertical pass
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
for (let k = 0; k < 3; k++) {
|
|
||||||
let v = 0;
|
|
||||||
for (const [s, w] of wy[y]) v += tmp[(s * size + x) * 3 + k] * w;
|
|
||||||
out[(y * size + x) * 3 + k] = Math.min(255, Math.round(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (x, y) => out.subarray((y * size + x) * 3, (y * size + x) * 3 + 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mean of the four corner pixels — the logo's solid background colour.
|
|
||||||
function cornerColour({ width, height, px }) {
|
|
||||||
const corners = [[0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1]];
|
|
||||||
return [0, 1, 2].map((k) =>
|
|
||||||
Math.round(corners.reduce((sum, [x, y]) => sum + px[(y * width + x) * 3 + k], 0) / 4),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `scale` < 1 shrinks the logo onto a background-coloured canvas so a
|
|
||||||
// maskable icon keeps the whole emblem inside the safe zone (inner 80% circle).
|
|
||||||
function logoIcon(logo, size, scale) {
|
|
||||||
if (scale === 1) return resize(logo, size);
|
|
||||||
const inner = Math.round(size * scale);
|
|
||||||
const offset = Math.floor((size - inner) / 2);
|
|
||||||
const pixel = resize(logo, inner);
|
|
||||||
const bg = cornerColour(logo);
|
|
||||||
return (x, y) => {
|
return (x, y) => {
|
||||||
const ix = x - offset, iy = y - offset;
|
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
||||||
return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
|
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
|
||||||
|
const t = (x + y) / (2 * (size - 1));
|
||||||
|
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const logo = decodePng(readFileSync(new URL("../../design/logos/NestQuest_Logo.png", import.meta.url)));
|
|
||||||
const outDir = new URL("../public/icons/", import.meta.url);
|
const outDir = new URL("../public/icons/", import.meta.url);
|
||||||
const icons = [
|
const icons = [
|
||||||
["icon-192.png", 192, 1],
|
["icon-192.png", 192, 1],
|
||||||
["icon-512.png", 512, 1],
|
["icon-512.png", 512, 1],
|
||||||
["icon-maskable-512.png", 512, 0.8],
|
["icon-maskable-512.png", 512, 0.75],
|
||||||
];
|
];
|
||||||
for (const [name, size, scale] of icons) {
|
for (const [name, size, scale] of icons) {
|
||||||
const path = fileURLToPath(new URL(name, outDir));
|
const path = fileURLToPath(new URL(name, outDir));
|
||||||
writeFileSync(path, encodePng(size, logoIcon(logo, size, scale)));
|
writeFileSync(path, encodePng(size, brandMark(size, scale)));
|
||||||
console.log(`wrote ${path}`);
|
console.log(`wrote ${path}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -614,34 +614,3 @@
|
|||||||
.defs-emoji {
|
.defs-emoji {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font Awesome search: glyph + label tiles, inline validation, paging */
|
|
||||||
.defs-icon-grid--results {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-icon-result {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 6px 4px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-icon-label {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-hint--error {
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-show-more {
|
|
||||||
align-self: flex-start;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import {
|
|||||||
FA_DEFINITION_ICONS,
|
FA_DEFINITION_ICONS,
|
||||||
emojiIconKey,
|
emojiIconKey,
|
||||||
faIconName,
|
faIconName,
|
||||||
faTypedIconKey,
|
|
||||||
findDefinitionIcon,
|
findDefinitionIcon,
|
||||||
normalizeDefinitionIconName,
|
normalizeDefinitionIconName,
|
||||||
searchFaIcons,
|
|
||||||
} from "./definitionIcons";
|
} from "./definitionIcons";
|
||||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||||
import { storeTokens } from "../auth/oidc";
|
import { storeTokens } from "../auth/oidc";
|
||||||
@@ -1355,7 +1353,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
|||||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
it("ships the curated Font Awesome quick picks, each drawn as a filled glyph", () => {
|
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
|
||||||
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
|
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
|
||||||
for (const entry of FA_DEFINITION_ICONS) {
|
for (const entry of FA_DEFINITION_ICONS) {
|
||||||
expect(entry.kind).toBe("fa");
|
expect(entry.kind).toBe("fa");
|
||||||
@@ -1531,245 +1529,6 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Font Awesome search ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
describe("DefinitionsTab Font Awesome search", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const CURATED = new Set(FA_DEFINITION_ICONS.map((icon) => icon.name));
|
|
||||||
|
|
||||||
function searchInput(s: HTMLElement): HTMLInputElement {
|
|
||||||
return within(s).getByRole("searchbox", { name: "Search Font Awesome icons" }) as HTMLInputElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function results(s: HTMLElement): HTMLElement {
|
|
||||||
return within(s).getByRole("radiogroup", { name: "Font Awesome search results" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultKeys(s: HTMLElement): string[] {
|
|
||||||
return within(results(s))
|
|
||||||
.getAllByRole("radio")
|
|
||||||
.map((option) => option.getAttribute("data-icon-option")!);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowFaIcon(row: HTMLElement): string | null {
|
|
||||||
const svg = row.querySelector(".defs-row-glyph svg");
|
|
||||||
return svg!.getAttribute("fill") === "none" ? null : svg!.getAttribute("data-icon");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(s: HTMLElement) {
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillNewTask(s: HTMLElement) {
|
|
||||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
|
||||||
target: { value: "Walk the dog" },
|
|
||||||
});
|
|
||||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
|
||||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
|
||||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
it("searches the full Free Solid set by name or label, exact and prefix matches first", () => {
|
|
||||||
expect(searchFaIcons("")).toEqual([]);
|
|
||||||
expect(searchFaIcons(" ")).toEqual([]);
|
|
||||||
const dog = searchFaIcons(" DOG ").map((icon) => icon.name);
|
|
||||||
expect(dog[0]).toBe("dog");
|
|
||||||
expect(dog).toEqual(expect.arrayContaining(["hotdog", "shield-dog"]));
|
|
||||||
expect(dog.some((name) => !CURATED.has(name))).toBe(true);
|
|
||||||
// A label match: "Shield Dog" contains "shield d" though no name does.
|
|
||||||
expect(searchFaIcons("shield d").map((icon) => icon.name)).toContain("shield-dog");
|
|
||||||
expect(searchFaIcons("dog-leashed")).toEqual([]);
|
|
||||||
|
|
||||||
expect(faTypedIconKey("dragon")).toBe("fa:dragon");
|
|
||||||
expect(faTypedIconKey(" Dragon ")).toBe("fa:dragon");
|
|
||||||
for (const bad of ["", "drag", "dgo", "dog-leashed", "github", "fa:dog"]) {
|
|
||||||
expect(faTypedIconKey(bad), bad).toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the curated quick picks while the search is empty", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
|
||||||
const s = sheet();
|
|
||||||
expect(searchInput(s).value).toBe("");
|
|
||||||
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
|
|
||||||
expect(within(quick).getAllByRole("radio")).toHaveLength(FA_DEFINITION_ICONS.length);
|
|
||||||
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a query swaps the quick picks for one bounded, labelled page of results", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
|
||||||
const s = sheet();
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "a" } });
|
|
||||||
const total = searchFaIcons("a").length;
|
|
||||||
expect(total).toBeGreaterThan(80);
|
|
||||||
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome" })).toBeNull();
|
|
||||||
expect(resultKeys(s)).toHaveLength(40);
|
|
||||||
expect(within(results(s)).getAllByRole("radio")[0].textContent).toBe(searchFaIcons("a")[0].label);
|
|
||||||
expect(within(s).getByText(`Showing 1–40 of ${total}`)).toBeTruthy();
|
|
||||||
expect(within(s).queryByRole("button", { name: "Previous" })).toBeNull();
|
|
||||||
|
|
||||||
// "Show more" replaces the page rather than appending to it.
|
|
||||||
const firstPage = resultKeys(s);
|
|
||||||
fireEvent.click(within(s).getByRole("button", { name: "Show more" }));
|
|
||||||
expect(resultKeys(s)).toEqual(searchFaIcons("a").slice(40, 80).map((icon) => `fa:${icon.name}`));
|
|
||||||
expect(resultKeys(s).some((key) => firstPage.includes(key))).toBe(false);
|
|
||||||
expect(within(s).getByText(`Showing 41–80 of ${total}`)).toBeTruthy();
|
|
||||||
fireEvent.click(within(s).getByRole("button", { name: "Previous" }));
|
|
||||||
expect(resultKeys(s)).toEqual(firstPage);
|
|
||||||
|
|
||||||
// A new query starts again from the first page.
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "dog" } });
|
|
||||||
expect(resultKeys(s)[0]).toBe("fa:dog");
|
|
||||||
expect(resultKeys(s)).toContain("fa:hotdog");
|
|
||||||
expect(within(s).queryByRole("button", { name: "Show more" })).toBeNull();
|
|
||||||
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "" } });
|
|
||||||
expect(within(s).getByRole("radiogroup", { name: "Font Awesome" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("paging through every match never mounts more than one page", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
|
||||||
const s = sheet();
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "-" } });
|
|
||||||
const all = searchFaIcons("-").map((icon) => `fa:${icon.name}`);
|
|
||||||
expect(all.length).toBeGreaterThan(900);
|
|
||||||
|
|
||||||
const seen: string[] = [];
|
|
||||||
for (;;) {
|
|
||||||
expect(results(s).children.length).toBeGreaterThan(0);
|
|
||||||
expect(results(s).children.length).toBeLessThanOrEqual(40);
|
|
||||||
seen.push(...resultKeys(s));
|
|
||||||
const more = within(s).queryByRole("button", { name: "Show more" });
|
|
||||||
if (!more) break;
|
|
||||||
fireEvent.click(more);
|
|
||||||
}
|
|
||||||
// Every match stays reachable, one page at a time, in order.
|
|
||||||
expect(seen).toEqual(all);
|
|
||||||
const lastStart = Math.floor((all.length - 1) / 40) * 40 + 1;
|
|
||||||
expect(within(s).getByText(`Showing ${lastStart}–${all.length} of ${all.length}`)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selecting a search result POSTs fa:<name> and renders on its row", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
|
||||||
const s = sheet();
|
|
||||||
fillNewTask(s);
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "hot" } });
|
|
||||||
expect(within(s).getByRole("alert").textContent).toMatch(/Select a Font Awesome icon from the results/);
|
|
||||||
const hotdog = within(results(s)).getByRole("radio", { name: "Hotdog" });
|
|
||||||
fireEvent.click(hotdog);
|
|
||||||
expect(hotdog.getAttribute("aria-checked")).toBe("true");
|
|
||||||
expect(within(s).queryByRole("alert")).toBeNull();
|
|
||||||
expect(searchInput(s).value).toBe("hot");
|
|
||||||
|
|
||||||
await save(s);
|
|
||||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:hotdog");
|
|
||||||
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-99"))).toBe("hotdog"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a typed non-curated Free Solid name is accepted and PATCHed", async () => {
|
|
||||||
expect(CURATED.has("dragon")).toBe(false);
|
|
||||||
await renderReady([{ ...BRUSH, id: 70, icon: "lucide:dog" }]);
|
|
||||||
fireEvent.click(screen.getByTestId("definition-70"));
|
|
||||||
const s = sheet();
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "Dragon" } });
|
|
||||||
expect(within(s).queryByRole("alert")).toBeNull();
|
|
||||||
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
const lucide = within(s).getByRole("radiogroup", { name: "Icon" });
|
|
||||||
expect(within(lucide).queryAllByRole("radio", { checked: true })).toHaveLength(0);
|
|
||||||
|
|
||||||
await save(s);
|
|
||||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dragon");
|
|
||||||
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-70"))).toBe("dragon"));
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [typed, message] of [
|
|
||||||
["do", /Select a Font Awesome icon from the results/],
|
|
||||||
["dgo", /“dgo” is not a Font Awesome Free Solid icon/],
|
|
||||||
["dog-leashed", /“dog-leashed” is not a Font Awesome Free Solid icon/],
|
|
||||||
["github", /“github” is not a Font Awesome Free Solid icon/],
|
|
||||||
] as const) {
|
|
||||||
it(`typed ${JSON.stringify(typed)} is rejected with a message and nothing is saved`, async () => {
|
|
||||||
await renderReady([{ ...BRUSH, id: 71, icon: "lucide:dog" }]);
|
|
||||||
fireEvent.click(screen.getByTestId("definition-71"));
|
|
||||||
const s = sheet();
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: typed } });
|
|
||||||
expect(within(s).getByRole("alert").textContent).toMatch(message);
|
|
||||||
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("false");
|
|
||||||
|
|
||||||
await save(s);
|
|
||||||
expect(api.writes()).toHaveLength(0);
|
|
||||||
expect(within(s).getByTestId("sheet-error").textContent).toMatch(message);
|
|
||||||
expect(screen.getByRole("dialog")).toBe(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("clearing the search, or No icon, removes the icon", async () => {
|
|
||||||
await renderReady([
|
|
||||||
{ ...BRUSH, id: 72, icon: "fa:dragon" },
|
|
||||||
{ ...BRUSH, id: 73, title: "Task 73", icon: "fa:dragon" },
|
|
||||||
]);
|
|
||||||
fireEvent.click(screen.getByTestId("definition-72"));
|
|
||||||
let s = sheet();
|
|
||||||
fireEvent.change(searchInput(s), { target: { value: "" } });
|
|
||||||
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("true");
|
|
||||||
await save(s);
|
|
||||||
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
|
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("definition-73"));
|
|
||||||
s = sheet();
|
|
||||||
fireEvent.click(within(s).getByRole("radio", { name: "No icon" }));
|
|
||||||
expect(searchInput(s).value).toBe("");
|
|
||||||
await save(s);
|
|
||||||
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a saved non-curated fa:<name> renders on its row and opens selected in the results", async () => {
|
|
||||||
await renderReady([{ ...BRUSH, id: 74, icon: "fa:dragon" }]);
|
|
||||||
expect(rowFaIcon(screen.getByTestId("definition-74"))).toBe("dragon");
|
|
||||||
fireEvent.click(screen.getByTestId("definition-74"));
|
|
||||||
const s = sheet();
|
|
||||||
expect(searchInput(s).value).toBe("dragon");
|
|
||||||
expect(within(s).queryByRole("alert")).toBeNull();
|
|
||||||
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
|
||||||
target: { value: "Feed the dragon" },
|
|
||||||
});
|
|
||||||
await save(s);
|
|
||||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a saved curated fa:<name> opens in the quick picks with an empty search", async () => {
|
|
||||||
await renderReady([{ ...BRUSH, id: 75, icon: "fa:dog" }]);
|
|
||||||
fireEvent.click(screen.getByTestId("definition-75"));
|
|
||||||
const s = sheet();
|
|
||||||
expect(searchInput(s).value).toBe("");
|
|
||||||
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
|
|
||||||
expect(within(quick).getByRole("radio", { name: "Dog" }).getAttribute("aria-checked")).toBe("true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Legacy icons ───────────────────────────────────────────────────── */
|
/* ── Legacy icons ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
describe("DefinitionsTab legacy icon edits", () => {
|
describe("DefinitionsTab legacy icon edits", () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
@@ -54,12 +53,8 @@ import {
|
|||||||
emojiIconText,
|
emojiIconText,
|
||||||
faIconKey,
|
faIconKey,
|
||||||
faIconName,
|
faIconName,
|
||||||
faTextProblem,
|
|
||||||
faTypedIconKey,
|
|
||||||
findDefinitionIcon,
|
|
||||||
lucideIconKey,
|
lucideIconKey,
|
||||||
normalizeDefinitionIconName,
|
normalizeDefinitionIconName,
|
||||||
searchFaIcons,
|
|
||||||
} from "./definitionIcons";
|
} from "./definitionIcons";
|
||||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||||
import { weekdayOf } from "../schedule/cycle";
|
import { weekdayOf } from "../schedule/cycle";
|
||||||
@@ -92,9 +87,6 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
|||||||
/** How many upcoming dates the edit sheet previews. */
|
/** How many upcoming dates the edit sheet previews. */
|
||||||
const PREVIEW_COUNT = 5;
|
const PREVIEW_COUNT = 5;
|
||||||
|
|
||||||
/** Font Awesome search results per page; only one page is ever mounted. */
|
|
||||||
const FA_RESULTS_PAGE = 40;
|
|
||||||
|
|
||||||
/** Quiet period after the last rule edit before the preview is requested. */
|
/** Quiet period after the last rule edit before the preview is requested. */
|
||||||
const PREVIEW_DEBOUNCE_MS = 300;
|
const PREVIEW_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
@@ -109,11 +101,6 @@ interface Draft {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
|
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
|
||||||
emoji: string;
|
emoji: string;
|
||||||
/**
|
|
||||||
* The Font Awesome search text; typing an exact Free Solid name sets
|
|
||||||
* `icon` to `fa:<name>`, and picking a search result keeps the text.
|
|
||||||
*/
|
|
||||||
faText: string;
|
|
||||||
assigneeIds: number[];
|
assigneeIds: number[];
|
||||||
repeats: Repeats;
|
repeats: Repeats;
|
||||||
interval: string;
|
interval: string;
|
||||||
@@ -148,7 +135,6 @@ function newDraft(activeChildren: AdminChild[], today: string): Draft {
|
|||||||
title: "",
|
title: "",
|
||||||
icon: null,
|
icon: null,
|
||||||
emoji: "",
|
emoji: "",
|
||||||
faText: "",
|
|
||||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||||
repeats: "daily",
|
repeats: "daily",
|
||||||
interval: "1",
|
interval: "1",
|
||||||
@@ -171,13 +157,10 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], to
|
|||||||
const active = new Set(activeChildren.map((child) => child.id));
|
const active = new Set(activeChildren.map((child) => child.id));
|
||||||
const dueTimes = emptyDueTimes();
|
const dueTimes = emptyDueTimes();
|
||||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||||
const fa = findDefinitionIcon(definition.icon);
|
|
||||||
return {
|
return {
|
||||||
title: definition.title,
|
title: definition.title,
|
||||||
icon: definition.icon,
|
icon: definition.icon,
|
||||||
emoji: emojiIconText(definition.icon),
|
emoji: emojiIconText(definition.icon),
|
||||||
// A Free Solid icon outside the quick picks opens as its search result.
|
|
||||||
faText: fa?.kind === "fa" && !FA_DEFINITION_ICONS.includes(fa) ? fa.name : "",
|
|
||||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
// Inactive children cannot be submitted (the API rejects them with 422).
|
||||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
||||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
repeats: REPEATS_BY_RULE[rule.rule_type],
|
||||||
@@ -267,7 +250,6 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
|
|||||||
if (draft.emoji && draft.icon === null) {
|
if (draft.emoji && draft.icon === null) {
|
||||||
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
|
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
|
||||||
}
|
}
|
||||||
if (draft.faText.trim() && draft.icon === null) return faTextProblem(draft.faText);
|
|
||||||
const rule = buildRule(draft);
|
const rule = buildRule(draft);
|
||||||
if (typeof rule === "string") return rule;
|
if (typeof rule === "string") return rule;
|
||||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||||
@@ -497,11 +479,6 @@ function EditSheet({
|
|||||||
? []
|
? []
|
||||||
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]);
|
|
||||||
const [faPage, setFaPage] = useState(0);
|
|
||||||
const faStart = faPage * FA_RESULTS_PAGE;
|
|
||||||
const faEnd = Math.min(faStart + FA_RESULTS_PAGE, faResults.length);
|
|
||||||
const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim();
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
@@ -642,10 +619,12 @@ function EditSheet({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={noIcon}
|
aria-checked={draft.icon === null && !draft.emoji}
|
||||||
aria-label="No icon"
|
aria-label="No icon"
|
||||||
className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
|
className={
|
||||||
onClick={() => update({ icon: null, emoji: "", faText: "" })}
|
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
|
||||||
|
}
|
||||||
|
onClick={() => update({ icon: null, emoji: "" })}
|
||||||
>
|
>
|
||||||
<TaskGlyph size={18} />
|
<TaskGlyph size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -661,7 +640,7 @@ function EditSheet({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-icon-option={name}
|
data-icon-option={name}
|
||||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })}
|
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
|
||||||
>
|
>
|
||||||
<GlyphSvg icon={icon} size={18} />
|
<GlyphSvg icon={icon} size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -671,93 +650,26 @@ function EditSheet({
|
|||||||
<span className="defs-sublabel" id="defs-label-icon-fa">
|
<span className="defs-sublabel" id="defs-label-icon-fa">
|
||||||
Font Awesome
|
Font Awesome
|
||||||
</span>
|
</span>
|
||||||
<input
|
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
|
||||||
className="defs-input"
|
{FA_DEFINITION_ICONS.map((icon) => {
|
||||||
type="search"
|
const { name, label } = icon;
|
||||||
aria-label="Search Font Awesome icons"
|
const on = faIconName(draft.icon) === name;
|
||||||
placeholder="Search or type an icon name, e.g. dog"
|
return (
|
||||||
value={draft.faText}
|
<button
|
||||||
onChange={(e) => {
|
key={name}
|
||||||
setFaPage(0);
|
type="button"
|
||||||
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
|
role="radio"
|
||||||
}}
|
aria-checked={on}
|
||||||
/>
|
aria-label={label}
|
||||||
{draft.faText.trim() && draft.icon === null ? (
|
data-icon-option={faIconKey(name)}
|
||||||
<p className="defs-hint defs-hint--error" role="alert">
|
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
{faTextProblem(draft.faText)}
|
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{draft.faText.trim() ? (
|
|
||||||
faResults.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="defs-icon-grid defs-icon-grid--results"
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Font Awesome search results"
|
|
||||||
>
|
>
|
||||||
{faResults.slice(faStart, faEnd).map((icon) => {
|
<GlyphSvg icon={icon} size={18} />
|
||||||
const { name, label } = icon;
|
</button>
|
||||||
const on = faIconName(draft.icon) === name;
|
);
|
||||||
return (
|
})}
|
||||||
<button
|
</div>
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={on}
|
|
||||||
aria-label={label}
|
|
||||||
data-icon-option={faIconKey(name)}
|
|
||||||
className={
|
|
||||||
on ? "defs-icon defs-icon-result defs-icon--on" : "defs-icon defs-icon-result"
|
|
||||||
}
|
|
||||||
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
|
||||||
>
|
|
||||||
<GlyphSvg icon={icon} size={18} />
|
|
||||||
<span className="defs-icon-label">{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="defs-hint">
|
|
||||||
Showing {faStart + 1}–{faEnd} of {faResults.length}
|
|
||||||
</p>
|
|
||||||
{faResults.length > FA_RESULTS_PAGE ? (
|
|
||||||
<div className="defs-show-more">
|
|
||||||
{faPage > 0 ? (
|
|
||||||
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page - 1)}>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{faEnd < faResults.length ? (
|
|
||||||
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page + 1)}>
|
|
||||||
Show more
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
|
|
||||||
{FA_DEFINITION_ICONS.map((icon) => {
|
|
||||||
const { name, label } = icon;
|
|
||||||
const on = faIconName(draft.icon) === name;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={on}
|
|
||||||
aria-label={label}
|
|
||||||
data-icon-option={faIconKey(name)}
|
|
||||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
|
||||||
onClick={() => update({ icon: faIconKey(name), emoji: "", faText: "" })}
|
|
||||||
>
|
|
||||||
<GlyphSvg icon={icon} size={18} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<label className="defs-due">
|
<label className="defs-due">
|
||||||
<span>Emoji</span>
|
<span>Emoji</span>
|
||||||
<input
|
<input
|
||||||
@@ -765,9 +677,7 @@ function EditSheet({
|
|||||||
aria-label="Emoji"
|
aria-label="Emoji"
|
||||||
placeholder="e.g. 🐶"
|
placeholder="e.g. 🐶"
|
||||||
value={draft.emoji}
|
value={draft.emoji}
|
||||||
onChange={(e) =>
|
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
|
||||||
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
|
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
|
||||||
* Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
|
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
|
||||||
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
|
* names, labels and path data come from the shared icon registry
|
||||||
* shortcut list; `fa:<name>` resolves against the full set
|
* (`src/icons/registry.generated.ts`, generated from
|
||||||
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
|
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
|
||||||
* icon registry (`src/icons/registry.generated.ts`, generated by
|
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
|
||||||
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
|
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||||
* installed Free Solid package) — no CDN, no runtime fetch. The stored value
|
|
||||||
* is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — and a
|
|
||||||
* legacy bare `<kebab-name>` still names the same Lucide entry.
|
|
||||||
*/
|
*/
|
||||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||||
import {
|
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
|
||||||
FA_ICONS,
|
|
||||||
FA_SOLID_ICONS,
|
|
||||||
LUCIDE_ICONS,
|
|
||||||
resolveIcon,
|
|
||||||
type GlyphIcon,
|
|
||||||
} from "../icons/registry.generated";
|
|
||||||
import { TaskGlyph } from "./glyphs";
|
import { TaskGlyph } from "./glyphs";
|
||||||
|
|
||||||
export type DefinitionIconEntry = GlyphIcon;
|
export type DefinitionIconEntry = GlyphIcon;
|
||||||
@@ -67,50 +58,14 @@ export function faIconName(value: string | null): string | null {
|
|||||||
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The Font Awesome Free Solid icons whose name or label contains `query`
|
|
||||||
* (trimmed, case-insensitive): an exact name first, then name-prefix
|
|
||||||
* matches, then the rest, each in registry (name) order. An empty query
|
|
||||||
* gives no results — the curated quick picks cover that case.
|
|
||||||
*/
|
|
||||||
export function searchFaIcons(query: string): DefinitionIconEntry[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return [];
|
|
||||||
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
|
|
||||||
return FA_SOLID_ICONS.filter(
|
|
||||||
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
|
|
||||||
).sort((a, b) => rank(a) - rank(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
|
|
||||||
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
|
|
||||||
* else null (a misspelling, a partial search, or a Pro/Brands name).
|
|
||||||
*/
|
|
||||||
export function faTypedIconKey(text: string): string | null {
|
|
||||||
const key = faIconKey(text.trim().toLowerCase());
|
|
||||||
return resolveIcon(key).kind === "fa" ? key : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Why Font Awesome search text cannot be saved as it stands: it matches
|
|
||||||
* icons but names none of them, or it matches nothing in the Free Solid set.
|
|
||||||
*/
|
|
||||||
export function faTextProblem(text: string): string {
|
|
||||||
return searchFaIcons(text).length > 0
|
|
||||||
? "Select a Font Awesome icon from the results, or type its full name."
|
|
||||||
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
||||||
export function emojiIconText(value: string | null): string {
|
export function emojiIconText(value: string | null): string {
|
||||||
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The glyph an icon value resolves to via the shared parser: a curated
|
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
|
||||||
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
|
* the shared parser; emoji, unknown and null values give null.
|
||||||
* emoji, unknown and null values give null.
|
|
||||||
*/
|
*/
|
||||||
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
|
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
|
||||||
const icon = resolveIcon(value);
|
const icon = resolveIcon(value);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe("offline bundling", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no bundled source imports an icon package — only the generated registry data ships", () => {
|
it("no bundled source imports an icon package — only the curated generated data ships", () => {
|
||||||
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
|
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
|
||||||
.flatMap(filesUnder)
|
.flatMap(filesUnder)
|
||||||
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
|
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ network and NO aiohttp installed; production builds the client through
|
|||||||
:func:`client_from_entry`, which hands in Home Assistant's shared
|
:func:`client_from_entry`, which hands in Home Assistant's shared
|
||||||
aiohttp session (``homeassistant.helpers.aiohttp_client
|
aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||||
.async_get_clientsession``). The module itself imports NEITHER
|
.async_get_clientsession``). The module itself imports NEITHER
|
||||||
homeassistant NOR aiohttp at module import (aiohttp appears only
|
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
||||||
behind ``TYPE_CHECKING`` for annotations, plus one lazy import inside
|
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
||||||
:meth:`NestQuestApiClient.stream_events` for its ``ClientTimeout``), so
|
the HA coupling lives in the one production factory.
|
||||||
the client is importable anywhere — the HA coupling lives in the one
|
|
||||||
production factory.
|
|
||||||
|
|
||||||
Every failure is typed: a transport failure (connection refused, DNS
|
Every failure is typed: a transport failure (connection refused, DNS
|
||||||
failure, timeout — the exchange never produced a response) and every
|
failure, timeout — the exchange never produced a response) and every
|
||||||
@@ -40,11 +38,7 @@ frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
|
|||||||
line). Opening the stream gets the same one-timeout, typed-error
|
line). Opening the stream gets the same one-timeout, typed-error
|
||||||
contract as every other call; reading it has NO body-wide timeout (a
|
contract as every other call; reading it has NO body-wide timeout (a
|
||||||
healthy stream is long-lived — the subscription manager owns
|
healthy stream is long-lived — the subscription manager owns
|
||||||
reconnection and backoff). That includes the session's own default:
|
reconnection and backoff).
|
||||||
Home Assistant's shared session carries ``ClientTimeout(total=300)``,
|
|
||||||
which would cut a healthy stream every five minutes, so the stream
|
|
||||||
request overrides it with ``total=None`` (keeping a finite
|
|
||||||
``sock_connect``).
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -284,19 +278,8 @@ class NestQuestApiClient:
|
|||||||
(``aclose``, i.e. the consumer's task being cancelled) stops
|
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||||
cleanly and closes the underlying response in every path.
|
cleanly and closes the underlying response in every path.
|
||||||
"""
|
"""
|
||||||
# Lazy: aiohttp ships with Home Assistant, but importing it at
|
|
||||||
# module level would make the client unimportable without it.
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
url = f"{self._base_url}{EVENTS_PATH}"
|
url = f"{self._base_url}{EVENTS_PATH}"
|
||||||
headers = self._headers()
|
headers = self._headers()
|
||||||
# The session's default total timeout (HA's shared session:
|
|
||||||
# 300 s) would cut a healthy stream mid-body, and events fired
|
|
||||||
# while reconnecting are not replayed — so the stream request
|
|
||||||
# is unbounded in total time; only connecting stays bounded.
|
|
||||||
stream_timeout = aiohttp.ClientTimeout(
|
|
||||||
total=None, sock_connect=self._timeout_seconds
|
|
||||||
)
|
|
||||||
# Opening the response gets ONE request timeout, exactly like
|
# Opening the response gets ONE request timeout, exactly like
|
||||||
# ``_request_json``; the read loop below runs WITHOUT one — a
|
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||||
# body-wide timeout would kill a healthy long-lived stream.
|
# body-wide timeout would kill a healthy long-lived stream.
|
||||||
@@ -306,7 +289,7 @@ class NestQuestApiClient:
|
|||||||
try:
|
try:
|
||||||
async with asyncio.timeout(self._timeout_seconds):
|
async with asyncio.timeout(self._timeout_seconds):
|
||||||
stream_cm = self._session.request(
|
stream_cm = self._session.request(
|
||||||
"GET", url, headers=headers, json=None, timeout=stream_timeout
|
"GET", url, headers=headers, json=None
|
||||||
)
|
)
|
||||||
response = await stream_cm.__aenter__()
|
response = await stream_cm.__aenter__()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -332,10 +315,7 @@ class NestQuestApiClient:
|
|||||||
event_type: str | None = None
|
event_type: str | None = None
|
||||||
data_lines: list[str] = []
|
data_lines: list[str] = []
|
||||||
buffer = ""
|
buffer = ""
|
||||||
# The body is ``response.content`` (aiohttp's StreamReader,
|
async for chunk in response:
|
||||||
# yielding bytes); the response object itself — HA's
|
|
||||||
# ``HassClientResponse`` included — is NOT async-iterable.
|
|
||||||
async for chunk in response.content:
|
|
||||||
if isinstance(chunk, (bytes, bytearray)):
|
if isinstance(chunk, (bytes, bytearray)):
|
||||||
chunk = chunk.decode("utf-8")
|
chunk = chunk.decode("utf-8")
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
blueprint:
|
|
||||||
name: NestQuest UniFi quest access
|
|
||||||
description: >-
|
|
||||||
One automation per child. Completely block the selected UniFi clients
|
|
||||||
after the local weekday/weekend cutoff while that child has quests
|
|
||||||
remaining TODAY. Restore network access immediately when the count
|
|
||||||
reaches zero, and at midnight even if yesterday was incomplete.
|
|
||||||
A missing or unavailable quest sensor is treated as still open after
|
|
||||||
the cutoff; verify the sensor before enabling this automation. The
|
|
||||||
selected UniFi client switches are ON when access is allowed and
|
|
||||||
OFF when blocked. Do not select infrastructure or adult devices.
|
|
||||||
domain: automation
|
|
||||||
input:
|
|
||||||
remaining_sensor:
|
|
||||||
name: Child's quests remaining today
|
|
||||||
description: Select this child's NestQuest quests remaining today sensor.
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
filter:
|
|
||||||
- integration: nestquest
|
|
||||||
domain: sensor
|
|
||||||
client_switches:
|
|
||||||
name: Child's UniFi client access switches
|
|
||||||
description: >-
|
|
||||||
Select every UniFi client access switch for this child. These
|
|
||||||
switches are ON when network access is allowed and OFF when blocked.
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
multiple: true
|
|
||||||
filter:
|
|
||||||
- integration: unifi
|
|
||||||
domain: switch
|
|
||||||
weekday_cutoff:
|
|
||||||
name: Monday-Friday cutoff
|
|
||||||
default: "18:25:00"
|
|
||||||
selector:
|
|
||||||
time: {}
|
|
||||||
weekend_cutoff:
|
|
||||||
name: Saturday-Sunday cutoff
|
|
||||||
default: "14:00:00"
|
|
||||||
selector:
|
|
||||||
time: {}
|
|
||||||
|
|
||||||
mode: restart
|
|
||||||
|
|
||||||
variables:
|
|
||||||
remaining_sensor: !input remaining_sensor
|
|
||||||
client_switches: !input client_switches
|
|
||||||
weekday_cutoff: !input weekday_cutoff
|
|
||||||
weekend_cutoff: !input weekend_cutoff
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- trigger: state
|
|
||||||
entity_id: !input remaining_sensor
|
|
||||||
- trigger: time
|
|
||||||
at: !input weekday_cutoff
|
|
||||||
- trigger: time
|
|
||||||
at: !input weekend_cutoff
|
|
||||||
- trigger: time
|
|
||||||
at: "00:00:00"
|
|
||||||
- trigger: homeassistant
|
|
||||||
event: start
|
|
||||||
# Reconcile after missed events or a temporary UniFi outage.
|
|
||||||
- trigger: time_pattern
|
|
||||||
minutes: "/5"
|
|
||||||
|
|
||||||
conditions:
|
|
||||||
- condition: template
|
|
||||||
value_template: "{{ client_switches | count > 0 }}"
|
|
||||||
|
|
||||||
actions:
|
|
||||||
- variables:
|
|
||||||
should_block: >-
|
|
||||||
{{ now().strftime('%H:%M') >=
|
|
||||||
(weekday_cutoff[:5] if now().weekday() < 5 else weekend_cutoff[:5])
|
|
||||||
and (states(remaining_sensor) | int(-1)) != 0 }}
|
|
||||||
- repeat:
|
|
||||||
for_each: "{{ client_switches }}"
|
|
||||||
sequence:
|
|
||||||
- choose:
|
|
||||||
- conditions:
|
|
||||||
- condition: template
|
|
||||||
value_template: >-
|
|
||||||
{{ should_block | bool and is_state(repeat.item, 'on') }}
|
|
||||||
sequence:
|
|
||||||
- action: switch.turn_off
|
|
||||||
target:
|
|
||||||
entity_id: "{{ repeat.item }}"
|
|
||||||
- conditions:
|
|
||||||
- condition: template
|
|
||||||
value_template: >-
|
|
||||||
{{ not (should_block | bool) and is_state(repeat.item, 'off') }}
|
|
||||||
sequence:
|
|
||||||
- action: switch.turn_on
|
|
||||||
target:
|
|
||||||
entity_id: "{{ repeat.item }}"
|
|
||||||
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 191 KiB |
@@ -15,5 +15,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.10.3"
|
"version": "0.9.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,25 +105,14 @@ Each definition carries one optional icon, shown on both surfaces. The picker of
|
|||||||
sources:
|
sources:
|
||||||
|
|
||||||
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
|
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
|
||||||
- **Font Awesome** — a search over the **full** Font Awesome Free *solid* set (every
|
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
|
||||||
glyph in `@fortawesome/free-solid-svg-icons`), plus a curated quick-pick grid, all drawn
|
drawn filled.
|
||||||
filled. With the search field empty the quick-pick grid shows; typing filters the full
|
|
||||||
set by name or label (an exact name ranks first, then name prefixes, then the rest),
|
|
||||||
shown 40 results per page with *Previous* / *Show more*.
|
|
||||||
**Free-text rule:** an admin may type a Free Solid icon name directly (trimmed,
|
|
||||||
case-insensitive — `Dog` stores `fa:dog`). Text that is not the exact name of a Free
|
|
||||||
Solid icon — a partial search, a misspelling, or a Pro or Brands name — is rejected in
|
|
||||||
the picker with an inline error and cannot be saved until a result is selected or the
|
|
||||||
full name is typed.
|
|
||||||
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
|
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
|
||||||
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
|
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
|
||||||
|
|
||||||
The curated Lucide set and the Font Awesome quick-pick list come from
|
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
|
||||||
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` generates their glyph path
|
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
|
||||||
data, together with the full Font Awesome Free Solid set, into the admin and panel bundles
|
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
|
||||||
(no CDN, no runtime fetch). Embedding the full set makes both bundles heavier — the panel
|
|
||||||
bundle (`nestquest-cards.js`) is about 1 MB raw / 276 kB gzip. See
|
|
||||||
`THIRD-PARTY-NOTICES.md` for licences.
|
|
||||||
|
|
||||||
The stored value is a **namespaced key**:
|
The stored value is a **namespaced key**:
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
|
|||||||
decide before shipping the card.
|
decide before shipping the card.
|
||||||
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
|
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
|
||||||
but the append-only event log already supports computing them later.
|
but the append-only event log already supports computing them later.
|
||||||
- Icon per quest definition is chosen by the parent from the curated Lucide set, the full
|
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
|
||||||
Font Awesome Free Solid set (searchable, with curated quick picks) or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
||||||
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
|
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
|
||||||
|
|||||||
@@ -106,8 +106,7 @@ collapse the column, the three-column rhythm is the layout.
|
|||||||
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
|
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
|
||||||
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
|
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
|
||||||
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
|
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
|
||||||
round caps and joins); `fa:<name>` draws the Font Awesome Free Solid glyph **filled** (any icon in the full
|
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
|
||||||
set, not only the quick picks);
|
|
||||||
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
|
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
|
||||||
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
|
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
|
||||||
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
|
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# NestQuest brand icons in Home Assistant
|
|
||||||
|
|
||||||
## Where the icon comes from
|
|
||||||
|
|
||||||
Since Home Assistant 2026.3, a custom integration can ship its own brand
|
|
||||||
images in `custom_components/<domain>/brand/`, and Home Assistant serves them
|
|
||||||
ahead of the brands CDN (`brands.home-assistant.io`). NestQuest ships:
|
|
||||||
|
|
||||||
| File | Size |
|
|
||||||
|------|------|
|
|
||||||
| `custom_components/nestquest/brand/icon.png` | 256×256 |
|
|
||||||
| `custom_components/nestquest/brand/icon@2x.png` | 512×512 |
|
|
||||||
| `custom_components/nestquest/brand/logo.png` | 256×256 |
|
|
||||||
| `custom_components/nestquest/brand/logo@2x.png` | 512×512 |
|
|
||||||
|
|
||||||
HACS installs the whole `custom_components/nestquest/` directory, so these
|
|
||||||
images arrive with the integration. On Home Assistant 2026.3 or newer they
|
|
||||||
appear on the integrations page and the integration tile, through Home
|
|
||||||
Assistant's brand endpoint. Older Home Assistant versions ignore the directory
|
|
||||||
and show the generic placeholder icon.
|
|
||||||
|
|
||||||
The `custom_integrations` folder of the `home-assistant/brands` repository is
|
|
||||||
legacy for this purpose; NestQuest does not depend on it.
|
|
||||||
|
|
||||||
## Known caveat: the HACS store card
|
|
||||||
|
|
||||||
The HACS store card (the repository listing inside HACS, shown before and
|
|
||||||
after installing) is rendered by HACS itself, not by the integration. It may
|
|
||||||
still take its image from the brands CDN or fall back to the avatar of the
|
|
||||||
repository owner (the `Maniacs-Mansion` organization). Whether HACS reads the
|
|
||||||
local `brand/` directory for that card has not been verified, so that icon
|
|
||||||
may stay generic. The repository page inside HACS shows the README, which
|
|
||||||
embeds the logo from `design/logos/NestQuest-NoBG.png`.
|
|
||||||
|
|
||||||
## Regenerating
|
|
||||||
|
|
||||||
The PNGs are generated offline and deterministically (pure Node, zlib only)
|
|
||||||
from `design/logos/NestQuest-NoBG.png`, box-filter downscaled with
|
|
||||||
transparency kept. From the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node design/logos/generate-brand-icons.mjs # write the four PNGs
|
|
||||||
node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
|
|
||||||
```
|
|
||||||
|
|
||||||
`tests/test_brand_icons.py` runs the `--check` and asserts the four files
|
|
||||||
exist with the expected sizes, so a missing or hand-edited image fails the
|
|
||||||
test suite.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// Generates the integration's local brand images in
|
|
||||||
// custom_components/nestquest/brand/ (read by Home Assistant 2026.3+) from the
|
|
||||||
// transparent logo (design/logos/NestQuest-NoBG.png), box-filter downscaled
|
|
||||||
// with alpha kept. Adapted from admin/scripts/generate-icons.mjs for RGBA.
|
|
||||||
// Pure Node (zlib only) — no network, no image dependencies.
|
|
||||||
// Run (from the repository root):
|
|
||||||
// node design/logos/generate-brand-icons.mjs # write the PNGs
|
|
||||||
// node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { deflateSync, inflateSync } from "node:zlib";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
|
|
||||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
||||||
let c = n;
|
|
||||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
return c >>> 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
function crc32(buf) {
|
|
||||||
let c = 0xffffffff;
|
|
||||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
||||||
return (c ^ 0xffffffff) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(type, data) {
|
|
||||||
const len = Buffer.alloc(4);
|
|
||||||
len.writeUInt32BE(data.length);
|
|
||||||
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
||||||
const crc = Buffer.alloc(4);
|
|
||||||
crc.writeUInt32BE(crc32(body));
|
|
||||||
return Buffer.concat([len, body, crc]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodePng(size, rgba) {
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(size, 0);
|
|
||||||
ihdr.writeUInt32BE(size, 4);
|
|
||||||
ihdr[8] = 8; // bit depth
|
|
||||||
ihdr[9] = 6; // colour type: RGBA
|
|
||||||
const stride = size * 4;
|
|
||||||
const raw = Buffer.alloc(size * (stride + 1));
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
raw[y * (stride + 1)] = 0; // filter: none
|
|
||||||
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
||||||
}
|
|
||||||
return Buffer.concat([
|
|
||||||
SIG,
|
|
||||||
chunk("IHDR", ihdr),
|
|
||||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal decoder for the source logo: 8-bit RGBA, non-interlaced only.
|
|
||||||
// Anything else fails loudly rather than producing a wrong icon.
|
|
||||||
function decodePng(buf) {
|
|
||||||
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
|
|
||||||
let width, height;
|
|
||||||
const idat = [];
|
|
||||||
for (let off = 8; off < buf.length; ) {
|
|
||||||
const len = buf.readUInt32BE(off);
|
|
||||||
const type = buf.toString("ascii", off + 4, off + 8);
|
|
||||||
const data = buf.subarray(off + 8, off + 8 + len);
|
|
||||||
if (type === "IHDR") {
|
|
||||||
width = data.readUInt32BE(0);
|
|
||||||
height = data.readUInt32BE(4);
|
|
||||||
const [depth, colour, , , interlace] = data.subarray(8);
|
|
||||||
if (depth !== 8 || colour !== 6 || interlace !== 0) {
|
|
||||||
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
|
|
||||||
}
|
|
||||||
} else if (type === "IDAT") idat.push(data);
|
|
||||||
else if (type === "IEND") break;
|
|
||||||
off += 12 + len;
|
|
||||||
}
|
|
||||||
const raw = inflateSync(Buffer.concat(idat));
|
|
||||||
const stride = width * 4;
|
|
||||||
const px = Buffer.alloc(stride * height);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
const filter = raw[y * (stride + 1)];
|
|
||||||
const src = y * (stride + 1) + 1;
|
|
||||||
const dst = y * stride;
|
|
||||||
for (let i = 0; i < stride; i++) {
|
|
||||||
const a = i >= 4 ? px[dst + i - 4] : 0;
|
|
||||||
const b = y > 0 ? px[dst - stride + i] : 0;
|
|
||||||
const c = i >= 4 && y > 0 ? px[dst - stride + i - 4] : 0;
|
|
||||||
let p;
|
|
||||||
if (filter === 0) p = 0;
|
|
||||||
else if (filter === 1) p = a;
|
|
||||||
else if (filter === 2) p = b;
|
|
||||||
else if (filter === 3) p = (a + b) >> 1;
|
|
||||||
else if (filter === 4) {
|
|
||||||
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
|
|
||||||
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
||||||
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
|
|
||||||
px[dst + i] = (raw[src + i] + p) & 0xff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (width !== height) throw new Error(`logo must be square, got ${width}x${height}`);
|
|
||||||
return { width, height, px };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box-filter (area-average) resize along one axis: each output sample is the
|
|
||||||
// coverage-weighted mean of the source samples it spans.
|
|
||||||
function boxWeights(srcLen, dstLen) {
|
|
||||||
const scale = srcLen / dstLen;
|
|
||||||
return Array.from({ length: dstLen }, (_, o) => {
|
|
||||||
const start = o * scale, end = start + scale;
|
|
||||||
const taps = [];
|
|
||||||
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
|
|
||||||
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
|
|
||||||
}
|
|
||||||
return taps;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Colour is averaged premultiplied by alpha, so fully transparent source
|
|
||||||
// pixels (whatever RGB they carry) cannot bleed a fringe into the edges.
|
|
||||||
function resize({ width, height, px }, size) {
|
|
||||||
const wx = boxWeights(width, size);
|
|
||||||
const wy = boxWeights(height, size);
|
|
||||||
const tmp = new Float64Array(size * height * 4); // horizontal pass, premultiplied
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
const t = (y * size + x) * 4;
|
|
||||||
for (const [s, w] of wx[x]) {
|
|
||||||
const p = (y * width + s) * 4;
|
|
||||||
const aw = px[p + 3] * w;
|
|
||||||
for (let k = 0; k < 3; k++) tmp[t + k] += px[p + k] * aw;
|
|
||||||
tmp[t + 3] += aw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const out = new Uint8Array(size * size * 4); // vertical pass, un-premultiply
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
const acc = [0, 0, 0, 0];
|
|
||||||
for (const [s, w] of wy[y]) {
|
|
||||||
const t = (s * size + x) * 4;
|
|
||||||
for (let k = 0; k < 4; k++) acc[k] += tmp[t + k] * w;
|
|
||||||
}
|
|
||||||
const o = (y * size + x) * 4;
|
|
||||||
const alpha = Math.min(255, Math.round(acc[3]));
|
|
||||||
for (let k = 0; k < 3; k++) {
|
|
||||||
out[o + k] = alpha === 0 ? 0 : Math.min(255, Math.round(acc[k] / acc[3]));
|
|
||||||
}
|
|
||||||
out[o + 3] = alpha;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logo = decodePng(readFileSync(new URL("NestQuest-NoBG.png", import.meta.url)));
|
|
||||||
const outDir = new URL("../../custom_components/nestquest/brand/", import.meta.url);
|
|
||||||
const files = [
|
|
||||||
["icon.png", 256],
|
|
||||||
["icon@2x.png", 512],
|
|
||||||
["logo.png", 256],
|
|
||||||
["logo@2x.png", 512],
|
|
||||||
];
|
|
||||||
const check = process.argv.includes("--check");
|
|
||||||
let drifted = false;
|
|
||||||
if (!check) mkdirSync(outDir, { recursive: true });
|
|
||||||
for (const [name, size] of files) {
|
|
||||||
const path = fileURLToPath(new URL(name, outDir));
|
|
||||||
const png = encodePng(size, resize(logo, size));
|
|
||||||
if (check) {
|
|
||||||
let current = null;
|
|
||||||
try {
|
|
||||||
current = readFileSync(path);
|
|
||||||
} catch {
|
|
||||||
// missing counts as drift
|
|
||||||
}
|
|
||||||
if (!current || !current.equals(png)) {
|
|
||||||
drifted = true;
|
|
||||||
console.error(`drift: ${path} differs from a fresh generation`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
writeFileSync(path, png);
|
|
||||||
console.log(`wrote ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (drifted) {
|
|
||||||
console.error("run `node design/logos/generate-brand-icons.mjs` and commit the result");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Lit rendering for the shared icon registry (`registry.generated.ts`,
|
* Lit rendering for the shared icon registry (`registry.generated.ts`,
|
||||||
* generated by tools/icons/generate.mjs: the curated Lucide set plus the full
|
* generated from tools/icons/curated-icons.json). The glyph path data is
|
||||||
* Font Awesome Free Solid set). The glyph path data is bundled — no CDN and
|
* bundled — no CDN and no runtime fetch.
|
||||||
* no runtime fetch.
|
|
||||||
*/
|
*/
|
||||||
import { html, svg, type TemplateResult } from "lit";
|
import { html, svg, type TemplateResult } from "lit";
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 375 KiB |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nestquest"
|
name = "nestquest"
|
||||||
version = "0.10.3"
|
version = "0.9.8"
|
||||||
description = "NestQuest Home Assistant Integration"
|
description = "NestQuest Home Assistant Integration"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -879,29 +879,6 @@ def set_coordinator_client(entry, client):
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FakeClientTimeout:
|
|
||||||
"""Stand-in for ``aiohttp.ClientTimeout`` (the fields the client sets)."""
|
|
||||||
|
|
||||||
total: float | None = None
|
|
||||||
sock_connect: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_aiohttp(monkeypatch):
|
|
||||||
"""Install a stand-in ``aiohttp`` module for one test.
|
|
||||||
|
|
||||||
aiohttp ships only with Home Assistant, so this mock-only harness
|
|
||||||
does not have it; the API client imports it lazily inside
|
|
||||||
``stream_events`` (for ``ClientTimeout``). Tests driving the real
|
|
||||||
stream path request this fixture; the module is removed again when
|
|
||||||
the test ends.
|
|
||||||
"""
|
|
||||||
module = SimpleNamespace(ClientTimeout=FakeClientTimeout)
|
|
||||||
monkeypatch.setitem(sys.modules, "aiohttp", module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def coordinator_client_from_test_override():
|
def coordinator_client_from_test_override():
|
||||||
"""Resolve every entry's coordinator client through the test seam.
|
"""Resolve every entry's coordinator client through the test seam.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import types
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from conftest import FakeClientTimeout, make_config_entry
|
from conftest import make_config_entry
|
||||||
|
|
||||||
from custom_components.nestquest import api_client
|
from custom_components.nestquest import api_client
|
||||||
from custom_components.nestquest.api_client import (
|
from custom_components.nestquest.api_client import (
|
||||||
@@ -33,10 +33,6 @@ from custom_components.nestquest.const import (
|
|||||||
DEFAULT_PANEL_TOKEN,
|
DEFAULT_PANEL_TOKEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ``stream_events`` imports aiohttp lazily; the harness has none, so
|
|
||||||
# every test here runs with the stand-in module installed.
|
|
||||||
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
|
|
||||||
|
|
||||||
BASE_URL = "http://api.test:8000"
|
BASE_URL = "http://api.test:8000"
|
||||||
TOKEN = "test-panel-token"
|
TOKEN = "test-panel-token"
|
||||||
|
|
||||||
@@ -128,7 +124,6 @@ class StubTransport:
|
|||||||
"url": url,
|
"url": url,
|
||||||
"headers": kwargs.get("headers"),
|
"headers": kwargs.get("headers"),
|
||||||
"json": kwargs.get("json"),
|
"json": kwargs.get("json"),
|
||||||
"timeout": kwargs.get("timeout"),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not self._outcomes:
|
if not self._outcomes:
|
||||||
@@ -477,109 +472,4 @@ async def test_client_logs_nothing() -> None:
|
|||||||
await client.get_snapshot()
|
await client.get_snapshot()
|
||||||
finally:
|
finally:
|
||||||
logging.getLogger().removeHandler(handler)
|
logging.getLogger().removeHandler(handler)
|
||||||
assert records == []
|
assert records == []
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# stream_events: the body is read from ``response.content``
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _StubStreamContent:
|
|
||||||
"""A fake aiohttp ``StreamReader``: async-iterable over byte chunks."""
|
|
||||||
|
|
||||||
def __init__(self, chunks) -> None:
|
|
||||||
self._chunks = list(chunks)
|
|
||||||
|
|
||||||
def __aiter__(self):
|
|
||||||
return self._iterate()
|
|
||||||
|
|
||||||
async def _iterate(self):
|
|
||||||
for chunk in self._chunks:
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
|
|
||||||
class StubStreamResponse:
|
|
||||||
"""A fake aiohttp/HA ``ClientResponse`` for a streaming body.
|
|
||||||
|
|
||||||
Like the real ``HassClientResponse`` it does NOT implement
|
|
||||||
``__aiter__`` — the body is iterated through ``.content`` — so a
|
|
||||||
client that iterates the response object itself raises the
|
|
||||||
``'async for' requires an object with __aiter__ method`` TypeError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, status: int, chunks) -> None:
|
|
||||||
self.status = status
|
|
||||||
self.content = _StubStreamContent(chunks)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_events_reads_frames_from_response_content() -> None:
|
|
||||||
"""Frames are parsed from ``response.content`` byte chunks, including
|
|
||||||
a frame split across chunk boundaries; a trailing incomplete frame
|
|
||||||
is discarded."""
|
|
||||||
assert not hasattr(StubStreamResponse, "__aiter__")
|
|
||||||
chunks = [
|
|
||||||
b'event: nestquest_quest_completed\ndata: {"instance_id"',
|
|
||||||
b': 7}\n',
|
|
||||||
b'\nevent: nestquest_child_day_complete\n',
|
|
||||||
b'data: {"child_id": 1}\n\n',
|
|
||||||
b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n',
|
|
||||||
]
|
|
||||||
transport = StubTransport(StubStreamResponse(200, chunks))
|
|
||||||
client = make_client(transport)
|
|
||||||
|
|
||||||
frames = [frame async for frame in client.stream_events()]
|
|
||||||
|
|
||||||
assert frames == [
|
|
||||||
("nestquest_quest_completed", {"instance_id": 7}),
|
|
||||||
("nestquest_child_day_complete", {"child_id": 1}),
|
|
||||||
]
|
|
||||||
assert transport.calls[0]["method"] == "GET"
|
|
||||||
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_events_non_2xx_raises_typed_with_status() -> None:
|
|
||||||
transport = StubTransport(StubStreamResponse(502, []))
|
|
||||||
client = make_client(transport)
|
|
||||||
|
|
||||||
with pytest.raises(NestQuestApiError) as excinfo:
|
|
||||||
async for _ in client.stream_events():
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert excinfo.value.status == 502
|
|
||||||
assert not excinfo.value.is_transport_error
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_events_invalid_json_frame_raises_typed() -> None:
|
|
||||||
transport = StubTransport(
|
|
||||||
StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"])
|
|
||||||
)
|
|
||||||
client = make_client(transport)
|
|
||||||
|
|
||||||
with pytest.raises(NestQuestApiError) as excinfo:
|
|
||||||
async for _ in client.stream_events():
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert excinfo.value.status == 200
|
|
||||||
assert not excinfo.value.is_transport_error
|
|
||||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_events_disables_the_session_total_timeout() -> None:
|
|
||||||
"""The stream request overrides the session's default total timeout
|
|
||||||
(HA's shared session: 300 s, which would cut a healthy stream) with
|
|
||||||
``total=None`` while keeping a finite connect timeout; the plain
|
|
||||||
request calls pass no per-request timeout."""
|
|
||||||
transport = StubTransport(
|
|
||||||
StubStreamResponse(200, []), StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
|
|
||||||
)
|
|
||||||
client = make_client(transport, timeout_seconds=7.0)
|
|
||||||
|
|
||||||
frames = [frame async for frame in client.stream_events()]
|
|
||||||
await client.get_snapshot()
|
|
||||||
|
|
||||||
assert frames == []
|
|
||||||
stream_timeout = transport.calls[0]["timeout"]
|
|
||||||
assert isinstance(stream_timeout, FakeClientTimeout)
|
|
||||||
assert stream_timeout.total is None
|
|
||||||
assert stream_timeout.sock_connect == 7.0
|
|
||||||
assert transport.calls[1]["timeout"] is None
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Behavior and wiring tests for the UniFi quest-access blueprint."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.blueprint_helpers import BlueprintInput, blueprint, render
|
|
||||||
|
|
||||||
|
|
||||||
def _blueprint() -> dict:
|
|
||||||
return blueprint("unifi_quest_access")
|
|
||||||
|
|
||||||
|
|
||||||
def test_inputs_and_reconciliation_triggers() -> None:
|
|
||||||
bp = _blueprint()
|
|
||||||
inputs = bp["blueprint"]["input"]
|
|
||||||
assert set(inputs) == {
|
|
||||||
"remaining_sensor",
|
|
||||||
"present_sensor",
|
|
||||||
"client_switches",
|
|
||||||
"weekday_cutoff",
|
|
||||||
"weekend_cutoff",
|
|
||||||
}
|
|
||||||
assert inputs["weekday_cutoff"]["default"] == "18:25:00"
|
|
||||||
assert inputs["weekend_cutoff"]["default"] == "14:00:00"
|
|
||||||
assert inputs["remaining_sensor"]["selector"]["entity"]["filter"] == [
|
|
||||||
{"integration": "nestquest", "domain": "sensor"}
|
|
||||||
]
|
|
||||||
assert inputs["present_sensor"]["selector"]["entity"]["filter"] == [
|
|
||||||
{"integration": "nestquest", "domain": "binary_sensor"}
|
|
||||||
]
|
|
||||||
assert inputs["client_switches"]["selector"]["entity"] == {
|
|
||||||
"multiple": True,
|
|
||||||
"filter": [{"integration": "unifi", "domain": "switch"}],
|
|
||||||
}
|
|
||||||
assert bp["variables"]["remaining_sensor"] == BlueprintInput(
|
|
||||||
"remaining_sensor"
|
|
||||||
)
|
|
||||||
assert bp["variables"]["present_sensor"] == BlueprintInput(
|
|
||||||
"present_sensor"
|
|
||||||
)
|
|
||||||
assert bp["variables"]["client_switches"] == BlueprintInput(
|
|
||||||
"client_switches"
|
|
||||||
)
|
|
||||||
assert bp["mode"] == "restart"
|
|
||||||
|
|
||||||
triggers = bp["triggers"]
|
|
||||||
assert {trigger["trigger"] for trigger in triggers} == {
|
|
||||||
"state",
|
|
||||||
"time",
|
|
||||||
"homeassistant",
|
|
||||||
"time_pattern",
|
|
||||||
}
|
|
||||||
assert {trigger["at"] for trigger in triggers if trigger["trigger"] == "time"} == {
|
|
||||||
BlueprintInput("weekday_cutoff"),
|
|
||||||
BlueprintInput("weekend_cutoff"),
|
|
||||||
"00:00:00",
|
|
||||||
}
|
|
||||||
assert any(
|
|
||||||
trigger.get("entity_id") == BlueprintInput("remaining_sensor")
|
|
||||||
for trigger in triggers
|
|
||||||
)
|
|
||||||
assert any(
|
|
||||||
trigger.get("entity_id") == BlueprintInput("present_sensor")
|
|
||||||
for trigger in triggers
|
|
||||||
)
|
|
||||||
assert any(
|
|
||||||
trigger.get("event") == "start" for trigger in triggers
|
|
||||||
)
|
|
||||||
assert any(
|
|
||||||
trigger.get("minutes") == "/5" for trigger in triggers
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("when", "remaining", "presence", "should_block"),
|
|
||||||
[
|
|
||||||
(datetime(2026, 10, 19, 18, 24), "2", "on", False), # Monday, pre-cutoff
|
|
||||||
(datetime(2026, 10, 19, 18, 25), "2", "on", True),
|
|
||||||
(datetime(2026, 10, 19, 19, 0), "0", "on", False),
|
|
||||||
(datetime(2026, 10, 19, 19, 0), "unknown", "on", True),
|
|
||||||
(datetime(2026, 10, 19, 19, 0), "2", "off", False), # On travels
|
|
||||||
(datetime(2026, 10, 19, 19, 0), "2", "unavailable", False),
|
|
||||||
(datetime(2026, 10, 17, 13, 59), "1", "on", False), # Saturday
|
|
||||||
(datetime(2026, 10, 17, 14, 0), "1", "on", True),
|
|
||||||
(datetime(2026, 10, 18, 14, 0), "0", "on", False), # Sunday
|
|
||||||
(datetime(2026, 10, 20, 0, 0), "1", "on", False), # no carry-over
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_access_decision(
|
|
||||||
when: datetime, remaining: str, presence: str, should_block: bool
|
|
||||||
) -> None:
|
|
||||||
decision = _blueprint()["actions"][0]["variables"]["should_block"]
|
|
||||||
actual = render(
|
|
||||||
decision,
|
|
||||||
now=lambda: when,
|
|
||||||
states=lambda entity_id: remaining,
|
|
||||||
is_state=lambda entity_id, state: presence == state,
|
|
||||||
remaining_sensor="sensor.selected_child",
|
|
||||||
present_sensor="binary_sensor.selected_child_present_today",
|
|
||||||
weekday_cutoff="18:25:00",
|
|
||||||
weekend_cutoff="14:00:00",
|
|
||||||
).strip()
|
|
||||||
assert actual == str(should_block)
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_out_of_sync_clients_receive_commands() -> None:
|
|
||||||
bp = _blueprint()
|
|
||||||
loop = bp["actions"][1]["repeat"]
|
|
||||||
assert loop["for_each"] == "{{ client_switches }}"
|
|
||||||
choices = loop["sequence"][0]["choose"]
|
|
||||||
assert len(choices) == 2
|
|
||||||
block, allow = choices
|
|
||||||
assert "is_state(repeat.item, 'on')" in block["conditions"][0]["value_template"]
|
|
||||||
assert block["sequence"][0]["action"] == "switch.turn_off"
|
|
||||||
assert "is_state(repeat.item, 'off')" in allow["conditions"][0]["value_template"]
|
|
||||||
assert allow["sequence"][0]["action"] == "switch.turn_on"
|
|
||||||
assert all(
|
|
||||||
choice["sequence"][0]["target"]["entity_id"] == "{{ repeat.item }}"
|
|
||||||
for choice in choices
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""The integration's local brand images (Home Assistant 2026.3+).
|
|
||||||
|
|
||||||
``custom_components/nestquest/brand/`` ships through HACS with the
|
|
||||||
integration, and Home Assistant serves those images ahead of the brands CDN.
|
|
||||||
They are generated offline from ``design/logos/NestQuest-NoBG.png`` by
|
|
||||||
``design/logos/generate-brand-icons.mjs``; these checks fail if the directory
|
|
||||||
goes missing, changes shape, or drifts from a fresh generation.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parent.parent
|
|
||||||
BRAND_DIR = REPO_ROOT / "custom_components" / "nestquest" / "brand"
|
|
||||||
GENERATOR = REPO_ROOT / "design" / "logos" / "generate-brand-icons.mjs"
|
|
||||||
EXPECTED = {
|
|
||||||
"icon.png": 256,
|
|
||||||
"icon@2x.png": 512,
|
|
||||||
"logo.png": 256,
|
|
||||||
"logo@2x.png": 512,
|
|
||||||
}
|
|
||||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_brand_dir_holds_exactly_the_four_images() -> None:
|
|
||||||
assert sorted(p.name for p in BRAND_DIR.iterdir()) == sorted(EXPECTED)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("name", "size"), sorted(EXPECTED.items()))
|
|
||||||
def test_brand_image_is_square_rgba_png(name: str, size: int) -> None:
|
|
||||||
data = (BRAND_DIR / name).read_bytes()
|
|
||||||
assert data[:8] == PNG_SIGNATURE
|
|
||||||
assert data[12:16] == b"IHDR"
|
|
||||||
width, height, depth, colour = struct.unpack(">IIBB", data[16:26])
|
|
||||||
assert (width, height) == (size, size)
|
|
||||||
assert (depth, colour) == (8, 6) # 8-bit RGBA keeps the transparency
|
|
||||||
|
|
||||||
|
|
||||||
def test_generator_reproduces_the_committed_images() -> None:
|
|
||||||
node = shutil.which("node")
|
|
||||||
if node is None:
|
|
||||||
pytest.skip("needs node")
|
|
||||||
result = subprocess.run(
|
|
||||||
[node, str(GENERATOR), "--check"],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
@@ -1431,26 +1431,6 @@ def test_open_quest_tile_falls_back_to_the_star() -> None:
|
|||||||
assert tile["icon"] is None
|
assert tile["icon"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_open_quest_tile_renders_any_free_solid_icon() -> None:
|
|
||||||
"""A Font Awesome Free Solid name outside the curated quick-pick list
|
|
||||||
still renders its glyph from the shipped bundle; an unknown ``fa:`` name
|
|
||||||
keeps the star."""
|
|
||||||
tiles = _quest_log_tiles(
|
|
||||||
[
|
|
||||||
_quest_row(1, "fa:rocket"),
|
|
||||||
_quest_row(2, "fa:mug-hot"),
|
|
||||||
_quest_row(3, "fa:definitely-not-an-icon"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")):
|
|
||||||
assert tiles[instance_id]["icon"] == key
|
|
||||||
assert tiles[instance_id]["fill"] == "currentColor"
|
|
||||||
assert tiles[instance_id]["paths"] > 0
|
|
||||||
assert not tiles[instance_id]["star"]
|
|
||||||
assert tiles[3]["star"]
|
|
||||||
assert tiles[3]["icon"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_sealed_quest_tile_keeps_the_star() -> None:
|
def test_sealed_quest_tile_keeps_the_star() -> None:
|
||||||
"""A sealed card keeps its star tile (and its wax seal) whatever the
|
"""A sealed card keeps its star tile (and its wax seal) whatever the
|
||||||
definition's icon; only the open card shows the quest's icon."""
|
definition's icon; only the open card shows the quest's icon."""
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ from custom_components.nestquest.api_client import (
|
|||||||
)
|
)
|
||||||
from custom_components.nestquest.sse import NestQuestEventStream
|
from custom_components.nestquest.sse import NestQuestEventStream
|
||||||
|
|
||||||
# ``stream_events`` imports aiohttp lazily; the harness has none, so
|
|
||||||
# every test here runs with the stand-in module installed.
|
|
||||||
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
|
|
||||||
|
|
||||||
BASE_URL = "http://api.test:8000"
|
BASE_URL = "http://api.test:8000"
|
||||||
TOKEN = "test-panel-token"
|
TOKEN = "test-panel-token"
|
||||||
|
|
||||||
@@ -169,29 +165,19 @@ MINIMAL_SNAPSHOT = {
|
|||||||
|
|
||||||
|
|
||||||
class _StreamingResponse:
|
class _StreamingResponse:
|
||||||
"""A fake aiohttp streaming response: ``status`` + ``.content``.
|
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
|
||||||
|
|
||||||
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
|
``chunks`` are the body pieces the stream yields in order; a chunk
|
||||||
the response itself is NOT async-iterable; the body is iterated
|
may be an exception, raised when the iteration reaches it (a
|
||||||
through ``.content``. ``chunks`` are the body pieces the stream
|
mid-stream transport drop). ``hold_open`` keeps the stream open
|
||||||
yields in order; a chunk may be an exception, raised when the
|
forever after the chunks run out, for cancellation tests.
|
||||||
iteration reaches it (a mid-stream transport drop). ``hold_open``
|
|
||||||
keeps the stream open forever after the chunks run out, for
|
|
||||||
cancellation tests.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
||||||
self.status = status
|
self.status = status
|
||||||
self.content = _StreamingContent(chunks, hold_open)
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
|
|
||||||
class _StreamingContent:
|
|
||||||
"""The fake response body: an async-iterable chunk stream."""
|
|
||||||
|
|
||||||
def __init__(self, chunks, hold_open: bool) -> None:
|
|
||||||
self._chunks = list(chunks)
|
self._chunks = list(chunks)
|
||||||
self._hold_open = hold_open
|
self._hold_open = hold_open
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
def __aiter__(self):
|
def __aiter__(self):
|
||||||
return self._iterate()
|
return self._iterate()
|
||||||
|
|||||||
@@ -16,15 +16,13 @@
|
|||||||
* Lucide circles, rects and lines are converted to path `d` strings so every
|
* Lucide circles, rects and lines are converted to path `d` strings so every
|
||||||
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
|
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
|
||||||
*
|
*
|
||||||
* Besides the curated sets (the Lucide set and the Font Awesome quick picks),
|
* Besides the curated sets, the module embeds the complete Font Awesome Free
|
||||||
* the module embeds the complete Font Awesome Free Solid set (`FA_SOLID_ICONS`),
|
* Solid set (`FA_SOLID_ICONS`). Its source is the installed
|
||||||
* which is what `fa:<name>` resolves against. Its source is the installed
|
|
||||||
* @fortawesome/free-solid-svg-icons package itself: every exported icon
|
* @fortawesome/free-solid-svg-icons package itself: every exported icon
|
||||||
* definition, de-duplicated by canonical `iconName` (the package also exports
|
* definition, de-duplicated by canonical `iconName` (the package also exports
|
||||||
* each legacy alias, e.g. faHome for `house`, as the same definition). The
|
* each legacy alias, e.g. faHome for `house`, as the same definition). The
|
||||||
* package carries no human labels, so each label is the kebab name in Title
|
* package carries no human labels, so each label is the kebab name in Title
|
||||||
* Case (`spray-can-sparkles` → "Spray Can Sparkles"). Embedding the full set
|
* Case (`spray-can-sparkles` → "Spray Can Sparkles").
|
||||||
* makes both bundles heavier: the panel bundle is about 1 MB raw / 276 kB gzip.
|
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
|||||||