domma-cms 1.6.37 → 1.6.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/blog/CLAUDE.md +11 -1
  5. package/plugins/blog/lib/cache.js +43 -0
  6. package/plugins/blog/lib/page.js +28 -9
  7. package/plugins/blog/plugin.js +14 -13
  8. package/plugins/blog/plugin.json +1 -1
  9. package/plugins/blog/plugin.public.js +1 -1
  10. package/plugins/blog/tests/cache.test.js +65 -0
  11. package/plugins/booking/CLAUDE.md +14 -2
  12. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  13. package/plugins/booking/admin/views/diary.js +153 -152
  14. package/plugins/booking/admin/views/people.js +64 -66
  15. package/plugins/booking/admin/views/settings.js +31 -32
  16. package/plugins/booking/plugin.json +1 -1
  17. package/plugins/booking/tests/admin-diary.test.js +9 -1
  18. package/plugins/feedback/CLAUDE.md +55 -0
  19. package/plugins/feedback/admin/css/index.css +16 -1
  20. package/plugins/feedback/admin/lib/kit.js +106 -0
  21. package/plugins/feedback/admin/lib/settings.js +107 -0
  22. package/plugins/feedback/admin/views/feedback.js +4 -1
  23. package/plugins/feedback/admin/views/inbox.js +11 -2
  24. package/plugins/feedback/admin/views/send.js +74 -7
  25. package/plugins/feedback/docs/guide.md +9 -9
  26. package/plugins/feedback/lib/attachments.js +133 -0
  27. package/plugins/feedback/lib/cache.js +43 -0
  28. package/plugins/feedback/lib/receiver.js +36 -6
  29. package/plugins/feedback/lib/sender.js +70 -6
  30. package/plugins/feedback/lib/shape.js +111 -6
  31. package/plugins/feedback/plugin.js +171 -17
  32. package/plugins/feedback/plugin.json +2 -2
  33. package/plugins/feedback/tests/api.test.js +125 -3
  34. package/plugins/feedback/tests/settings.test.js +59 -0
  35. package/plugins/feedback/tests/shape.test.js +64 -0
  36. package/plugins/free-tier.lock.json +73 -61
  37. package/plugins/mail-reader/CLAUDE.md +15 -4
  38. package/plugins/mail-reader/plugin.json +1 -1
  39. package/plugins/shopping-cart/CLAUDE.md +28 -2
  40. package/plugins/shopping-cart/admin/css/index.css +3 -0
  41. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  42. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  43. package/plugins/shopping-cart/docs/guide.md +7 -3
  44. package/plugins/shopping-cart/lib/payments.js +15 -2
  45. package/plugins/shopping-cart/lib/refunds.js +51 -0
  46. package/plugins/shopping-cart/lib/sales.js +15 -8
  47. package/plugins/shopping-cart/lib/shop.js +38 -3
  48. package/plugins/shopping-cart/plugin.js +47 -19
  49. package/plugins/shopping-cart/plugin.json +2 -2
  50. package/plugins/shopping-cart/tests/api.test.js +52 -0
  51. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  52. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  53. package/plugins/support/CLAUDE.md +51 -0
  54. package/plugins/support/admin/css/index.css +16 -1
  55. package/plugins/support/admin/lib/kit.js +192 -8
  56. package/plugins/support/admin/lib/settings.js +123 -0
  57. package/plugins/support/admin/views/desk.js +55 -12
  58. package/plugins/support/admin/views/inbox.js +17 -4
  59. package/plugins/support/admin/views/support.js +4 -1
  60. package/plugins/support/docs/guide.md +7 -9
  61. package/plugins/support/lib/attachments.js +133 -0
  62. package/plugins/support/lib/cache.js +43 -0
  63. package/plugins/support/lib/receiver.js +45 -10
  64. package/plugins/support/lib/sender.js +81 -10
  65. package/plugins/support/lib/shape.js +153 -12
  66. package/plugins/support/plugin.js +159 -29
  67. package/plugins/support/plugin.json +2 -2
  68. package/plugins/support/tests/api.test.js +125 -3
  69. package/plugins/support/tests/settings.test.js +68 -0
  70. package/plugins/support/tests/shape.test.js +51 -1
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.51.11
2
+ * Domma Tools CSS v0.51.12
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-12T06:05:33.864Z
6
- * Commit: a53de2f
5
+ * Built: 2026-10-13T13:46:21.895Z
6
+ * Commit: ce9f5f1
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.51.11
2
+ * Domma Tools v0.51.12
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-12T06:05:20.912Z
6
- * Commit: a53de2f
5
+ * Built: 2026-10-13T13:46:07.193Z
6
+ * Commit: ce9f5f1
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.37",
3
+ "version": "1.6.38",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -84,7 +84,7 @@
84
84
  "@fastify/rate-limit": "^10.3.0",
