domma-cms 1.8.32 → 1.8.34

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 (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,30 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {markdownTrigger} from '../admin/lib/shortcuts.js';
4
+
5
+ /** Docs - Markdown shortcuts while typing. */
6
+
7
+ test('space after a marker turns the line into that block', () => {
8
+ assert.deepEqual(markdownTrigger('#', ' '), {length: 1, action: {type: 'block', tag: 'h1'}});
9
+ assert.deepEqual(markdownTrigger('###', ' ').action, {type: 'block', tag: 'h3'});
10
+ assert.equal(markdownTrigger('-', ' ').action.type, 'ul');
11
+ assert.equal(markdownTrigger('*', ' ').action.type, 'ul');
12
+ assert.equal(markdownTrigger('1.', ' ').action.type, 'ol');
13
+ assert.equal(markdownTrigger('1)', ' ').action.type, 'ol');
14
+ assert.equal(markdownTrigger('[]', ' ').action.type, 'checklist');
15
+ assert.equal(markdownTrigger('[ ]', ' ').length, 3);
16
+ assert.deepEqual(markdownTrigger('>', ' ').action, {type: 'block', tag: 'blockquote'});
17
+ assert.deepEqual(markdownTrigger('```', ' ').action, {type: 'block', tag: 'pre'});
18
+ });
19
+
20
+ test('Enter after three dashes draws a line; ordinary text never triggers', () => {
21
+ assert.equal(markdownTrigger('---', 'Enter').action.type, 'hr');
22
+ assert.equal(markdownTrigger('***', 'Enter').action.type, 'hr');
23
+ assert.equal(markdownTrigger('---', ' '), null);
24
+ assert.equal(markdownTrigger('#', 'Enter'), null);
25
+ assert.equal(markdownTrigger('a#', ' '), null);
26
+ assert.equal(markdownTrigger('#####', ' '), null);
27
+ assert.equal(markdownTrigger('2.', ' '), null);
28
+ assert.equal(markdownTrigger('', ' '), null);
29
+ assert.equal(markdownTrigger('# ', ' '), null);
30
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@af8baa8",
3
+ "source": "dcms-marketplace@ec1e685",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.10.1",
@@ -125,6 +125,70 @@
125
125
  "tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
126
126
  }
127
127
  },
128
+ "crossword": {
129
+ "version": "1.1.0",
130
+ "files": {
131
+ "CLAUDE.md": "598736eec2dfa833f7bb84ca9376071d1143d98a5c01106d0ff453749af18e43",
132
+ "admin/css/index.css": "d8d32268504d5aefa9c5fbf48a0c67b978cdd01d3be0bc747205a001972e05db",
133
+ "admin/templates/crossword.html": "336b4a3b7efff1ab8004c0516d55f11ae2efdc98cb3b18133ea00ca07311e2aa",
134
+ "admin/views/crossword.js": "42f3aabd09483eddf34d25dae243a853c562a13348eafe4fc1977361c1b03f4e",
135
+ "collections/crossword-plays.json": "20577250f00ebe0f6503cc2aae83dba93d88381b76b0b1dbc76c7b856d4295ff",
136
+ "collections/crossword-puzzles.json": "1b70950c96e50ede4d6a0c8e59ca7767b3c684c1a7c68588dc6462e69dad596e",
137
+ "config.js": "7a15db33506a432f5c43a095366c2e82a3bd20ceded585fb4fb78b93ad6dff29",
138
+ "docs/guide.md": "1189300bbe20f032e9040fe027e84a6137ab560e2fe9b88219aba66ea336ec4d",
139
+ "inject/body.html": "9dad7927d5afbd5e53f1edaa4a420403cd2325ce908624f9643a087388c090ab",
140
+ "lib/crossword.js": "f8149db65fb3a7f82b2fbae6a9f8d3ceeeec95730e426cf6fc36ea058527df34",
141
+ "lib/store.js": "284b3b8abe9fdaa64a660d5ab0120856b893d166b28e8bda6cf0ef038baa74bc",
142
+ "plugin.js": "5c1885955d122200c7ff9629d7fb9fcb54c232edb4cec4658574ca90d73730e5",
143
+ "plugin.json": "c5f9e3d934423161af6ef5fbf52c0c3394e737245a4de21da72da6d75a790f44",
144
+ "public/crossword.css": "656f369ea6587b000ff56b8ab80010f4f76af10966537c180950074ec5169f61",
145
+ "public/crossword.js": "8418a2c52a3d351a95e271d3c9f564b7336243ccde75106a9998995575802173",
146
+ "public/lib/layout.js": "0026afd8b42277838c55e1c031fa07cfea1c640c47aaf385e461cc998c4619c0"
147
+ }
148
+ },
149
+ "docs": {
150
+ "version": "2.14.0",
151
+ "files": {
152
+ "CLAUDE.md": "7c3b32e6e4b3456b8d6acafde57cbe6af519d6771d81294cfb90318801da43e3",
153
+ "admin/css/docs.css": "89304c596f2d36492198885359fa86998f9f36be522238983e32e5d01319c759",
154
+ "admin/lib/api.js": "0ddd567995a79e2379b92049de3b09a8bdf1c0d798042c7fbe3937f14e3631bf",
155
+ "admin/lib/convert.js": "37e8310aa0c225fad9c063649e31351c05fe4371081ede9fe4466aac64123b0e",
156
+ "admin/lib/drafts.js": "b3562434b6ddfba6c3759932b91a0c41bdf8190921ab7a65a004367b70d74278",
157
+ "admin/lib/editor.js": "78c136e474e04039073173aea97c3a4b7650692d0561cd2fd08c95923b6c2451",
158
+ "admin/lib/find-replace.js": "eee60406cd963ac9e936f67488ec71b99f526ab32079736f4370516a1cd6c238",
159
+ "admin/lib/folders.js": "e62d964358aea84a29eebef28dc1050a34bfce081dea37d8a4b874fc1d22b3a8",
160
+ "admin/lib/panel-guard.js": "928f23a4d38df91e6fcde52c64e69b36c87e72147973dd28eb8d7ce952072875",
161
+ "admin/lib/pdf-view.js": "3d2c05f0ea0b6978e759f56083c3300139d25c5abf116230135b8a6a64bc8174",
162
+ "admin/lib/send.js": "422ae97c8e89e09529fe0e5621b8d7a770f14e67cd8cb0892d2d729d696dde78",
163
+ "admin/lib/settings.js": "efcdf74799abd6c46a1b70034545c1cc89492cc625e6af88efcf63563f9d3e40",
164
+ "admin/lib/share.js": "bd1e4310a73416848281284da1fa1a5b06d996eeb49724cbf3dc4a14a7e45eb9",
165
+ "admin/lib/shortcuts.js": "b96088bda42714ae92da37d28fb3ead5390e45b8d39d01c8f5e1e981e09df0b0",
166
+ "admin/lib/templates.js": "3a2c678c8be5bd29baac39ab8e06e1c37d385d909fa6502bc9fb1a3ab4150917",
167
+ "admin/lib/versions.js": "5affab802d1539b754427222afc8e232961cc3208e00251b033ee48ad5d02174",
168
+ "admin/templates/docs.html": "4a621859200e6c2731db8f0924e6b76b03660dc01ae637d19de5ca2bbe92a33d",
169
+ "admin/templates/editor.html": "cd937141373deeb8f3d204e47d11fe297a9b863499a95e19d62dbe1b2b05213a",
170
+ "admin/views/docs.js": "52de890d32b63d522f8ae12b40e2525cf67b34584a62a745799e95ff352b0679",
171
+ "admin/views/editor.js": "ded2998b0ef19f22874410363b6667b718219e0057ff3b1c8c3292693206b932",
172
+ "config.js": "4435664c2d96088ad21d68241a2a6998b12c80592270deba77710b8c014de0a3",
173
+ "docs/guide.md": "75f05f0665bd3b5a9ac00131df21c98c86fa71ad8fafdb8331d25b1adc0a61dd",
174
+ "lib/extensions.js": "a449f10e309bcb16bb7d426315e85b2efe9be6d3420313c0e30315f146820d99",
175
+ "lib/importer.js": "2bbe9c9d37d6c627fa9ad445171c01b349a14582a882fe8cf9a33c42045e03dc",
176
+ "lib/pdf-worker.js": "c256c32520a37a6579a528f16d5965951cfb65d7984a246168c1b33fd36d72eb",
177
+ "lib/pdf.js": "83abc3aba0c322c50b528b8a037466a294303f55d7c30f3cd1445f811b78ae28",
178
+ "lib/store.js": "bd2f629bfdc74e10a3ab56f37291eb8b09fb6479c04facdff9514c6298b0ba29",
179
+ "plugin.js": "c9228fc20524fe43a1a05495d20be33a0723fa3d4ffb0c853844d45b58b8b216",
180
+ "plugin.json": "35024c51c478b72b6aa0070a4ed18b9eb32d0334b120199c788b8d95ca4dbaf1",
181
+ "plugin.public.js": "a9cd7a9211f5b0006832054a15055a3c36afa9f9b284600cdc5fce19e892c178",
182
+ "templates/public-doc.html": "7360cbeec5cba8a2b1b1d074730972bb4b90cfe913a799a06522b2e1a0f566d8",
183
+ "tests/_env.js": "2daf6b0dd3211498298db79e5da80c17e2be98e380f777bc9c555c3eb99d128a",
184
+ "tests/_pdf.js": "69274f267546b139d2f1b8a159f0a0cf25dbc9b589d966398c2b7ae93f85186e",
185
+ "tests/convert.test.js": "27dc072e3a5f6fd482560e547048723bc7884aa8f53a3bc2f547758a28c535a4",
186
+ "tests/drafts.test.js": "209c1691aa321e07c0ab24f747920b15d7bd56c2b3f5a581b116aad73c157d8f",
187
+ "tests/pdf.test.js": "316c7b32fb4562f42fd59da1df9c8a3bccacb8286db6540b36e10f85c510d68e",
188
+ "tests/plugin.test.js": "a76c3a5bab6e525bc201f73f4ed76b604b41331cbeba0c6155253b88b40b5bb0",
189
+ "tests/shortcuts.test.js": "7f96b63e91150e7670eb349a81034ce354f64ea42503d6a8ffcdae4867d7bba1"
190
+ }
191
+ },
128
192
  "feedback": {
129
193
  "version": "1.2.0",
130
194
  "files": {
@@ -153,6 +217,23 @@
153
217
  "tests/shape.test.js": "2e2e5e17ce059fbaa4dad8170ae45382d6c7e74bacf3b301814bb81a60ba98ba"
154
218
  }
155
219
  },
220
+ "garage": {
221
+ "version": "1.6.0",
222
+ "files": {
223
+ "CLAUDE.md": "43ab4680cc730deb17b2505afea6bfdddee0cb5ddcc1af20dddc2b76bc33851a",
224
+ "admin/css/garage.css": "79a10a2ed7f5f39f14ec1a2be9c83c3837758248c1601a8bf30eb15915a7d5fa",
225
+ "admin/lib/garage.js": "bcad98c3f12247a148ec86d19da0c0bb680c7f5abf2cf20e9ec107395fd79f96",
226
+ "admin/templates/garage.html": "eca088e11a562a6a2bd6af2d335b409d18f3dd0decbe96c5bb83cc5c9d4cd450",
227
+ "admin/views/garage.js": "f425f451b84172ea550391ac05c696324bac7a18db104df53eba0195fa59a891",
228
+ "collections/garage-vehicles/schema.json": "800faeee49f6aca76c3bddef4d7863a52f9de7786f76a299258d04b919f15e4d",
229
+ "config.js": "058a5ad42a4900728abc0e18d9260ae6baea638bdb7f177a4ac5b029eef4799f",
230
+ "docs/guide.md": "d228030133355cf827719a356e306b2d9b73bd4c73a8d0d1029afe8205753ab1",
231
+ "plugin.js": "2e4fd339ffdb98cf834be4e0967d0cfd6756646d7cee1ba00b6706f356fdbbc0",
232
+ "plugin.json": "e9b7a7a8ce12a7a0b16c0eaa0361f827873341854057a08cb1dcffdd378a5bf5",
233
+ "tests/access.test.js": "ef23a077746f22082ccd07a4fb82a92b5692aa63cd021843d32f5ffacfc29957",
234
+ "tests/garage.test.js": "3d8ce8ad5ab484601eb9826ea259121ec4bf16fe22469be4000e382823bb85bb"
235
+ }
236
+ },
156
237
  "mail-reader": {
157
238
  "version": "1.2.8",
158
239
  "files": {
@@ -164,6 +245,51 @@
164
245
  "plugin.json": "e5ac7f3496d9a535fa4899d1de87a514535084d13acecd6a5897744b6174807a"
165
246
  }
166
247
  },
248
+ "messages": {
249
+ "version": "1.9.0",
250
+ "files": {
251
+ "CLAUDE.md": "b2be02f68229bcc345b1798b5669a90e6c0c0261780f131ce70f8220365db569",
252
+ "admin/css/messages.css": "5e81a3a48413749be7d1dddb3259ab885200a0ca2d8e028f217f1849e56deb66",
253
+ "admin/lib/messages.js": "7e52d4d2f5edc8ea6a1a752e301e3bfaefff0b2dcdfc32de37d153649485f0ae",
254
+ "admin/templates/messages.html": "5b11840d5d9554506fb34ca0e600548cdaea32c917a0afa51cecc276b3809343",
255
+ "admin/views/messages.js": "4c2445d7ae238131ef5fb948bf7cad7c9baac2c3f08a20af04dd570a4ccfa72b",
256
+ "collections/user-message-threads/schema.json": "d735c28ccf11bc1aaca4a3b3707ea2b4188df326e28921db307ba0358bd1658f",
257
+ "collections/user-messages/schema.json": "b246e141871bdca51bbc05ca4bd0f7726c8e6084b2cbfedab62409af1805109f",
258
+ "config.js": "496b32d1683c575c0bf4623f4bbff35cd4e6b00c9d895e3e602e28891fa9f6e2",
259
+ "docs/guide.md": "b5757d47d129d095d3d2a77bed3524e5c68d8ba55bbf1b581b6cc487ace6d6bd",
260
+ "lib/call.js": "a04df3674b17fb68db96fc3278b20e3bcc17c2729a550ac7a79add1cce9234c8",
261
+ "plugin.js": "c83e16e9e58946b112ae1feae48346222c0b553e3407f443a518f84cfa1c6232",
262
+ "plugin.json": "6fec1355862053b31e45cd3f90de0d8d63b69115851fa461a5be57e905be5e48",
263
+ "plugin.public.js": "82fa4df6ab4d8a68a0de7a10a7fd30f9e041ede2ab803eb7709de7312af25f51",
264
+ "templates/call.html": "9e2eabbf0379f8ee0b8114f4d6f84b7d996448a6eb10934a7d7529f9eca4041c",
265
+ "tests/after-send.test.js": "43a866573cfe8b8c8de63b6bf12066b2030844da6fa9d4614561e2d92681bd88",
266
+ "tests/api.test.js": "d0cc206b6d20d593c8a8abf9dfb0ebc577c8676139d77bd2820ee13fceb57412",
267
+ "tests/call.test.js": "7656d1a75f6ed40058677b85db5de727df00c6aff6b07868a79e5ba0f565a93e",
268
+ "tests/drafts.test.js": "362d01511b950bd7c7ac115e1677ffbc67f7f2b8ac241cd19df2acc73f652b8c",
269
+ "tests/messages.test.js": "5b724e53fcf7f3b5a035c47c0a4ca98dcd7de42b65f4a4ea195a9409fd53e8af"
270
+ }
271
+ },
272
+ "quiz": {
273
+ "version": "1.1.0",
274
+ "files": {
275
+ "CLAUDE.md": "9d037c11b0de115bf17a6c53387116be037c0d4dcd1b6bf3dafa373539143e16",
276
+ "admin/css/index.css": "6598225cfb8b58c486eb390ac41c7f359c78f48e0c97bc8b354d3832f618afa8",
277
+ "admin/templates/quiz.html": "f0b65c406b0b6325a815906ca8da7d884d5d24f2504e05631e46c34a5ca0edb9",
278
+ "admin/views/quiz.js": "c58ba86ec989df8f29e8acff2266fb3463e301c5516bf9281193de539b7c1f44",
279
+ "collections/quiz-plays.json": "331d665a90b6dddf069245a2faf8dd454a36d387702343a7e36d1fe6dd5015f8",
280
+ "collections/quiz-quizzes.json": "ce9bacf6c05fb5c22f9a5c5105f624c7a9c6d78c2bd343072d9cb7587e920b1d",
281
+ "config.js": "24db3727a804678ef12290ae258c7cf52afbc7a425bb452d87b7b2467ff7c559",
282
+ "docs/guide.md": "a7f8f449b94ecc726dc82fc7a0ae7c335431fa757f9493859bdf16e633689c3f",
283
+ "inject/body.html": "07727ead4a04fdeb2759a19e8cc358ba2692b807fb741a6daafaf02463fe8233",
284
+ "lib/engine.js": "a554c7abfa357288bbd73f47c9bcbf8eab7d8222ff63b1fd119a3ca5f9fb79f4",
285
+ "lib/quiz.js": "d697bbd1af41575a9ecee49781695f2dfb4a585bf0230cc39ac5d174dc9c2762",
286
+ "lib/store.js": "a740d879185e20c036ac17ab5cbca74d1f71097f4af3bc121fcddc826d8686c6",
287
+ "plugin.js": "0ec6f96fc4922c7ef41b36bd757c02926ee99d50e78fe85f7f30b9fbd9605b25",
288
+ "plugin.json": "788960b6a31c7c1c2d525fb7cae9ab9b7c9310def5d778c275f22302ecaadf78",
289
+ "public/quiz.css": "e8869fb30e22763357464e9aa7a353306b6631b8a7418ba4e483331e66aada67",
290
+ "public/quiz.js": "dedc96469e440c979b20d7d95655fbb25062b00b27be569b2a55ec5a8ec0cbb5"
291
+ }
292
+ },
167
293
  "security": {
168
294
  "version": "1.2.1",
169
295
  "files": {
@@ -289,6 +415,26 @@
289
415
  "tests/settings.test.js": "434acbfd940eef800b1166a9aaf36363351af5652fcfb06527b8498866b7b964",
290
416
  "tests/shape.test.js": "7866c6c1d140716bf0817449937d6d711d7c3b97be1db1e1eb33bde341e36296"
291
417
  }
418
+ },
419
+ "theme-roller": {
420
+ "version": "1.3.0",
421
+ "files": {
422
+ "CLAUDE.md": "c396bf316dcfe62bdfeee59d6973309aa267ac7a6b20c626f6404b18e4809e64",
423
+ "admin/css/theme-roller.css": "dfb2059fb6da0e5cfaf5bd4c1e1d5da7b1918f0edbeb2a2df8a2333094bc8149",
424
+ "admin/lib/prompt.js": "36ccd3f1536fbcfa57d69470157df633609ed7f1af41b7e351e4df13db210faf",
425
+ "admin/lib/themes.js": "eb521dcd999db513e17d124591bbd4509c756756b8cc80c259bc5b502c63a344",
426
+ "admin/lib/view.js": "16105e216465bfb0a7a9fe838af6deb5384904327906b98099abbad898b96cdf",
427
+ "admin/templates/theme-roller.html": "66fabd3819eeb08544c3ee9fd065a458c9e18d415385f9bba0c92527f96eaa0c",
428
+ "admin/views/theme-roller-view.js": "be3d628901b91fd44e12981d06c5a1eb06df0c327a43f86850828cf90b332fb0",
429
+ "config.js": "450f0af4f4c1ecc4c7180f2e364c8a59bfed69dd350fb6b47bce8641c2a37786",
430
+ "docs/guide.md": "ced47c67e49b3c02e1f85ab5b6da8e0dc7055fcf2688fc9994cf7b52b03881c9",
431
+ "plugin.js": "e685064cb17b879d41428c71a3a2b072501acaa964088c322964f27f622deff2",
432
+ "plugin.json": "cd19ab0a17c40b6305cc3dcd3f60f70d67ab1acd1926eda31957153bbf413b74",
433
+ "tests/api.test.js": "8ad5a76a1feebde7ad6dc3867ee49b25d4a33c3579e2149b955cd8333ab73ef0",
434
+ "tests/prompt-values.test.js": "8cedaecea858dd76bbb92923ad49f8a7b579a368e681265b095dbe13060edc24",
435
+ "tests/themes.test.js": "23071a019db49e3161956517c8bb5bb6e41312ca453819baa761ae613b1cfb67",
436
+ "tests/view.test.js": "24adb2c3e4f9d84dbcd8450addb134f75e2fec25667cda106ec8c80618247e94"
437
+ }
292
438
  }
293
439
  }
