domma-cms 1.8.30 → 1.8.32
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.
- package/CHANGELOG.md +11 -0
- package/docs/DommaFeatures.md +5 -4
- package/package.json +3 -1
- package/plugins/_lib/admin/games/games.css +1 -0
- package/plugins/_lib/admin/games/kit.js +241 -0
- package/plugins/_lib/games/http.js +82 -0
- package/plugins/_lib/games/names.js +42 -0
- package/plugins/_lib/games/plays.js +155 -0
- package/plugins/_lib/games/ranking.js +68 -0
- package/plugins/_lib/games/timing.js +27 -0
- package/plugins/_lib/public/games/format.js +38 -0
- package/plugins/_lib/public/games/games.css +1 -0
- package/plugins/_lib/public/games/games.js +204 -0
- package/plugins/crossword/admin/css/index.css +1 -0
- package/plugins/crossword/admin/templates/crossword.html +1 -0
- package/plugins/crossword/admin/views/crossword.js +317 -0
- package/plugins/crossword/collections/crossword-plays.json +20 -0
- package/plugins/crossword/collections/crossword-puzzles.json +17 -0
- package/plugins/crossword/config.js +6 -0
- package/plugins/crossword/inject/body.html +1 -0
- package/plugins/crossword/lib/crossword.js +170 -0
- package/plugins/crossword/lib/store.js +56 -0
- package/plugins/crossword/plugin.js +241 -0
- package/plugins/crossword/plugin.json +78 -0
- package/plugins/crossword/public/crossword.css +1 -0
- package/plugins/crossword/public/crossword.js +57 -0
- package/plugins/crossword/public/lib/layout.js +199 -0
- package/plugins/quiz/admin/css/index.css +1 -0
- package/plugins/quiz/admin/templates/quiz.html +1 -0
- package/plugins/quiz/admin/views/quiz.js +352 -0
- package/plugins/quiz/collections/quiz-plays.json +20 -0
- package/plugins/quiz/collections/quiz-quizzes.json +17 -0
- package/plugins/quiz/config.js +6 -0
- package/plugins/quiz/inject/body.html +1 -0
- package/plugins/quiz/lib/engine.js +115 -0
- package/plugins/quiz/lib/quiz.js +225 -0
- package/plugins/quiz/lib/store.js +56 -0
- package/plugins/quiz/plugin.js +241 -0
- package/plugins/quiz/plugin.json +78 -0
- package/plugins/quiz/public/quiz.css +1 -0
- package/plugins/quiz/public/quiz.js +80 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - the rules. PURE: no I/O, so the same decisions run in the routes and
|
|
3
|
+
* the tests.
|
|
4
|
+
*
|
|
5
|
+
* cleanQuiz(input) -> {quiz, errors} what the admin saved, made safe
|
|
6
|
+
* isOpen(quiz, now) -> {open, reason} can people play it now?
|
|
7
|
+
* planPlay(quiz, rand) -> state this player's questions, in their order
|
|
8
|
+
* publicQuestion(...) -> what the player may see of a question (never the answer)
|
|
9
|
+
* mark(question, given) -> {correct, given} one answer marked
|
|
10
|
+
* answerText(question, ids) -> "Paris" an answer in words, for feedback and review
|
|
11
|
+
*
|
|
12
|
+
* @module quiz/lib/quiz
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const KINDS = ['single', 'multiple', 'truefalse', 'text'];
|
|
16
|
+
export const STATUSES = ['draft', 'open', 'closed'];
|
|
17
|
+
export const SHOW_ANSWERS = ['never', 'after-each', 'end'];
|
|
18
|
+
|
|
19
|
+
export const LIMITS = {questions: 200, options: 8, text: 1000, option: 200, accepted: 20, seconds: 3600, timeLimit: 4 * 3600, points: 100};
|
|
20
|
+
|
|
21
|
+
const str = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').trim().slice(0, max);
|
|
22
|
+
const int = (v, min, max, dflt = 0) => {
|
|
23
|
+
const n = Math.round(Number(v));
|
|
24
|
+
return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : dflt;
|
|
25
|
+
};
|
|
26
|
+
const iso = (v) => {
|
|
27
|
+
const t = Date.parse(String(v || ''));
|
|
28
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : '';
|
|
29
|
+
};
|
|
30
|
+
const shortId = () => Math.random().toString(36).slice(2, 10);
|
|
31
|
+
const idOf = (v) => (/^[a-z0-9_-]{1,40}$/i.test(String(v || '')) ? String(v) : shortId());
|
|
32
|
+
|
|
33
|
+
/** "Capital Cities 2026" -> "capital-cities-2026". */
|
|
34
|
+
export function slugify(s) {
|
|
35
|
+
return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** For comparing typed answers: case, accents, punctuation and spacing do not matter; a leading "the"/"a" neither. */
|
|
40
|
+
export function foldAnswer(s) {
|
|
41
|
+
return String(s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
42
|
+
.replace(/&/g, ' and ').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/^(the|a|an) /, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cleanQuestion(q, n, errors) {
|
|
46
|
+
const kind = KINDS.includes(q?.kind) ? q.kind : 'single';
|
|
47
|
+
const out = {
|
|
48
|
+
id: idOf(q?.id), kind,
|
|
49
|
+
text: str(q?.text, LIMITS.text),
|
|
50
|
+
image: str(q?.image, 500),
|
|
51
|
+
points: int(q?.points ?? 1, 0, LIMITS.points, 1),
|
|
52
|
+
seconds: int(q?.seconds, 0, LIMITS.seconds),
|
|
53
|
+
explanation: str(q?.explanation, LIMITS.text),
|
|
54
|
+
options: [],
|
|
55
|
+
accepted: []
|
|
56
|
+
};
|
|
57
|
+
const where = `Question ${n}`;
|
|
58
|
+
if (!out.text) errors[`q${n}`] = `${where} has no question.`;
|
|
59
|
+
if (out.image && !/^(\/(?!\/)|https:\/\/)/.test(out.image)) errors[`q${n}`] = `${where}: the picture must be on this site (/media/...) or an https:// address.`;
|
|
60
|
+
|
|
61
|
+
if (kind === 'text') {
|
|
62
|
+
const raw = Array.isArray(q?.accepted) ? q.accepted : String(q?.accepted || '').split('\n');
|
|
63
|
+
out.accepted = [...new Set(raw.map(a => str(a, LIMITS.option)).filter(Boolean))].slice(0, LIMITS.accepted);
|
|
64
|
+
if (!out.accepted.length) errors[`q${n}`] ??= `${where} needs at least one accepted answer.`;
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
if (kind === 'truefalse') {
|
|
68
|
+
const isTrue = Array.isArray(q?.options) ? q.options.find(o => o?.id === 'true')?.correct !== false : true;
|
|
69
|
+
out.options = [{id: 'true', text: 'True', correct: isTrue}, {id: 'false', text: 'False', correct: !isTrue}];
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const o of (Array.isArray(q?.options) ? q.options : []).slice(0, LIMITS.options)) {
|
|
74
|
+
const text = str(o?.text, LIMITS.option);
|
|
75
|
+
if (!text) continue;
|
|
76
|
+
let id = idOf(o?.id);
|
|
77
|
+
while (seen.has(id)) id = shortId();
|
|
78
|
+
seen.add(id);
|
|
79
|
+
out.options.push({id, text, correct: o?.correct === true});
|
|
80
|
+
}
|
|
81
|
+
const right = out.options.filter(o => o.correct).length;
|
|
82
|
+
if (out.options.length < 2) errors[`q${n}`] ??= `${where} needs at least two answers to choose from.`;
|
|
83
|
+
else if (!right) errors[`q${n}`] ??= `${where}: tick the right answer.`;
|
|
84
|
+
else if (kind === 'single' && right > 1) errors[`q${n}`] ??= `${where} allows one answer but has ${right} ticked. Make it "more than one answer" or untick the others.`;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The quiz as the admin saved it, cleaned. `errors` keyed by field (`title`, `q3`...).
|
|
90
|
+
*
|
|
91
|
+
* @returns {{quiz: object, errors: Object<string, string>}}
|
|
92
|
+
*/
|
|
93
|
+
export function cleanQuiz(input = {}) {
|
|
94
|
+
const errors = {};
|
|
95
|
+
const quiz = {
|
|
96
|
+
title: str(input.title, 120),
|
|
97
|
+
slug: slugify(input.slug || input.title),
|
|
98
|
+
intro: str(input.intro, 2000),
|
|
99
|
+
status: STATUSES.includes(input.status) ? input.status : 'draft',
|
|
100
|
+
opensAt: iso(input.opensAt),
|
|
101
|
+
closesAt: iso(input.closesAt),
|
|
102
|
+
timeLimit: int(input.timeLimit, 0, LIMITS.timeLimit),
|
|
103
|
+
questionSeconds: int(input.questionSeconds, 0, LIMITS.seconds),
|
|
104
|
+
shuffleQuestions: input.shuffleQuestions === true,
|
|
105
|
+
shuffleOptions: input.shuffleOptions === true,
|
|
106
|
+
draw: int(input.draw, 0, LIMITS.questions),
|
|
107
|
+
showAnswers: SHOW_ANSWERS.includes(input.showAnswers) ? input.showAnswers : 'end',
|
|
108
|
+
leaderboard: input.leaderboard !== false,
|
|
109
|
+
leaderboardSize: int(input.leaderboardSize ?? 10, 3, 100, 10),
|
|
110
|
+
attemptsPerName: int(input.attemptsPerName, 0, 100),
|
|
111
|
+
questions: []
|
|
112
|
+
};
|
|
113
|
+
if (!quiz.title) errors.title = 'Give the quiz a title.';
|
|
114
|
+
if (!quiz.slug) errors.slug = 'Give the quiz a short name for its address (letters and numbers).';
|
|
115
|
+
if (quiz.opensAt && quiz.closesAt && quiz.closesAt <= quiz.opensAt) errors.closesAt = 'It has to close after it opens.';
|
|
116
|
+
const raw = Array.isArray(input.questions) ? input.questions.slice(0, LIMITS.questions) : [];
|
|
117
|
+
const ids = new Set();
|
|
118
|
+
raw.forEach((q, i) => {
|
|
119
|
+
const clean = cleanQuestion(q, i + 1, errors);
|
|
120
|
+
while (ids.has(clean.id)) clean.id = shortId();
|
|
121
|
+
ids.add(clean.id);
|
|
122
|
+
quiz.questions.push(clean);
|
|
123
|
+
});
|
|
124
|
+
if (quiz.status === 'open' && !quiz.questions.length) errors.questions = 'Add at least one question before opening the quiz.';
|
|
125
|
+
if (quiz.draw && quiz.draw > quiz.questions.length) quiz.draw = 0;
|
|
126
|
+
return {quiz, errors};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Can people play it right now? `reason` is said to them when not. */
|
|
130
|
+
export function isOpen(quiz, now = Date.now()) {
|
|
131
|
+
if (!quiz || quiz.status === 'draft') return {open: false, reason: 'This quiz is not open yet.', state: 'draft'};
|
|
132
|
+
if (quiz.status === 'closed') return {open: false, reason: 'This quiz has closed.', state: 'closed'};
|
|
133
|
+
if (quiz.opensAt && Date.parse(quiz.opensAt) > now) return {open: false, reason: 'This quiz opens soon.', state: 'scheduled', opensAt: quiz.opensAt};
|
|
134
|
+
if (quiz.closesAt && Date.parse(quiz.closesAt) <= now) return {open: false, reason: 'This quiz has closed.', state: 'ended'};
|
|
135
|
+
if (!quiz.questions?.length) return {open: false, reason: 'This quiz has no questions yet.', state: 'empty'};
|
|
136
|
+
return {open: true, reason: '', state: 'open'};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fisher-Yates with the caller's random (tests pass a fixed one). */
|
|
140
|
+
export function shuffle(list, rand = Math.random) {
|
|
141
|
+
const a = [...list];
|
|
142
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
143
|
+
const j = Math.floor(rand() * (i + 1));
|
|
144
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
145
|
+
}
|
|
146
|
+
return a;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* This player's quiz: which questions (all, or `draw` of them), in what order,
|
|
151
|
+
* and each one's answers in what order. Fixed at the start so a reload shows
|
|
152
|
+
* the same thing.
|
|
153
|
+
*/
|
|
154
|
+
export function planPlay(quiz, rand = Math.random) {
|
|
155
|
+
let qs = quiz.shuffleQuestions || quiz.draw ? shuffle(quiz.questions, rand) : [...quiz.questions];
|
|
156
|
+
if (quiz.draw) qs = qs.slice(0, quiz.draw);
|
|
157
|
+
return {
|
|
158
|
+
order: qs.map(q => ({q: q.id, opts: (quiz.shuffleOptions && q.kind !== 'truefalse' ? shuffle(q.options, rand) : q.options).map(o => o.id)})),
|
|
159
|
+
index: 0,
|
|
160
|
+
servedAt: [],
|
|
161
|
+
answers: []
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const maxScoreOf = (quiz, state) => state.order.reduce((sum, o) => sum + (quiz.questions.find(q => q.id === o.q)?.points ?? 0), 0);
|
|
166
|
+
|
|
167
|
+
/** A question's time limit in ms (its own, else the quiz's default), 0 = none. */
|
|
168
|
+
export const questionLimitMs = (quiz, q) => ((q?.seconds || quiz.questionSeconds || 0) * 1000);
|
|
169
|
+
|
|
170
|
+
/** What the player sees: the question and its choices - never which is right. */
|
|
171
|
+
export function publicQuestion(quiz, state) {
|
|
172
|
+
const slot = state.order[state.index];
|
|
173
|
+
const q = slot && quiz.questions.find(x => x.id === slot.q);
|
|
174
|
+
if (!q) return null;
|
|
175
|
+
const byId = new Map(q.options.map(o => [o.id, o]));
|
|
176
|
+
return {
|
|
177
|
+
id: q.id, kind: q.kind, text: q.text, image: q.image, points: q.points,
|
|
178
|
+
options: q.kind === 'text' ? [] : slot.opts.map(id => byId.get(id)).filter(Boolean).map(o => ({id: o.id, text: o.text})),
|
|
179
|
+
seconds: questionLimitMs(quiz, q) / 1000
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One answer marked. `given`: an option id (single, truefalse), a list of ids
|
|
185
|
+
* (multiple), or what they typed (text). Anything else is simply wrong.
|
|
186
|
+
*
|
|
187
|
+
* @returns {{correct: boolean, given: string|string[]}}
|
|
188
|
+
*/
|
|
189
|
+
export function mark(q, given) {
|
|
190
|
+
if (!q) return {correct: false, given: ''};
|
|
191
|
+
if (q.kind === 'text') {
|
|
192
|
+
const typed = String(given ?? '').slice(0, LIMITS.option);
|
|
193
|
+
const f = foldAnswer(typed);
|
|
194
|
+
return {correct: Boolean(f) && q.accepted.some(a => foldAnswer(a) === f), given: typed};
|
|
195
|
+
}
|
|
196
|
+
const ids = new Set(q.options.map(o => o.id));
|
|
197
|
+
if (q.kind === 'multiple') {
|
|
198
|
+
const picked = [...new Set((Array.isArray(given) ? given : [given]).map(String).filter(id => ids.has(id)))];
|
|
199
|
+
const right = q.options.filter(o => o.correct).map(o => o.id);
|
|
200
|
+
return {correct: picked.length === right.length && right.every(id => picked.includes(id)), given: picked};
|
|
201
|
+
}
|
|
202
|
+
const one = ids.has(String(given)) ? String(given) : '';
|
|
203
|
+
return {correct: Boolean(one) && q.options.find(o => o.id === one)?.correct === true, given: one};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** An answer (option ids, or typed text) in words. */
|
|
207
|
+
export function answerText(q, given) {
|
|
208
|
+
if (!q) return '';
|
|
209
|
+
if (q.kind === 'text') return String(given ?? '');
|
|
210
|
+
const list = Array.isArray(given) ? given : [given];
|
|
211
|
+
return list.map(id => q.options.find(o => o.id === id)?.text).filter(Boolean).join(', ');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The right answer in words. */
|
|
215
|
+
export const rightText = (q) => (q?.kind === 'text' ? q.accepted[0] || '' : q?.options.filter(o => o.correct).map(o => o.text).join(', ') || '');
|
|
216
|
+
|
|
217
|
+
/** The quiz as a list row in the admin. */
|
|
218
|
+
export function summary(quiz, plays = []) {
|
|
219
|
+
const finished = plays.filter(p => p.status === 'finished');
|
|
220
|
+
return {
|
|
221
|
+
id: quiz.id, title: quiz.title, slug: quiz.slug, status: quiz.status, opensAt: quiz.opensAt, closesAt: quiz.closesAt,
|
|
222
|
+
questions: quiz.questions?.length || 0, plays: plays.length, finished: finished.length,
|
|
223
|
+
best: finished.reduce((m, p) => Math.max(m, p.score || 0), 0), updatedAt: quiz.updatedAt || ''
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - where quizzes and plays are kept: the collections `quiz-quizzes` and
|
|
3
|
+
* `quiz-plays` (made at every boot when missing; API access all off - a quiz
|
|
4
|
+
* holds its answers, a play the player's name).
|
|
5
|
+
*
|
|
6
|
+
* @module quiz/lib/store
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
|
|
11
|
+
import {PlayStore} from '../../_lib/games/plays.js';
|
|
12
|
+
|
|
13
|
+
export const QUIZZES = 'quiz-quizzes';
|
|
14
|
+
export const PLAYS = 'quiz-plays';
|
|
15
|
+
|
|
16
|
+
export const plays = new PlayStore(PLAYS, {order: 'score'});
|
|
17
|
+
|
|
18
|
+
export async function ensureCollections(log = console) {
|
|
19
|
+
for (const slug of [QUIZZES, PLAYS]) {
|
|
20
|
+
if (await getCollection(slug)) continue;
|
|
21
|
+
const schema = JSON.parse(await fs.readFile(new URL(`../collections/${slug}.json`, import.meta.url), 'utf8'));
|
|
22
|
+
await createCollection(schema);
|
|
23
|
+
log.info?.(`[quiz] Created collection: ${slug}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const flat = (e) => (e ? {id: e.id, ...e.data, createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt} : null);
|
|
28
|
+
|
|
29
|
+
let cache = null;
|
|
30
|
+
export async function allQuizzes() {
|
|
31
|
+
cache ??= listEntries(QUIZZES, {limit: 100000}).then(r => (r.entries || []).map(flat)).catch(() => []);
|
|
32
|
+
return cache;
|
|
33
|
+
}
|
|
34
|
+
const forget = () => { cache = null; };
|
|
35
|
+
|
|
36
|
+
export const getQuiz = async (id) => flat(await getEntry(QUIZZES, String(id)).catch(() => null));
|
|
37
|
+
export const quizBySlug = async (slug) => (await allQuizzes()).find(q => q.slug === String(slug)) || null;
|
|
38
|
+
|
|
39
|
+
/** A short name no other quiz has. */
|
|
40
|
+
export async function uniqueSlug(slug, selfId = '') {
|
|
41
|
+
const taken = new Set((await allQuizzes()).filter(q => q.id !== selfId).map(q => q.slug));
|
|
42
|
+
if (!taken.has(slug)) return slug;
|
|
43
|
+
for (let n = 2; ; n++) if (!taken.has(`${slug}-${n}`)) return `${slug}-${n}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function saveQuiz(id, data) {
|
|
47
|
+
const saved = id ? await updateEntry(QUIZZES, id, data) : await createEntry(QUIZZES, data);
|
|
48
|
+
forget();
|
|
49
|
+
return flat(saved);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function removeQuiz(id) {
|
|
53
|
+
await deleteEntry(QUIZZES, String(id));
|
|
54
|
+
forget();
|
|
55
|
+
return plays.reset(String(id));
|
|
56
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - the API (mounted at /api/plugins/quiz).
|
|
3
|
+
*
|
|
4
|
+
* Admin (the `quiz` permission; read to look, manage to change):
|
|
5
|
+
* GET /count sidebar badge: plays finished today
|
|
6
|
+
* GET /quizzes every quiz as a list row
|
|
7
|
+
* GET /quizzes/:id · POST /quizzes · PUT /quizzes/:id · DELETE /quizzes/:id
|
|
8
|
+
* POST /quizzes/:id/copy a copy, as a draft
|
|
9
|
+
* GET /quizzes/:id/plays · DELETE /quizzes/:id/plays · PUT /plays/:id · DELETE /plays/:id
|
|
10
|
+
*
|
|
11
|
+
* Players (no sign-in), under /public - the server keeps the time and marks every answer:
|
|
12
|
+
* GET /public/quiz/:slug title, intro, how many questions, the limits
|
|
13
|
+
* GET /public/quiz/:slug/leaderboard ?period=today|week|month|all&limit=
|
|
14
|
+
* POST /public/quiz/:slug/start {name} -> {playId, token} (the token is shown once)
|
|
15
|
+
* GET /public/play/:id where the play is now: the current question (no answer), time left
|
|
16
|
+
* POST /public/play/:id/answer {q, answer} -> marked; feedback when the quiz shows answers as you go
|
|
17
|
+
* GET /public/play/:id/result score, time, place, and the answers when the quiz shows them
|
|
18
|
+
* Each /public/play call carries the token in `X-Play-Token`.
|
|
19
|
+
*
|
|
20
|
+
* Shortcodes: [quiz id="<short name>" /] and [quiz-leaderboard id="<short name>" period="week" limit="10" /].
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {cleanPlayerName} from '../_lib/games/names.js';
|
|
24
|
+
import {boardQuery, fail, isBot, limited, mountPlayAdmin, todayBadge, tokenOf} from '../_lib/games/http.js';
|
|
25
|
+
import {answerText, cleanQuiz, isOpen, maxScoreOf, planPlay, rightText, summary} from './lib/quiz.js';
|
|
26
|
+
import {answer, catchUp, feedbackFor, serve, view} from './lib/engine.js';
|
|
27
|
+
import {allQuizzes, ensureCollections, getQuiz, plays, quizBySlug, removeQuiz, saveQuiz, uniqueSlug} from './lib/store.js';
|
|
28
|
+
|
|
29
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
30
|
+
const slugAttr = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 60);
|
|
31
|
+
|
|
32
|
+
function registerShortcodes(hooks) {
|
|
33
|
+
if (typeof hooks.registerShortcode !== 'function') return;
|
|
34
|
+
// Self-closing. The page's script (public/quiz.js, on every page via inject/body.html) mounts on the placeholder.
|
|
35
|
+
hooks.registerShortcode('quiz', (attrStr, _body, ctx) => {
|
|
36
|
+
const a = ctx.parseShortcodeAttrs(attrStr || '');
|
|
37
|
+
const slug = slugAttr(a.id || a.slug);
|
|
38
|
+
if (!slug) return '<p class="gm-muted">[quiz] needs id="the quiz\'s short name".</p>';
|
|
39
|
+
return `<div class="gm-app qz-app" data-quiz='${esc(JSON.stringify({slug}))}'><p class="gm-muted">Loading the quiz…</p></div>`;
|
|
40
|
+
});
|
|
41
|
+
hooks.registerShortcode('quiz-leaderboard', (attrStr, _body, ctx) => {
|
|
42
|
+
const a = ctx.parseShortcodeAttrs(attrStr || '');
|
|
43
|
+
const id = slugAttr(a.id || a.slug);
|
|
44
|
+
if (!id) return '<p class="gm-muted">[quiz-leaderboard] needs id="the quiz\'s short name".</p>';
|
|
45
|
+
const cfg = {id, period: String(a.period || 'all'), limit: Math.max(1, Math.min(100, Number(a.limit) || 10)), title: String(a.title || 'Leaderboard').slice(0, 80)};
|
|
46
|
+
return `<div class="qz-board" data-quiz-board='${esc(JSON.stringify(cfg))}'></div>`;
|
|
47
|
+
// First: the engine matches [quiz…/] by prefix, so [quiz] would otherwise take [quiz-leaderboard …/] too.
|
|
48
|
+
}, {priority: 5});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What anyone may know about a quiz before playing it. */
|
|
52
|
+
function publicInfo(quiz) {
|
|
53
|
+
const state = isOpen(quiz);
|
|
54
|
+
const count = quiz.draw || quiz.questions.length;
|
|
55
|
+
return {
|
|
56
|
+
slug: quiz.slug, title: quiz.title, intro: quiz.intro, open: state.open, reason: state.reason, opensAt: state.opensAt || '',
|
|
57
|
+
questions: count, timeLimit: quiz.timeLimit, questionSeconds: quiz.questionSeconds,
|
|
58
|
+
perQuestionTimes: quiz.questions.some(q => q.seconds > 0),
|
|
59
|
+
showAnswers: quiz.showAnswers, leaderboard: quiz.leaderboard, leaderboardSize: quiz.leaderboardSize,
|
|
60
|
+
attemptsPerName: quiz.attemptsPerName
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The review at the end: every question, their answer and the right one - only when the quiz shows answers. */
|
|
65
|
+
function review(quiz, play) {
|
|
66
|
+
if (quiz.showAnswers === 'never') return null;
|
|
67
|
+
return play.state.order.map((slot, i) => {
|
|
68
|
+
const q = quiz.questions.find(x => x.id === slot.q);
|
|
69
|
+
const a = play.state.answers[i];
|
|
70
|
+
if (!q) return null;
|
|
71
|
+
return {
|
|
72
|
+
text: q.text, image: q.image, points: q.points,
|
|
73
|
+
yours: a ? (a.given == null ? '' : answerText(q, a.given)) : '',
|
|
74
|
+
right: rightText(q), correct: a?.correct === true, late: a?.late === true, explanation: q.explanation || ''
|
|
75
|
+
};
|
|
76
|
+
}).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default async function quizPlugin(fastify, options) {
|
|
80
|
+
const {authenticate, requirePermission} = options.auth;
|
|
81
|
+
const hooks = options.hooks || {};
|
|
82
|
+
const can = (action) => ({preHandler: [authenticate, requirePermission('quiz', action)]});
|
|
83
|
+
|
|
84
|
+
await ensureCollections(fastify.log);
|
|
85
|
+
registerShortcodes(hooks);
|
|
86
|
+
|
|
87
|
+
if (typeof hooks.registerNotificationSource === 'function') {
|
|
88
|
+
try {
|
|
89
|
+
hooks.registerNotificationSource({id: 'top-scores', label: 'Quiz top scores', severity: 'info', defaultOn: true,
|
|
90
|
+
description: 'Someone has gone top of a quiz\'s all-time leaderboard.'});
|
|
91
|
+
} catch { /* older engine */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Admin ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
fastify.get('/count', can('read'), async () => todayBadge(plays, 'quiz play'));
|
|
97
|
+
|
|
98
|
+
fastify.get('/quizzes', can('read'), async () => {
|
|
99
|
+
const everyPlay = await plays.all();
|
|
100
|
+
return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id)))
|
|
101
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
fastify.get('/quizzes/:id', can('read'), async (request, reply) => {
|
|
105
|
+
const quiz = await getQuiz(request.params.id);
|
|
106
|
+
return quiz || fail(reply, 404, 'That quiz is not here any more.');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
async function save(request, reply, id) {
|
|
110
|
+
const current = id ? await getQuiz(id) : null;
|
|
111
|
+
if (id && !current) return fail(reply, 404, 'That quiz is not here any more.');
|
|
112
|
+
const {quiz, errors} = cleanQuiz(request.body || {});
|
|
113
|
+
if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
|
|
114
|
+
quiz.slug = await uniqueSlug(quiz.slug, id || '');
|
|
115
|
+
const saved = await saveQuiz(id, quiz);
|
|
116
|
+
return id ? saved : reply.code(201).send(saved);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fastify.post('/quizzes', can('manage'), (request, reply) => save(request, reply, ''));
|
|
120
|
+
fastify.put('/quizzes/:id', can('manage'), (request, reply) => save(request, reply, request.params.id));
|
|
121
|
+
|
|
122
|
+
fastify.post('/quizzes/:id/copy', can('manage'), async (request, reply) => {
|
|
123
|
+
const quiz = await getQuiz(request.params.id);
|
|
124
|
+
if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
|
|
125
|
+
const {id, createdAt, updatedAt, ...rest} = quiz;
|
|
126
|
+
const copy = {...rest, title: `${quiz.title} (copy)`.slice(0, 120), status: 'draft', slug: await uniqueSlug(quiz.slug)};
|
|
127
|
+
return reply.code(201).send(await saveQuiz('', copy));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
fastify.delete('/quizzes/:id', can('manage'), async (request, reply) => {
|
|
131
|
+
if (!await getQuiz(request.params.id)) return fail(reply, 404, 'That quiz is not here any more.');
|
|
132
|
+
return {ok: true, playsRemoved: await removeQuiz(request.params.id)};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
mountPlayAdmin(fastify, {store: plays, base: '/quizzes', can, exists: async (id) => Boolean(await getQuiz(id))});
|
|
136
|
+
|
|
137
|
+
// ---- Players ----------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** A quiz the public may see: anything but a draft. */
|
|
140
|
+
const shownQuiz = async (slug) => {
|
|
141
|
+
const quiz = await quizBySlug(slug);
|
|
142
|
+
return quiz && quiz.status !== 'draft' ? quiz : null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
fastify.get('/public/quiz/:slug', limited(120), async (request, reply) => {
|
|
146
|
+
const quiz = await shownQuiz(request.params.slug);
|
|
147
|
+
if (!quiz) return fail(reply, 404, 'There is no quiz here.');
|
|
148
|
+
return publicInfo(quiz);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
fastify.get('/public/quiz/:slug/leaderboard', limited(120), async (request, reply) => {
|
|
152
|
+
const quiz = await shownQuiz(request.params.slug);
|
|
153
|
+
if (!quiz || !quiz.leaderboard) return fail(reply, 404, 'There is no leaderboard here.');
|
|
154
|
+
const {period, limit} = boardQuery(request.query, quiz.leaderboardSize);
|
|
155
|
+
return {title: quiz.title, period, rows: await plays.board(quiz.id, {period, limit})};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
fastify.post('/public/quiz/:slug/start', limited(20, 10), async (request, reply) => {
|
|
159
|
+
const quiz = await shownQuiz(request.params.slug);
|
|
160
|
+
if (!quiz) return fail(reply, 404, 'There is no quiz here.');
|
|
161
|
+
const state = isOpen(quiz);
|
|
162
|
+
if (!state.open) return fail(reply, 409, state.reason, {code: 'closed'});
|
|
163
|
+
if (isBot(request.body)) return fail(reply, 400, 'Something went wrong. Please try again.');
|
|
164
|
+
const {name, error} = cleanPlayerName(request.body?.name);
|
|
165
|
+
if (error) return fail(reply, 400, error, {code: 'name'});
|
|
166
|
+
if (quiz.attemptsPerName && await plays.attemptsBy(quiz.id, name) >= quiz.attemptsPerName) {
|
|
167
|
+
return fail(reply, 409, quiz.attemptsPerName === 1 ? `${name} has already had a go at this quiz.` : `${name} has already had ${quiz.attemptsPerName} goes at this quiz.`, {code: 'attempts'});
|
|
168
|
+
}
|
|
169
|
+
const plan = planPlay(quiz);
|
|
170
|
+
const play = await plays.start({gameId: quiz.id, name, maxScore: maxScoreOf(quiz, plan), state: plan});
|
|
171
|
+
return reply.code(201).send({playId: play.id, token: play.token, name, total: plan.order.length, maxScore: play.maxScore});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The play behind a request, brought up to date with the clock (and saved
|
|
176
|
+
* when that changed it). Null after answering with the reason.
|
|
177
|
+
*/
|
|
178
|
+
async function livePlay(request, reply, {serveNow = false} = {}) {
|
|
179
|
+
const opened = await plays.open(request.params.id, tokenOf(request));
|
|
180
|
+
if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
|
|
181
|
+
const quiz = await getQuiz(opened.gameId);
|
|
182
|
+
if (!quiz) { fail(reply, 404, 'That quiz is not here any more.', {code: 'gone'}); return null; }
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
const play = await plays.change(opened.id, (p) => {
|
|
185
|
+
const a = catchUp(quiz, p, now);
|
|
186
|
+
const b = serveNow && serve(quiz, p, now);
|
|
187
|
+
return a || b ? p : null;
|
|
188
|
+
});
|
|
189
|
+
if (play.status === 'finished' && opened.status === 'playing') await afterFinish(quiz, play);
|
|
190
|
+
return {quiz, play, now};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A new top of the all-time board tells the admins. */
|
|
194
|
+
async function afterFinish(quiz, play) {
|
|
195
|
+
try {
|
|
196
|
+
if (!quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
|
|
197
|
+
hooks.notify?.({
|
|
198
|
+
title: `New top score on "${quiz.title}"`,
|
|
199
|
+
body: `${play.name}: ${play.score} of ${play.maxScore} in ${Math.round(play.timeMs / 1000)} seconds`,
|
|
200
|
+
severity: 'info', link: `#/plugins/quiz/plays/${quiz.id}`, source: 'top-scores', dedupeKey: `quiz-top:${play.id}`
|
|
201
|
+
});
|
|
202
|
+
} catch { /* a notice never breaks a play */ }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fastify.get('/public/play/:id', limited(240), async (request, reply) => {
|
|
206
|
+
const live = await livePlay(request, reply, {serveNow: true});
|
|
207
|
+
if (!live) return reply;
|
|
208
|
+
return view(live.quiz, live.play, live.now);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
fastify.post('/public/play/:id/answer', limited(240), async (request, reply) => {
|
|
212
|
+
const live = await livePlay(request, reply);
|
|
213
|
+
if (!live) return reply;
|
|
214
|
+
const {quiz} = live;
|
|
215
|
+
let outcome = null;
|
|
216
|
+
const play = await plays.change(live.play.id, (p) => {
|
|
217
|
+
outcome = answer(quiz, p, request.body?.q, request.body?.answer, Date.now());
|
|
218
|
+
return outcome.error ? null : p;
|
|
219
|
+
});
|
|
220
|
+
if (outcome?.error) return fail(reply, 409, outcome.error, {code: outcome.code || 'finished', play: view(quiz, play)});
|
|
221
|
+
if (play.status === 'finished') await afterFinish(quiz, play);
|
|
222
|
+
return {correct: outcome.correct, late: outcome.late, points: outcome.points, feedback: feedbackFor(quiz, outcome.q, outcome), play: view(quiz, play)};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
fastify.get('/public/play/:id/result', limited(120), async (request, reply) => {
|
|
226
|
+
const live = await livePlay(request, reply);
|
|
227
|
+
if (!live) return reply;
|
|
228
|
+
const {quiz, play} = live;
|
|
229
|
+
if (play.status !== 'finished') return fail(reply, 409, 'This play has not finished yet.', {code: 'playing'});
|
|
230
|
+
return {
|
|
231
|
+
name: play.name, score: play.score, maxScore: play.maxScore, timeMs: play.timeMs, timeUp: play.state.timeUp === true,
|
|
232
|
+
correct: play.state.answers.filter(a => a.correct).length, total: play.state.order.length,
|
|
233
|
+
hidden: play.hidden === true,
|
|
234
|
+
place: quiz.leaderboard && !play.hidden ? await plays.placeOf(play, 'all') : 0,
|
|
235
|
+
placeToday: quiz.leaderboard && !play.hidden ? await plays.placeOf(play, 'today') : 0,
|
|
236
|
+
leaderboard: quiz.leaderboard,
|
|
237
|
+
review: review(quiz, play)
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quiz",
|
|
3
|
+
"displayName": "Quiz",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"tier": "free",
|
|
6
|
+
"description": "Make quizzes in the admin and put them on any page. Players type a name, answer against the clock, and see where they came on the leaderboard. The server keeps the time and marks every answer, so the browser never holds the answers.",
|
|
7
|
+
"author": "Domma CMS",
|
|
8
|
+
"date": "2026-10-20",
|
|
9
|
+
"icon": "help-circle",
|
|
10
|
+
"permissions": [
|
|
11
|
+
{
|
|
12
|
+
"key": "quiz",
|
|
13
|
+
"label": "Quiz",
|
|
14
|
+
"group": "Plugins",
|
|
15
|
+
"icon": "help-circle",
|
|
16
|
+
"description": "See quizzes and who played them (read), or make, change and remove quizzes and plays (manage).",
|
|
17
|
+
"actions": [
|
|
18
|
+
{
|
|
19
|
+
"key": "read",
|
|
20
|
+
"label": "See",
|
|
21
|
+
"description": "Open Quiz and see the quizzes, plays and leaderboards"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "manage",
|
|
25
|
+
"label": "Manage",
|
|
26
|
+
"description": "Make, change and delete quizzes; hide or remove plays; reset a leaderboard"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"grant": [
|
|
30
|
+
"admin"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"inject": {
|
|
35
|
+
"bodyEnd": "inject/body.html"
|
|
36
|
+
},
|
|
37
|
+
"admin": {
|
|
38
|
+
"css": [
|
|
39
|
+
"../_lib/admin/games/games.css",
|
|
40
|
+
"admin/css/index.css"
|
|
41
|
+
],
|
|
42
|
+
"sidebar": [
|
|
43
|
+
{
|
|
44
|
+
"id": "quiz",
|
|
45
|
+
"text": "Quiz",
|
|
46
|
+
"icon": "help-circle",
|
|
47
|
+
"url": "#/plugins/quiz",
|
|
48
|
+
"section": "#/plugins/quiz",
|
|
49
|
+
"permission": "quiz",
|
|
50
|
+
"group": "Games",
|
|
51
|
+
"countUrl": "/api/plugins/quiz/count"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"routes": [
|
|
55
|
+
{
|
|
56
|
+
"path": "/plugins/quiz",
|
|
57
|
+
"view": "plugin-quiz",
|
|
58
|
+
"title": "Quiz - Domma CMS"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "/plugins/quiz/:a",
|
|
62
|
+
"view": "plugin-quiz",
|
|
63
|
+
"title": "Quiz - Domma CMS"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "/plugins/quiz/:a/:b",
|
|
67
|
+
"view": "plugin-quiz",
|
|
68
|
+
"title": "Quiz - Domma CMS"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"views": {
|
|
72
|
+
"plugin-quiz": {
|
|
73
|
+
"entry": "quiz/admin/views/quiz.js?v=5be1f180",
|
|
74
|
+
"exportName": "quizView"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.qz-q{font-size:1.35rem;line-height:1.35;margin:0 0 1rem;outline:none}.qz-image{display:block;max-width:100%;max-height:320px;border-radius:8px;margin:0 0 1rem}.qz-hint{margin:-.5rem 0 .75rem}.qz-options{display:grid;gap:.6rem}@media(min-width:640px){.qz-options{grid-template-columns:1fr 1fr}}.qz-option{display:flex;align-items:center;gap:.75rem;width:100%;text-align:left;font:inherit;cursor:pointer;padding:.8rem 1rem;border-radius:10px;border:2px solid var(--gm-line);background:var(--gm-soft);color:inherit;transition:border-color .12s,transform .12s}.qz-option:hover:not(:disabled){border-color:var(--gm-accent);transform:translateY(-1px)}.qz-option:focus-visible{outline:2px solid var(--gm-accent);outline-offset:2px}.qz-option:disabled{cursor:default;opacity:.7}.qz-option.is-picked{border-color:var(--gm-accent);background:color-mix(in srgb,var(--gm-accent) 15%,transparent)}.qz-letter{flex:none;display:inline-grid;place-items:center;width:1.8rem;height:1.8rem;border-radius:50%;background:var(--gm-accent);color:#fff;font-weight:700;font-size:.9rem}.qz-typed{display:flex;flex-direction:column;gap:.35rem;max-width:420px}.qz-verdict{font-size:1.8rem;margin:0 0 .5rem;outline:none}.qz-verdict.is-right{color:var(--gm-good)}.qz-verdict.is-wrong{color:var(--gm-bad)}.qz-review{margin-top:1.5rem}.qz-review h3{margin:0 0 .5rem;font-size:1.1rem}.qz-review-list{margin:0;padding-left:0;list-style:none;display:grid;gap:.5rem}.qz-review-list li{padding:.6rem .8rem;border-radius:8px;border-left:4px solid var(--gm-line);background:var(--gm-soft)}.qz-review-list li.is-right{border-left-color:var(--gm-good)}.qz-review-list li.is-wrong{border-left-color:var(--gm-bad)}.qz-review-q{margin:0 0 .25rem;font-weight:600}.qz-review-a{margin:0}.qz-live{position:absolute;left:-9999px}
|