domma-cms 1.8.46 → 1.9.0

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 (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  3. package/bin/lib/left-free-tier.js +83 -0
  4. package/bin/update.js +24 -0
  5. package/docs/DommaFeatures.md +47 -7
  6. package/docs/plugin-development.md +7 -0
  7. package/package.json +1 -1
  8. package/plugins/_lib/games/names.js +6 -0
  9. package/plugins/_lib/public/games/motion.css +1 -0
  10. package/plugins/_lib/public/games/motion.js +28 -0
  11. package/plugins/free-tier.lock.json +1 -43
  12. package/server/routes/api/api-endpoints.js +5 -1
  13. package/server/routes/api/collections.js +4 -3
  14. package/server/routes/api/forms.js +22 -0
  15. package/server/routes/api/views.js +18 -0
  16. package/server/services/collectionAccess.js +97 -15
  17. package/server/services/permissionRegistry.js +39 -11
  18. package/plugins/crossword/CLAUDE.md +0 -37
  19. package/plugins/crossword/admin/css/index.css +0 -18
  20. package/plugins/crossword/admin/templates/crossword.html +0 -1
  21. package/plugins/crossword/admin/views/crossword.js +0 -449
  22. package/plugins/crossword/collections/crossword-plays.json +0 -20
  23. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  24. package/plugins/crossword/config.js +0 -6
  25. package/plugins/crossword/docs/guide.md +0 -53
  26. package/plugins/crossword/inject/body.html +0 -1
  27. package/plugins/crossword/lib/crossword.js +0 -170
  28. package/plugins/crossword/lib/store.js +0 -56
  29. package/plugins/crossword/plugin.js +0 -274
  30. package/plugins/crossword/plugin.json +0 -79
  31. package/plugins/crossword/public/crossword.css +0 -36
  32. package/plugins/crossword/public/crossword.js +0 -519
  33. package/plugins/crossword/public/lib/layout.js +0 -199
  34. package/plugins/quiz/CLAUDE.md +0 -43
  35. package/plugins/quiz/admin/css/index.css +0 -13
  36. package/plugins/quiz/admin/templates/quiz.html +0 -1
  37. package/plugins/quiz/admin/views/quiz.js +0 -530
  38. package/plugins/quiz/collections/quiz-plays.json +0 -20
  39. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  40. package/plugins/quiz/config.js +0 -6
  41. package/plugins/quiz/docs/guide.md +0 -61
  42. package/plugins/quiz/inject/body.html +0 -1
  43. package/plugins/quiz/lib/engine.js +0 -115
  44. package/plugins/quiz/lib/quiz.js +0 -225
  45. package/plugins/quiz/lib/store.js +0 -56
  46. package/plugins/quiz/plugin.js +0 -275
  47. package/plugins/quiz/plugin.json +0 -79
  48. package/plugins/quiz/public/quiz.css +0 -36
  49. package/plugins/quiz/public/quiz.js +0 -372
@@ -1,199 +0,0 @@
1
- /**
2
- * Crossword - laying words out in a grid. PURE, and isomorphic: the admin
3
- * builder runs it in the browser for a live preview, the server runs it again
4
- * to check what it is given. It holds no puzzle, so it is safe to serve.
5
- *
6
- * cleanAnswer("New York") -> {letters: 'NEWYORK', enumeration: '3,4'}
7
- * generate(words, {seed}) -> {width, height, entries, unplaced}
8
- * solutionGrid(layout) -> rows of letters ('' = a black square), or throws if entries clash
9
- * numberEntries(entries) -> entries numbered in reading order
10
- *
11
- * The generator is greedy: longest words first, each placed where it crosses
12
- * the most letters already down without touching any other word side by side,
13
- * keeping the grid as square as it can. It tries a number of shuffles from the
14
- * seed and keeps the best, so the same seed always gives the same grid.
15
- *
16
- * @module crossword/public/lib/layout
17
- */
18
-
19
- export const MAX_WORDS = 60;
20
- export const MAX_LETTERS = 21;
21
- export const MIN_LETTERS = 2;
22
-
23
- /** Letters only, upper case, accents folded; the word lengths for the clue, e.g. "(3,4)". */
24
- export function cleanAnswer(raw) {
25
- const words = String(raw ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toUpperCase()
26
- .split(/[\s-]+/).map(w => w.replace(/[^A-Z]/g, '')).filter(Boolean);
27
- const letters = words.join('');
28
- const hyphenated = /-/.test(String(raw ?? ''));
29
- return {letters, enumeration: words.map(w => w.length).join(hyphenated ? '-' : ',')};
30
- }
31
-
32
- /** A small seeded random (mulberry32), so a seed always lays out the same grid. */
33
- export function seeded(seed) {
34
- let a = (Number(seed) >>> 0) || 1;
35
- return () => {
36
- a = (a + 0x6D2B79F5) >>> 0;
37
- let t = a;
38
- t = Math.imul(t ^ (t >>> 15), t | 1);
39
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
40
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
41
- };
42
- }
43
-
44
- const key = (r, c) => `${r},${c}`;
45
- const DIRS = {across: [0, 1], down: [1, 0]};
46
-
47
- /** One attempt: words in this order, placed greedily on an open board. */
48
- function attempt(words) {
49
- const cells = new Map(); // "r,c" -> {ch, across: bool, down: bool}
50
- const placed = [];
51
- const unplaced = [];
52
- let box = {r0: 0, r1: -1, c0: 0, c1: -1};
53
-
54
- const at = (r, c) => cells.get(key(r, c));
55
-
56
- function fits(w, r, c, dir) {
57
- const [dr, dc] = DIRS[dir];
58
- // The squares just before and after the word must be empty.
59
- if (at(r - dr, c - dc) || at(r + dr * w.length, c + dc * w.length)) return -1;
60
- let crossings = 0;
61
- for (let i = 0; i < w.length; i++) {
62
- const rr = r + dr * i;
63
- const cc = c + dc * i;
64
- const cell = at(rr, cc);
65
- if (cell) {
66
- if (cell.ch !== w[i] || cell[dir]) return -1;
67
- crossings++;
68
- continue;
69
- }
70
- // An empty square must not sit beside another word's letter (side by side, parallel).
71
- if (at(rr + dc, cc + dr) || at(rr - dc, cc - dr)) return -1;
72
- }
73
- return crossings;
74
- }
75
-
76
- function put(w, r, c, dir, item) {
77
- const [dr, dc] = DIRS[dir];
78
- for (let i = 0; i < w.length; i++) {
79
- const k = key(r + dr * i, c + dc * i);
80
- const cell = cells.get(k) || {ch: w[i], across: false, down: false};
81
- cell[dir] = true;
82
- cells.set(k, cell);
83
- }
84
- const r1 = r + dr * (w.length - 1);
85
- const c1 = c + dc * (w.length - 1);
86
- box = placed.length
87
- ? {r0: Math.min(box.r0, r), r1: Math.max(box.r1, r1), c0: Math.min(box.c0, c), c1: Math.max(box.c1, c1)}
88
- : {r0: r, r1, c0: c, c1};
89
- placed.push({...item, row: r, col: c, dir});
90
- }
91
-
92
- let queue = [...words];
93
- let progress = true;
94
- // Words that could not cross anything yet get another chance once more are down.
95
- while (queue.length && progress) {
96
- progress = false;
97
- const left = [];
98
- for (const item of queue) {
99
- const w = item.letters;
100
- if (!placed.length) { put(w, 0, 0, 'across', item); progress = true; continue; }
101
- let best = null;
102
- for (const [k, cell] of cells) {
103
- const [cr, cc] = k.split(',').map(Number);
104
- for (let i = 0; i < w.length; i++) {
105
- if (w[i] !== cell.ch) continue;
106
- for (const dir of ['across', 'down']) {
107
- if (cell[dir]) continue;
108
- const [dr, dc] = DIRS[dir];
109
- const r = cr - dr * i;
110
- const c = cc - dc * i;
111
- const crossings = fits(w, r, c, dir);
112
- if (crossings < 1) continue;
113
- const r1 = r + dr * (w.length - 1);
114
- const c1 = c + dc * (w.length - 1);
115
- const h = Math.max(box.r1, r1) - Math.min(box.r0, r) + 1;
116
- const wd = Math.max(box.c1, c1) - Math.min(box.c0, c) + 1;
117
- // More crossings, then a smaller, squarer grid.
118
- const score = crossings * 100 - (h * wd) / 4 - Math.abs(h - wd) * 2;
119
- if (!best || score > best.score) best = {score, r, c, dir};
120
- }
121
- }
122
- }
123
- if (best) { put(w, best.r, best.c, best.dir, item); progress = true; }
124
- else left.push(item);
125
- }
126
- queue = left;
127
- }
128
- unplaced.push(...queue);
129
- return {placed, unplaced, box, crossings: [...cells.values()].filter(c => c.across && c.down).length};
130
- }
131
-
132
- /** Entries numbered in reading order: a square that starts any entry gets the next number. */
133
- export function numberEntries(entries) {
134
- const starts = [...new Set(entries.map(e => key(e.row, e.col)))]
135
- .map(k => k.split(',').map(Number)).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
136
- const num = new Map(starts.map(([r, c], i) => [key(r, c), i + 1]));
137
- return entries.map(e => ({...e, number: num.get(key(e.row, e.col))}))
138
- .sort((a, b) => (a.dir === b.dir ? a.number - b.number : a.dir === 'across' ? -1 : 1));
139
- }
140
-
141
- /**
142
- * A layout from a list of {answer, clue}.
143
- *
144
- * @param {{answer: string, clue: string}[]} words
145
- * @param {{seed?: number, tries?: number}} [opts]
146
- * @returns {{width: number, height: number, entries: object[], unplaced: object[], seed: number}}
147
- */
148
- export function generate(words, {seed = 1, tries = 40} = {}) {
149
- const items = [];
150
- const seen = new Set();
151
- for (const w of (words || []).slice(0, MAX_WORDS)) {
152
- const {letters, enumeration} = cleanAnswer(w?.answer);
153
- if (letters.length < MIN_LETTERS || letters.length > MAX_LETTERS || seen.has(letters)) continue;
154
- seen.add(letters);
155
- items.push({answer: String(w.answer).trim(), letters, enumeration, clue: String(w?.clue ?? '').trim()});
156
- }
157
- if (!items.length) return {width: 0, height: 0, entries: [], unplaced: [], seed};
158
-
159
- const rand = seeded(seed);
160
- let best = null;
161
- for (let t = 0; t < Math.max(1, tries); t++) {
162
- // The first try is longest-first; the rest shuffle words of similar length.
163
- const order = items.map(it => ({it, k: it.letters.length + (t ? rand() * 4 : 0)})).sort((a, b) => b.k - a.k).map(x => x.it);
164
- const res = attempt(order);
165
- const h = res.box.r1 - res.box.r0 + 1;
166
- const w = res.box.c1 - res.box.c0 + 1;
167
- const score = res.placed.length * 1000 + res.crossings * 10 - h * w / 2 - Math.abs(h - w) * 3;
168
- if (!best || score > best.score) best = {...res, score, h, w};
169
- }
170
- const entries = numberEntries(best.placed.map(p => ({
171
- answer: p.answer, letters: p.letters, enumeration: p.enumeration, clue: p.clue,
172
- row: p.row - best.box.r0, col: p.col - best.box.c0, dir: p.dir
173
- })));
174
- return {width: best.w, height: best.h, entries, unplaced: best.unplaced.map(u => ({answer: u.answer, clue: u.clue})), seed};
175
- }
176
-
177
- /**
178
- * The solution as rows of letters ('' = black). Throws when the entries do
179
- * not fit the size or disagree about a square - a layout from anywhere but
180
- * generate() is checked this way before it is saved.
181
- */
182
- export function solutionGrid({width, height, entries}) {
183
- const grid = Array.from({length: height}, () => Array(width).fill(''));
184
- for (const e of entries) {
185
- const [dr, dc] = DIRS[e.dir] || [];
186
- if (dr === undefined) throw new Error('An entry has no direction.');
187
- for (let i = 0; i < e.letters.length; i++) {
188
- const r = e.row + dr * i;
189
- const c = e.col + dc * i;
190
- if (r < 0 || c < 0 || r >= height || c >= width) throw new Error(`"${e.answer}" runs off the grid.`);
191
- if (grid[r][c] && grid[r][c] !== e.letters[i]) throw new Error(`"${e.answer}" clashes with another answer.`);
192
- grid[r][c] = e.letters[i];
193
- }
194
- }
195
- return grid;
196
- }
197
-
198
- /** The grid's shape for the player: '#' black, '.' a square to fill - no letters. */
199
- export const shapeOf = (grid) => grid.map(row => row.map(ch => (ch ? '.' : '#')).join(''));
@@ -1,43 +0,0 @@
1
- # quiz - AI Assistant Guide
2
-
3
- ## Purpose
4
-
5
- Timed quizzes on the public site with name-only players and leaderboards. 1.1.0 (2026-10-21), `tier: free`,
6
- disabled on arrival. Sibling of `crossword`; both sit in the sidebar's Tools > Games folder (`"group": "Games"`)
7
- and share `plugins/_lib/games` (server) + `_lib/public/games` (browser) + `_lib/admin/games` (admin kit).
8
-
9
- ## Files
10
-
11
- - `lib/quiz.js` - PURE rules: `cleanQuiz` (the only gate on what the admin saves), `isOpen`, `planPlay` (question
12
- order, option order, `draw`, fixed at start), `publicQuestion` (never `correct`), `mark`, `foldAnswer`.
13
- - `lib/engine.js` - PURE clock: `catchUp`, `serve`, `answer`, `end`, `view`. Each mutates the play and says if it
14
- changed; the route saves through `plays.change()` (locked read-modify-write).
15
- - `lib/store.js` - `quiz-quizzes` collection + `plays = new PlayStore('quiz-plays', {order: 'score'})`.
16
- - `plugin.js` - routes (header comment lists them), shortcodes, the `top-scores` notification source.
17
- - `public/quiz.js` - the player (domma-reactive bindings), injected on every page by `inject/body.html`. Exports
18
- `mount(el, {test})`, which the admin's Test screen uses.
19
- - `admin/views/quiz.js` - list, editor, test and plays screens, all domma-reactive through the admin kit
20
- (`mountScreen`, `wireRows`, `sortableRows`, `testScreen`, `playsScreen` in `_lib/admin/games/kit.js`). Templates
21
- are strings in the JS (a templateUrl goes through `_.render`, which eats data-each `{{ }}`); the questions are an
22
- `observableArray` of question models with their answers nested as `{{#each options key=id}}`.
23
- - Tests: `tests/plugins/games-pure.test.js`, `tests/plugins/games-api.test.js` in the CMS repo.
24
-
25
- ## Contracts
26
-
27
- - **The server keeps time and marks.** A question's clock starts at `serve()` - the GET of `/public/play/:id` -
28
- and only then can it be answered (`answer()` refuses an unserved one with `code: 'not-current'`). So the player
29
- must GET the next question after each answer, not use the one in the answer response. `GRACE_MS` (3s) covers
30
- latency; late answers are marked wrong, never refused.
31
- - **The play token** (`X-Play-Token`) is returned once by start and compared with `timingSafeEqual`.
32
- `PlayStore.adminView` strips it and the state from everything the admin sees.
33
- - **Leaderboards** rank finished, unhidden plays, best per `nameKey`, score then time (`_lib/games/ranking.js`).
34
- - **Shortcode prefix trap:** the engine's plugin-shortcode regex is a PREFIX match, so `[quiz` also matches
35
- `[quiz-leaderboard`. `quiz-leaderboard` is registered with `{priority: 5}` so it runs first. Keep it that way.
36
- - **Domma bindings:** a `data-on-submit` handler must return `false` synchronously - an async one returns a
37
- Promise and the form submits natively (page reload).
38
- - Collections' API verbs are all off: nothing here is readable through `/api/v1`.
39
- - **Test plays** (1.1.0): `GET/POST /quizzes/:id/test` (manage) give the start screen and a play marked `test: true`,
40
- for the SAVED quiz whatever its status. It is played through the ordinary `/public/play` routes. `PlayStore` leaves
41
- tests out of `forGame` (so boards, places, goes-per-name and the admin list), `todayBadge` skips them,
42
- `afterFinish` never notifies for one, and the next test of the game clears them (`clearTests`). `/result` answers
43
- `test: true` and `wouldPlace` instead of a place. Tests: `games-api.test.js` "test plays".
@@ -1,13 +0,0 @@
1
- /* Quiz admin - the question editor. Shared pieces are in /plugins/_lib/admin/games/games.css. */
2
- .qza-questions { display: flex; flex-direction: column; gap: .75rem; }
3
- .qza-question { border: 1px solid var(--gma-line); border-left: 4px solid var(--dm-primary); border-radius: var(--dm-radius-md, 8px);
4
- padding: .85rem 1rem; display: flex; flex-direction: column; gap: .6rem; background: var(--dm-background, transparent); }
5
- .qza-question .form-select { width: auto; }
6
- .qza-answers { display: flex; flex-direction: column; gap: .4rem; }
7
- .qza-answer { display: flex; align-items: center; gap: .5rem; padding: .25rem .4rem; border-radius: 6px; }
8
- .qza-answer.is-right { background: color-mix(in srgb, var(--dm-success) 12%, transparent); }
9
- .qza-answer .form-input { flex: 1; min-width: 0; }
10
- .qza-mark { display: inline-flex; align-items: center; cursor: pointer; padding: .2rem; }
11
- .qza-mark input { width: 1.1rem; height: 1.1rem; accent-color: var(--dm-success); }
12
- .qza-fixed { flex: 1; font-weight: 500; }
13
- .qza-more summary { cursor: pointer; color: var(--dm-text-muted); font-size: var(--dm-text-sm); margin-bottom: .5rem; }
@@ -1 +0,0 @@
1
- <div class="gma qza" data-quiz-admin></div>