294
440
  }
@@ -0,0 +1,51 @@
1
+ # Garage
2
+
3
+ UK vehicle lookups through the DVLA Vehicle Enquiry Service, a per-user history of them, and "My Garage" -
4
+ the saved ones, with their MOT and road tax at a glance and on the sidebar badge.
5
+
6
+ ## Files
7
+
8
+ - `plugin.js` - API (lookup proxy, history, save toggle, delete, `GET /count` badge) and the
9
+ `[garage-vehicles /]` shortcode. Entries live in the `garage-vehicles` collection, one per user + reg,
10
+ and EVERY read - routes, notification check and shortcode - goes through `listEntries`/`getEntry`
11
+ (collections service -> the slug's storage adapter), so file and MongoDB storage behave the same.
12
+ - `admin/lib/garage.js` - PURE, shared by server and browser: registration normalising/display,
13
+ `checkState` (an MOT or tax check -> tone + words), `vehicleAlert`, `vehicleRow`, `arrangeVehicles`,
14
+ `badgeFor`. Dates are calendar days; `today` is the reader's (the sidebar sends `?today=`).
15
+ - `admin/views/garage.js`, `admin/templates/garage.html`, `admin/css/garage.css`; `tests/garage.test.js`,
16
+ `tests/access.test.js`.
17
+
18
+ ## Gotchas
19
+
20
+ - The DVLA API key is read at LOOKUP time (`getPluginSettings`), so one saved from the settings panel works
21
+ without a restart; `DVLA_API_KEY` in the environment wins. It is a secret: the settings form guards a
22
+ typed key but never keeps it as a draft.
23
+ - The number plate is black on yellow whatever the theme (a UK plate is, by law) - the two `--gx-plate-*`
24
+ properties are the screen's only non-token colours.
25
+ - The badge counts saved vehicles needing attention (expired/overdue = danger; due within 30 days or SORN);
26
+ nothing due = no badge. It is cached a minute per user and day, and cleared by any write from that user.
27
+ - `/count` is per user; so is everything else - a user never sees another's vehicles.
28
+ - **Access (1.3.0):** permission `garage` (read / create = look up and save / delete), plugin.json, granted once to
29
+ `admin` and `user`; every route checks it; the sidebar entry needs it. `[garage-vehicles user="..." /]` needs a
30
+ user - the bare form put every user's saved vehicles on a public page.
31
+ - **Notifications (1.4.0):** `garage:due`, daily + at boot: `vehicleNotices()` (pure, admin/lib/garage.js) - MOT/tax run out = critical, due within DUE_SOON_DAYS (30) = warning, SORN never; to the vehicle's OWNER (userIds). Keys `vehicle:<id>:expired|due`; the other state and passing checks are resolved.
32
+ - **Storage (1.4.1):** the shortcode is async and reads through `listEntries`, like the routes. Until 1.4.1 it read
33
+ `content/collections/garage-vehicles/data.json` directly and rendered nothing when the collection was on MongoDB.
34
+ Never read the data file directly; same access as before (a named `user`, saved vehicles only).
35
+
36
+ ## 1.5.0 (2026-10-15 Tool Upgrade, second pass)
37
+
38
+ - Save: `PATCH /vehicles/:id/save` takes `{isSaved: true|false}` and sets it; no body toggles (old callers); any
39
+ other body is 400. The screen always sends the state it wants, so a stale screen can't flip a vehicle back, and
40
+ every way in (result button, details, menu) greys while that vehicle's save is in flight (an observable list).
41
+ - Settings save re-reads `/api/plugins` and PUTs `settingsWithKey(stored, key)` (admin/lib/garage.js): the PUT
42
+ replaces the whole settings object, so never send just `{dvlaApiKey}` (it used to wipe the other settings). The key
43
+ is never drafted or echoed; closing with a typed key asks "Discard what you have typed?" (OK discards, Escape keeps).
44
+ - One panel per thing (claim released, and its effect stopped, if building throws); the details panel follows the
45
+ list; skeleton slots on both lists; here() after every await; someone without plugin rights is told so.
46
+
47
+ ## 1.6.0 (2026-10-21): free
48
+
49
+ `tier: free`, no `closedSource` - no licence is needed or checked any more (the engine only checks a
50
+ `closedSource` plugin). Included in every hosted plan and free to self-host; not `bundle: false`, so the next
51
+ `make sync-free-tier` ships it in the engine.
@@ -0,0 +1,73 @@
1
+ /* Garage admin screen - see admin/views/garage.js. --dm-* tokens only, except the number plate below. */
2
+ .gx {
3
+ /* A UK rear number plate is black on yellow by law (DVLA INF104), whatever the admin's theme -
4
+ the one place this screen is not themed. */
5
+ --gx-plate-bg: #ffd900;
6
+ --gx-plate-fg: #000;
7
+ display: flex; flex-direction: column; gap: .75rem;
8
+ }
9
+ .gx [hidden], .gx-panel [hidden] { display: none !important; }
10
+ .gx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .6rem; }
11
+ .gx-card-title { display: flex; align-items: center; gap: .4rem; font-weight: 600; }
12
+ .gx-spacer { flex: 1; }
13
+ .gx-note { margin: 0; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
14
+ .gx-note.is-bad { color: var(--dm-danger); }
15
+ .gx-alert { margin: 0; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; padding: .55rem .8rem; border-radius: var(--dm-radius-md);
16
+ background: color-mix(in srgb, var(--dm-warning) 16%, transparent); color: var(--dm-text); font-size: var(--dm-text-sm); }
17
+ .gx-lookup-bar { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
18
+ .gx-lookup-bar .btn, .gx-card-title .btn, .gx-actions .btn, .gx-bar .btn { display: inline-flex; align-items: center; gap: .3rem; }
19
+ .gx-plate-input { width: 11rem; background: var(--gx-plate-bg); color: var(--gx-plate-fg); border: 3px solid var(--gx-plate-fg);
20
+ border-radius: var(--dm-radius-md); font-family: 'Arial Black', Arial, sans-serif; font-weight: 900; font-size: 1.2rem;
21
+ letter-spacing: 3px; text-align: center; text-transform: uppercase; padding: .35rem .5rem; }
22
+ .gx-plate-input::placeholder { color: color-mix(in srgb, var(--gx-plate-fg) 35%, transparent); }
23
+ .gx-plate { display: inline-block; flex-shrink: 0; background: var(--gx-plate-bg); color: var(--gx-plate-fg); border: 2px solid var(--gx-plate-fg);
24
+ border-radius: var(--dm-radius-sm); font-family: 'Arial Black', Arial, sans-serif; font-weight: 900; font-size: .85rem;
25
+ letter-spacing: 2px; padding: .1rem .45rem; white-space: nowrap; min-width: 6.5rem; text-align: center; }
26
+ .gx-plate.is-big { font-size: 1.15rem; padding: .2rem .6rem; letter-spacing: 3px; }
27
+
28
+ /* Result and details */
29
+ .gx-result { display: flex; flex-direction: column; gap: .5rem; border-top: 1px solid var(--dm-border); padding-top: .6rem; }
30
+ .gx-result-head { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
31
+ .gx-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .35rem 1rem; font-size: var(--dm-text-sm); margin: 0; }
32
+ .gx-facts dt { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
33
+ .gx-facts dd { margin: 0; font-weight: 600; }
34
+ .gx-actions { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; }
35
+
36
+ /* Tabs, bars, rows */
37
+ .gx-count { opacity: .65; font-size: var(--dm-text-xs); margin-left: .2rem; }
38
+ .gx-bar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
39
+ .gx-search { flex: 1; min-width: 10rem; max-width: 20rem; }
40
+ .gx-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
41
+ .gx-seg button { border: 0; background: none; color: var(--dm-text); padding: .3rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
42
+ .gx-seg button + button { border-left: 1px solid var(--dm-border); }
43
+ .gx-seg button b { font-weight: 500; opacity: .65; margin-left: .2rem; }
44
+ .gx-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
45
+ .gx-list { display: flex; flex-direction: column; }
46
+ .gx-row { display: flex; align-items: center; gap: .75rem; padding: .55rem .35rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; }
47
+ .gx-row:last-child { border-bottom: 0; }
48
+ .gx-row:hover, .gx-row:focus-visible { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); outline: none; }
49
+ .gx-row.is-danger { box-shadow: inset 3px 0 0 var(--dm-danger); }
50
+ .gx-row.is-warn { box-shadow: inset 3px 0 0 var(--dm-warning); }
51
+ .gx-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
52
+ .gx-main strong, .gx-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
53
+ .gx-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
54
+ .gx-chips { display: inline-flex; gap: .3rem; flex-wrap: wrap; justify-content: flex-end; }
55
+ .gx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .1rem .5rem; border-radius: 999px; white-space: nowrap;
56
+ background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
57
+ .gx-chip.is-ok { background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); }
58
+ .gx-chip.is-warn { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
59
+ .gx-chip.is-danger { background: color-mix(in srgb, var(--dm-danger) 16%, transparent); color: var(--dm-danger); }
60
+ .gx-chip.is-saved { background: color-mix(in srgb, var(--dm-primary) 14%, transparent); color: var(--dm-primary); }
61
+ .gx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
62
+ .gx-kebab:hover { color: var(--dm-primary); }
63
+
64
+ /* Slideover panels */
65
+ .gx-panel { display: flex; flex-direction: column; gap: .85rem; }
66
+ .gx-panel .form-label { display: flex; align-items: center; gap: .3rem; }
67
+ .gx-panel-actions { display: flex; align-items: center; gap: .4rem; padding-top: .6rem; border-top: 1px solid var(--dm-border); }
68
+ .gx-panel-actions .btn { display: inline-flex; align-items: center; gap: .3rem; }
69
+ .gx-hint { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-xs); margin-top: .25rem; }
70
+ @media (max-width: 640px) { .gx-main small { white-space: normal; } .gx-chips { max-width: 45%; } }
71
+ .gx-chips.gx-chips-start { justify-content: flex-start; }
72
+ /* Skeleton slots: block elements, shown only while the first load runs. */
73
+ .gx-loading { display: block; min-height: 1.25rem; }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Garage - the pure part, shared by the server (plugin.js: the sidebar badge)
3
+ * and the admin screen. No I/O, no DOM. Tested in tests/garage.test.js.
4
+ *
5
+ * Dates from the DVLA are calendar days ("2026-07-01"); `today` is the
6
+ * READER's calendar day (the sidebar sends ?today=), so a car is not "due
7
+ * tomorrow" for someone in a different evening.
8
+ */
9
+
10
+ import {fmtDate} from '../../../_lib/admin/ui/dates.js';
11
+
12
+ /** How far ahead an MOT or tax date counts as "due soon". */
13
+ export const DUE_SOON_DAYS = 30;
14
+
15
+ /** "ab12 cde" -> "AB12CDE" - how the server stores and matches a registration. */
16
+ export function normaliseReg(reg) {
17
+ return String(reg ?? '').toUpperCase().replace(/\s+/g, '');
18
+ }
19
+
20
+ /**
21
+ * A registration shown the way a plate reads: current-style marks get their
22
+ * space back ("AB12CDE" -> "AB12 CDE"); anything else is left as it is.
23
+ */
24
+ export function displayReg(reg) {
25
+ const r = normaliseReg(reg);
26
+ return /^[A-Z]{2}\d{2}[A-Z]{3}$/.test(r) ? `${r.slice(0, 4)} ${r.slice(4)}` : r;
27
+ }
28
+
29
+ /** YYYY-MM-DD of a Date, local. */
30
+ export function isoDay(d = new Date()) {
31
+ const pad = (n) => String(n).padStart(2, '0');
32
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
33
+ }
34
+
35
+ /** Whole days from `today` to `day` (negative = past), or null when either is not a date. */
36
+ export function daysUntil(day, today) {
37
+ const a = /^(\d{4}-\d{2}-\d{2})/.exec(String(day ?? ''))?.[1];
38
+ const b = /^(\d{4}-\d{2}-\d{2})/.exec(String(today ?? ''))?.[1];
39
+ if (!a || !b) return null;
40
+ const ms = Date.parse(`${a}T00:00:00Z`) - Date.parse(`${b}T00:00:00Z`);
41
+ return Number.isFinite(ms) ? Math.round(ms / 86_400_000) : null;
42
+ }
43
+
44
+ /** "2027-01-23" -> "23/01/2027"; anything else as it came. */
45
+ export function shortDay(day) {
46
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(day ?? ''));
47
+ return m ? fmtDate(m[1]) : String(day ?? '');
48
+ }
49
+
50
+ const when = (d) => (d < -1 ? `${-d} days ago` : d === -1 ? 'yesterday' : d === 0 ? 'today' : d === 1 ? 'tomorrow' : `in ${d} days`);
51
+
52
+ /**
53
+ * One check (MOT or road tax) as a chip: its tone and words.
54
+ *
55
+ * danger - expired / not valid / untaxed, or its date has passed
56
+ * warn - SORN, or due within DUE_SOON_DAYS
57
+ * ok - valid / taxed, date comfortably ahead
58
+ * muted - unknown (the DVLA did not say)
59
+ *
60
+ * @param {'mot'|'tax'} kind
61
+ * @param {string} status - as the DVLA gives it ("Valid", "Taxed", "SORN", "Not valid", "No details held by DVLA"…)
62
+ * @param {string|null} date - motExpiryDate / taxDueDate
63
+ * @param {string} today - YYYY-MM-DD
64
+ * @returns {{tone: 'danger'|'warn'|'ok'|'muted', text: string, days: number|null, due: boolean, bad: boolean}}
65
+ */
66
+ export function checkState(kind, status, date, today) {
67
+ const label = kind === 'mot' ? 'MOT' : 'Tax';
68
+ const s = String(status ?? '').trim();
69
+ const ls = s.toLowerCase();
70
+ const days = daysUntil(date, today);
71
+ const out = (tone, text) => ({tone, text, days, due: tone === 'warn' && days !== null, bad: tone === 'danger'});
72
+
73
+ if (kind === 'tax' && ls === 'sorn') return out('warn', 'SORN');
74
+ if (!s || ls.includes('no details') || ls.includes('no results')) {
75
+ return days === null ? out('muted', `${label} unknown`) : fromDate();
76
+ }
77
+ if (ls.includes('not valid') || ls === 'untaxed' || ls.includes('expired')) {
78
+ return out('danger', days !== null && days < 0 ? `${label} expired ${when(days)}` : `${label}: ${s}`);
79
+ }
80
+ return fromDate();
81
+
82
+ function fromDate() {
83
+ if (days === null) return out(ls === 'valid' || ls === 'taxed' ? 'ok' : 'muted', `${label}: ${s || 'unknown'}`);
84
+ if (days < 0) return out('danger', `${label} ${kind === 'mot' ? 'expired' : 'overdue'} ${when(days)}`);
85
+ if (days <= DUE_SOON_DAYS) return out('warn', `${label} due ${when(days)}`);
86
+ return out('ok', `${label} to ${shortDay(date)}`);
87
+ }
88
+ }
89
+
90
+ /** The worse of a vehicle's two checks - what the row and the badge go by. */
91
+ export function vehicleAlert(v, today) {
92
+ const mot = checkState('mot', v?.motStatus, v?.motExpiryDate, today);
93
+ const tax = checkState('tax', v?.taxStatus, v?.taxDueDate, today);
94
+ const rank = {danger: 0, warn: 1, ok: 2, muted: 3};
95
+ const worst = rank[mot.tone] <= rank[tax.tone] ? mot : tax;
96
+ return {mot, tax, tone: worst.tone, needsAttention: mot.tone === 'danger' || tax.tone === 'danger' || mot.due || tax.due || tax.text === 'SORN'};
97
+ }
98
+
99
+ /**
100
+ * A vehicle as the screen lists it.
101
+ *
102
+ * @param {object} v - {id, registrationNumber, make, colour, yearOfManufacture, fuelType, engineCapacity, co2Emissions, motStatus, motExpiryDate, taxStatus, taxDueDate, isSaved, lookupDate}
103
+ * @param {string} today
104
+ */
105
+ export function vehicleRow(v, today) {
106
+ const a = vehicleAlert(v, today);
107
+ return {
108
+ id: v.id,
109
+ reg: displayReg(v.registrationNumber),
110
+ title: [v.make, v.yearOfManufacture].filter(Boolean).join(' · ') || 'Unknown vehicle',
111
+ meta: [v.colour, v.fuelType, v.engineCapacity ? `${v.engineCapacity}cc` : '', v.co2Emissions ? `${v.co2Emissions}g/km CO₂` : '']
112
+ .filter(Boolean).join(' · '),
113
+ motText: a.mot.text,
114
+ motTone: `is-${a.mot.tone}`,
115
+ taxText: a.tax.text,
116
+ taxTone: `is-${a.tax.tone}`,
117
+ saved: v.isSaved === true,
118
+ attention: a.needsAttention,
119
+ rowCls: a.tone === 'danger' ? 'is-danger' : a.needsAttention ? 'is-warn' : ''
120
+ };
121
+ }
122
+
123
+ /**
124
+ * The vehicles a tab shows: search, the "needs attention" filter, and order -
125
+ * attention first (worst, then soonest), then by registration.
126
+ *
127
+ * @param {object[]} vehicles
128
+ * @param {{query?: string, filter?: 'all'|'attention', today: string, order?: 'attention'|'recent'}} opts
129
+ */
130
+ export function arrangeVehicles(vehicles, {query = '', filter = 'all', today, order = 'attention'} = {}) {
131
+ const q = String(query).trim().toLowerCase().replace(/\s+/g, '');
132
+ const withAlert = (vehicles || []).map(v => ({v, a: vehicleAlert(v, today)}))
133
+ .filter(({v, a}) => filter !== 'attention' || a.needsAttention)
134
+ .filter(({v}) => !q || [v.registrationNumber, v.make, v.colour, v.yearOfManufacture, v.fuelType, v.motStatus, v.taxStatus]
135
+ .some(x => String(x ?? '').toLowerCase().replace(/\s+/g, '').includes(q)));
136
+ const rank = {danger: 0, warn: 1, ok: 2, muted: 3};
137
+ const soonest = ({a}) => Math.min(a.mot.days ?? 1e9, a.tax.days ?? 1e9);
138
+ if (order === 'recent') {
139
+ withAlert.sort((x, y) => String(y.v.lookupDate || '').localeCompare(String(x.v.lookupDate || '')));
140
+ } else {
141
+ withAlert.sort((x, y) => rank[x.a.tone] - rank[y.a.tone] || soonest(x) - soonest(y)
142
+ || normaliseReg(x.v.registrationNumber).localeCompare(normaliseReg(y.v.registrationNumber)));
143
+ }
144
+ return withAlert.map(({v}) => v);
145
+ }
146
+
147
+ /**
148
+ * The sidebar badge (countUrl GET /api/plugins/garage/count): the saved
149
+ * vehicles whose MOT or tax has run out or is due within DUE_SOON_DAYS - red
150
+ * once any has run out. Nothing to do = no badge. Plain text throughout.
151
+ *
152
+ * @param {object[]} saved - the reader's saved vehicles
153
+ * @param {string} today
154
+ */
155
+ export function badgeFor(saved = [], today) {
156
+ const rows = saved.map(v => ({v, a: vehicleAlert(v, today)}));
157
+ const attention = rows.filter(r => r.a.needsAttention)
158
+ .sort((x, y) => ({danger: 0, warn: 1}[x.a.tone] ?? 2) - ({danger: 0, warn: 1}[y.a.tone] ?? 2));
159
+ const expired = attention.filter(r => r.a.tone === 'danger').length;
160
+ const n = attention.length;
161
+ return {
162
+ count: n,
163
+ label: n ? `${n} vehicle${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention${expired ? ` (${expired} expired)` : ''}`
164
+ : `${saved.length} saved vehicle${saved.length === 1 ? '' : 's'}, nothing due`,
165
+ tone: expired ? 'danger' : 'info',
166
+ details: {
167
+ title: 'Garage',
168
+ rows: [
169
+ {label: 'Saved vehicles', value: String(saved.length)},
170
+ {label: 'Expired or overdue', value: String(expired), ...(expired && {tone: 'danger'})},
171
+ {label: `Due within ${DUE_SOON_DAYS} days, or SORN`, value: String(n - expired)}
172
+ ],
173
+ items: attention.slice(0, 8).map(({v, a}) => ({
174
+ text: `${displayReg(v.registrationNumber)}${v.make ? ` · ${v.make}` : ''}`,
175
+ meta: [a.mot, a.tax].filter(c => c.tone === 'danger' || c.tone === 'warn').map(c => c.text).join(' · '),
176
+ ...(a.tone === 'danger' && {tone: 'danger'})
177
+ })),
178
+ empty: 'Every saved vehicle is taxed and has a valid MOT.'
179
+ }
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The plugin settings to PUT when a new DVLA key is saved: the settings as
185
+ * stored now (other secrets arrive masked, and the server keeps a masked value
186
+ * as it was), with the key replaced. Never just {dvlaApiKey} - the PUT replaces
187
+ * the whole settings object, so anything left out would be lost.
188
+ *
189
+ * @param {object} stored - the plugin's settings from GET /api/plugins
190
+ * @param {string} key
191
+ * @returns {object}
192
+ */
193
+ export function settingsWithKey(stored, key) {
194
+ const base = stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {};
195
+ return {...base, dvlaApiKey: String(key ?? '').trim()};
196
+ }
197
+
198
+ // ---- Notifications (1.4, Notifications Phase 4) -------------------------------
199
+
200
+ /**
201
+ * The daily notices for saved vehicles - each to its OWNER (a garage is
202
+ * personal). An MOT or tax check that has run out is critical; one due within
203
+ * DUE_SOON_DAYS is a warning. SORN is not notified: it is declared on purpose.
204
+ * One key per vehicle and state, so moving from due to expired is a new,
205
+ * worse notice (the due one is resolved), and renewing resolves both.
206
+ *
207
+ * @param {object[]} saved - saved vehicles, each with userId
208
+ * @param {string} today - YYYY-MM-DD
209
+ * @returns {{raise: object[], resolve: string[]}}
210
+ */
211
+ export function vehicleNotices(saved = [], today) {
212
+ const raise = [];
213
+ const resolve = [];
214
+ for (const v of saved) {
215
+ if (!v?.id || !v.userId) continue;
216
+ const a = vehicleAlert(v, today);
217
+ const checks = [a.mot, a.tax];
218
+ const expired = checks.filter(c => c.tone === 'danger');
219
+ const due = checks.filter(c => c.tone === 'warn' && c.due);
220
+ const key = (state) => `vehicle:${v.id}:${state}`;
221
+ const reg = displayReg(v.registrationNumber);
222
+ const name = `${reg}${v.make ? ` (${v.make})` : ''}`;
223
+ if (expired.length) {
224
+ raise.push({title: `${name}: ${expired.map(c => c.text).join(' and ')}`, body: 'It should not be on the road until this is sorted.',
225
+ severity: 'critical', link: '#/plugins/garage', source: 'due', dedupeKey: key('expired'), audience: {userIds: [String(v.userId)]}});
226
+ resolve.push(key('due'));
227
+ } else if (due.length) {
228
+ raise.push({title: `${name}: ${due.map(c => c.text).join(' and ')}`, body: 'Book it in before the date passes.',
229
+ severity: 'warning', link: '#/plugins/garage', source: 'due', dedupeKey: key('due'), audience: {userIds: [String(v.userId)]}});
230
+ resolve.push(key('expired'));
231
+ } else {
232
+ resolve.push(key('due'), key('expired'));
233
+ }
234
+ }
235
+ return {raise, resolve};
236
+ }