85
85
  "@fastify/static": "9.1.1",
86
86
  "bcryptjs": "^3.0.3",
87
- "domma-js": "0.51.11",
87
+ "domma-js": "0.51.12",
88
88
  "dotenv": "^17.2.3",
89
89
  "fastify": "5.8.5",
90
90
  "gray-matter": "^4.0.3",
@@ -20,7 +20,8 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
20
20
  - `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
21
21
  - `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
22
22
  - `public/samples/*.svg` - sample covers
23
- - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
23
+ - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`, `tests/cache.test.js`
24
+ - `lib/cache.js` - PURE: the 30 s read cache (forgotten on every write route)
24
25
  - `admin/views/blog.js` - post list (KPIs + status/category/search filters)
25
26
  - `admin/views/post-editor.js` - create / edit a post
26
27
  - `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
@@ -329,3 +330,12 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
329
330
  tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
330
331
  used to raise a staff notification.
331
332
  - `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
333
+
334
+ ## 1.9.7 (2026-10-13 Tool sweep - caching)
335
+
336
+ - **Whole-collection reads are cached 30 s** (`lib/cache.js`, pure, `tests/cache.test.js`): `loadEntries()` in
337
+ lib/page.js (public pages, shortcodes, the sitemap source, the `/comments/pending-count` badge) and
338
+ `resolveAuthorName()` (one user lookup per author per window). The promise is kept, so concurrent misses share one
339
+ read. **Every non-GET route of this plugin forgets it in an `onSend` hook** - before the writer's response leaves -
340
+ so the admin's own next read, and the public POST /comments, are fresh. The TTL only covers writes made outside
341
+ the plugin (Collections tool, Blog Pro). Callers share the cached array: filter/sort copies, never mutate it.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A short memory for whole-collection reads.
3
+ *
4
+ * Every public page view and every sidebar badge poll (once a minute from
5
+ * every open admin tab) read ALL posts, categories and comments from the
6
+ * adapter. The data changes only when someone writes, so a read is kept for a
7
+ * little while and every write route of this plugin throws the memory away
8
+ * (`forget()`, from an onSend hook in plugin.js - before the writer's own
9
+ * response leaves, so their next read is fresh). The TTL is the safety net
10
+ * for writes this plugin never sees (the Collections tool, a Pro take-over).
11
+ *
12
+ * PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
13
+ *
14
+ * @module blog/lib/cache
15
+ */
16
+
17
+ export const CACHE_MS = 30_000;
18
+
19
+ /**
20
+ * @param {{ttl?: number, clock?: () => number}} [opts]
21
+ * @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
22
+ */
23
+ export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
24
+ // key -> {at, value: Promise}. The PROMISE is kept, not the result, so
25
+ // concurrent misses share one read, and a write landing while a read is in
26
+ // flight clears the map before that read can be stored - nothing stale
27
+ // survives a forget().
28
+ const kept = new Map();
29
+ return {
30
+ get(key, load) {
31
+ const hit = kept.get(key);
32
+ if (hit && clock() - hit.at < ttl) return hit.value;
33
+ const value = Promise.resolve().then(load);
34
+ const entry = {at: clock(), value};
35
+ kept.set(key, entry);
36
+ // A failed read is not remembered: the next caller tries again.
37
+ value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
38
+ return value;
39
+ },
40
+ forget() { kept.clear(); },
41
+ get size() { return kept.size; }
42
+ };
43
+ }
@@ -13,6 +13,13 @@ import defaultConfig from '../config.js';
13
13
  import {fmtDate} from '../../_lib/admin/ui/dates.js';
14
14
  import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
15
15
  import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
16
+ import {createCache} from './cache.js';
17
+
18
+ // Whole-collection reads and author names, kept a short while (lib/cache.js):
19
+ // every public page view and every badge poll read all posts, categories and
20
+ // comments. plugin.js calls forgetEntries() on every write route of this plugin.
21
+ const remembered = createCache();
22
+ export const forgetEntries = () => remembered.forget();
16
23
 
17
24
  /**
18
25
  * Whether readers see a post. Blog Pro files posts under several blogs
@@ -69,12 +76,21 @@ export function liveBase(registered) {
69
76
  }
70
77
 
71
78
  /** Always returns the actual array of entries, regardless of adapter shape. */
72
- export async function loadEntries(slug) {
79
+ async function readEntries(slug) {
73
80
  const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
74
81
  const arr = Array.isArray(result) ? result : (Array.isArray(result?.entries) ? result.entries : []);
75
82
  return arr.map((entry) => ({id: entry.id, ...(entry.data ?? entry)}));
76
83
  }
77
84
 
85
+ /**
86
+ * A collection's entries, newest first - from the cache when a read is fresh.
87
+ * Callers get the SAME array, so none may change it in place (they filter and
88
+ * sort copies, as before).
89
+ */
90
+ export function loadEntries(slug) {
91
+ return remembered.get(`entries:${slug}`, () => readEntries(slug));
92
+ }
93
+
78
94
  /** Render a post body through the full Markdown + shortcode pipeline. */
79
95
  export async function renderPostBody(content) {
80
96
  if (!content) return {html: '', usedComponents: []};
@@ -87,14 +103,17 @@ export async function renderPostBody(content) {
87
103
  }
88
104
  }
89
105
 
90
- export async function resolveAuthorName(authorId) {
91
- if (!authorId) return 'Unknown';
92
- try {
93
- const user = await getUserById(authorId);
94
- return user?.name || user?.email || 'Unknown';
95
- } catch {
96
- return 'Unknown';
97
- }
106
+ /** A user's display name - one lookup per author per cache window, not per card. */
107
+ export function resolveAuthorName(authorId) {
108
+ if (!authorId) return Promise.resolve('Unknown');
109
+ return remembered.get(`author:${authorId}`, async () => {
110
+ try {
111
+ const user = await getUserById(authorId);
112
+ return user?.name || user?.email || 'Unknown';
113
+ } catch {
114
+ return 'Unknown';
115
+ }
116
+ });
98
117
  }
99
118
 
100
119
  /** One lookup per author, however many posts they wrote. */
@@ -5,7 +5,7 @@ import {getPluginStates, savePluginState} from '../../server/services/plugins.js
5
5
  import {renderBlogPage} from '../../server/services/renderer.js';
6
6
  import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
7
  import {catalogue, seedBlocks} from './lib/render.js';
8
- import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
8
+ import {forgetEntries, liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
9
9
  import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
10
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
11
  import {commentKey, commentNotice} from './lib/notices.js';
@@ -167,17 +167,9 @@ function isVisible(post) {
167
167
  || (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
168
168
  }
169
169
 
170
- async function loadPosts() {
171
- const result = await listEntries(POSTS_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' });
172
- const arr = Array.isArray(result) ? result : (result?.entries || []);
173
- return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
174
- }
175
-
176
- async function loadCategories() {
177
- const result = await listEntries(CATEGORIES_SLUG, { limit: 10000 });
178
- const arr = Array.isArray(result) ? result : (result?.entries || []);
179
- return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
180
- }
170
+ // The shortcodes run on public page views: the same cached reads as the pages.
171
+ const loadPosts = () => loadAll(POSTS_SLUG);
172
+ const loadCategories = () => loadAll(CATEGORIES_SLUG);
181
173
 
182
174
  function selectPosts(posts, { limit, categorySlug, categoryMap }) {
183
175
  let filtered = posts.filter(isVisible);
@@ -337,6 +329,15 @@ export default async function blogPlugin(fastify, options) {
337
329
  });
338
330
  }
339
331
 
332
+ // Every write route of this plugin (posts, categories, comments - the public
333
+ // POST /comments too - samples) retires the cached reads behind the public
334
+ // pages, the shortcodes and the badge. onSend, not onResponse: the memory is
335
+ // gone BEFORE the writer's response leaves, so their very next read is fresh.
336
+ // The hook is encapsulated - only this plugin's routes reach it.
337
+ fastify.addHook('onSend', async (request) => {
338
+ if (request.method !== 'GET' && request.method !== 'HEAD') forgetEntries();
339
+ });
340
+
340
341
  // The layouts are blocks; put the shipped ones where the Blocks tool can
341
342
  // edit them. Once each, never over a site's own copy (lib/render.js).
342
343
  try {
@@ -392,7 +393,7 @@ export default async function blogPlugin(fastify, options) {
392
393
  });
393
394
  const html = await renderBlogPage(
394
395
  fileURLToPath(new URL('./templates/post.html', import.meta.url)),
395
- { post: page.html, assetV: '1.9.6' },
396
+ { post: page.html, assetV: '1.9.7' },
396
397
  { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
397
398
  usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
398
399
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.9.6",
4
+ "version": "1.9.7",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
@@ -24,7 +24,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
24
24
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
25
25
 
26
26
  /** Bumped with the plugin, so a release is never served a stale script. */
27
- const ASSET_V = '1.9.6';
27
+ const ASSET_V = '1.9.7';
28
28
 
29
29
  /**
30
30
  * Above this many posts a listing pages on the server instead: the browser
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Blog - the short memory behind the public pages and the badge (lib/cache.js),
3
+ * and the rule that matters: a write is seen by the very next read.
4
+ */
5
+ import {test} from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import Fastify from 'fastify';
8
+ import {createCache} from '../lib/cache.js';
9
+
10
+ test('a read is shared for the cache window, then read again', async () => {
11
+ let now = 0;
12
+ let reads = 0;
13
+ const c = createCache({ttl: 30_000, clock: () => now});
14
+ const load = async () => { reads++; return ['post']; };
15
+ const [a, b] = await Promise.all([c.get('posts', load), c.get('posts', load)]);
16
+ assert.equal(reads, 1, 'concurrent misses share one read');
17
+ assert.equal(a, b, 'the same array - callers must not change it in place');
18
+ now = 29_999;
19
+ await c.get('posts', load);
20
+ assert.equal(reads, 1);
21
+ now = 30_000;
22
+ await c.get('posts', load);
23
+ assert.equal(reads, 2, 'the window passed: read again');
24
+ });
25
+
26
+ test('forget() retires every key, and a read in flight when the write landed is not kept', async () => {
27
+ const c = createCache({ttl: 60_000});
28
+ let value = 'old';
29
+ let release;
30
+ const slow = () => new Promise(r => { release = r; });
31
+ const inFlight = c.get('posts', slow);
32
+ await c.get('categories', async () => ['cat']);
33
+ assert.equal(c.size, 2);
34
+ // The write finishes while the read is still on its way.
35
+ value = 'new';
36
+ c.forget();
37
+ release('old');
38
+ assert.equal(await inFlight, 'old', 'the caller who started before the write still gets what they read');
39
+ assert.equal(await c.get('posts', async () => value), 'new', 'the next read is fresh, not the stale one');
40
+ assert.deepEqual(await c.get('categories', async () => ['fresh']), ['fresh']);
41
+ });
42
+
43
+ test('a failed read is not remembered', async () => {
44
+ const c = createCache();
45
+ await assert.rejects(() => c.get('posts', async () => { throw new Error('adapter down'); }), /adapter down/);
46
+ assert.equal(c.size, 0);
47
+ assert.deepEqual(await c.get('posts', async () => ['ok']), ['ok']);
48
+ });
49
+
50
+ test('the plugin forgets on every write route, before the writer\'s response leaves', async () => {
51
+ // The hook as plugin.js registers it, on a stand-in app: the point under test
52
+ // is the method rule and that it fires in onSend (the writer's next read is fresh).
53
+ const c = createCache();
54
+ const app = Fastify();
55
+ app.addHook('onSend', async (request) => { if (request.method !== 'GET' && request.method !== 'HEAD') c.forget(); });
56
+ app.get('/posts', async () => c.get('posts', async () => ({at: Date.now()})));
57
+ app.put('/posts/1', async () => ({ok: true}));
58
+ await c.get('posts', async () => 'stale');
59
+ assert.equal(c.size, 1);
60
+ await app.inject({method: 'GET', url: '/posts'});
61
+ assert.equal(c.size, 1, 'a GET keeps the memory');
62
+ await app.inject({method: 'PUT', url: '/posts/1', payload: {}});
63
+ assert.equal(c.size, 0, 'a write retires it');
64
+ await app.close();
65
+ });
@@ -17,8 +17,8 @@ Calendar events block their times (read from `calendar-occurrences` / `calendar-
17
17
  service - no import of the Calendar, which is a separate paid Tool).
18
18
 
19
19
  `tier: free`, ships with the engine, disabled on arrival. Plan and phases: `dcms-marketplace/docs/booking-plan.md`.
20
- Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
21
- pattern - it does not supersede it. Payment is Pro only.
20
+ Booking Pro (marketplace `booking-pro`, 0.4.x) is an ADD-ON that registers into this one (`lib/extensions.js`), the
21
+ Cart / Cart Pro pattern - it does not supersede it. Neither takes payment yet.
22
22
 
23
23
  1.6.0 (2026-09-30): "Add people" in an online booking's call (core with `server/services/callInvites.js` and
24
24
  `_lib/call/invite.js`; both imported dynamically - lib/call.js by top-level `await import`, plugin.js beside
@@ -168,3 +168,15 @@ only to roles that hold it.
168
168
  the run (restored after) and forges a Host to prove it.
169
169
  - The API tests create contacts in domma-cms's `content/collections/contacts-contacts/data.json` (tracked) - they
170
170
  snapshot and restore it; a crashed run can leave it changed: `git checkout` it in domma-cms.
171
+
172
+ ## 1.6.4 (2026-10-13 Tool sweep, upgrades)
173
+ - The Diary is domma-reactive end to end: heads, hour gutter and columns are keyed `data-each` lists (`HEAD`, `HOUR`,
174
+ `COL` in diary.js; each column's open bands and blocks are `{{#each}}` inside it). One `draw` effect works out every
175
+ measure as a CSS length (`blockRow`) and the markup only binds; the empty state, "add a service" tip and first-run
176
+ checklist (`STEP`) are bound too. Kebab / right-click / Shift+F10 still come from `wireRows(gridBox, {match: '.bk-block'})`
177
+ (delegated, so re-rendered blocks need no rewiring). `mergeWindows` moved to admin/lib/diary-layout.js (tested).
178
+ - Settings > Closed days (`CLOSURE_ROW`) and a person's opening hours / days off (`DAY_ROW` with a nested
179
+ `{{#each windows}}`, `OFF_ROW`) are keyed lists of per-field observables; `hoursOut` / `daysOffOut` / `closuresOut`
180
+ turn them back into plain data for the dirty snapshot and the save. A "from" after the "to" still pulls "to" along.
181
+ - Icons in rows added after binding are drawn by `I.scan` after a flush (`rescan`), because new rows arrive after
182
+ the panel's first scan.
@@ -138,3 +138,21 @@ export function columnItems(bookings, {bookableId = ''} = {}) {
138
138
  }
139
139
  return [...groups.values()].map(g => ({...g, session: g.bookings.length > 1}));
140
140
  }
141
+
142
+ /**
143
+ * Opening windows (minutes) merged where they touch or overlap - the week view's one band per day for everyone.
144
+ * Moved here from diary.js (1.6.4) so it is tested with the rest of the geometry.
145
+ *
146
+ * @param {{from: number, to: number}[]} list
147
+ * @returns {{from: number, to: number}[]} sorted, non-overlapping copies
148
+ */
149
+ export function mergeWindows(list) {
150
+ const sorted = [...(list || [])].sort((a, b) => a.from - b.from);
151
+ const out = [];
152
+ for (const w of sorted) {
153
+ const last = out[out.length - 1];
154
+ if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
155
+ else out.push({...w});
156
+ }
157
+ return out;
158
+ }