domma-cms 1.8.45 → 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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/admin/js/lib/code-format.js +50 -40
  3. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  4. package/admin/js/views/page-editor.js +10 -8
  5. package/bin/lib/left-free-tier.js +83 -0
  6. package/bin/update.js +24 -0
  7. package/docs/DommaFeatures.md +47 -7
  8. package/docs/configuration.md +19 -7
  9. package/docs/plugin-development.md +7 -0
  10. package/package.json +1 -1
  11. package/plugins/_lib/games/names.js +6 -0
  12. package/plugins/_lib/public/games/motion.css +1 -0
  13. package/plugins/_lib/public/games/motion.js +28 -0
  14. package/plugins/free-tier.lock.json +1 -43
  15. package/server/routes/api/api-endpoints.js +5 -1
  16. package/server/routes/api/collections.js +4 -3
  17. package/server/routes/api/forms.js +22 -0
  18. package/server/routes/api/views.js +18 -0
  19. package/server/services/collectionAccess.js +97 -15
  20. package/server/services/permissionRegistry.js +39 -11
  21. package/plugins/crossword/CLAUDE.md +0 -37
  22. package/plugins/crossword/admin/css/index.css +0 -18
  23. package/plugins/crossword/admin/templates/crossword.html +0 -1
  24. package/plugins/crossword/admin/views/crossword.js +0 -449
  25. package/plugins/crossword/collections/crossword-plays.json +0 -20
  26. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  27. package/plugins/crossword/config.js +0 -6
  28. package/plugins/crossword/docs/guide.md +0 -53
  29. package/plugins/crossword/inject/body.html +0 -1
  30. package/plugins/crossword/lib/crossword.js +0 -170
  31. package/plugins/crossword/lib/store.js +0 -56
  32. package/plugins/crossword/plugin.js +0 -274
  33. package/plugins/crossword/plugin.json +0 -79
  34. package/plugins/crossword/public/crossword.css +0 -36
  35. package/plugins/crossword/public/crossword.js +0 -519
  36. package/plugins/crossword/public/lib/layout.js +0 -199
  37. package/plugins/quiz/CLAUDE.md +0 -43
  38. package/plugins/quiz/admin/css/index.css +0 -13
  39. package/plugins/quiz/admin/templates/quiz.html +0 -1
  40. package/plugins/quiz/admin/views/quiz.js +0 -530
  41. package/plugins/quiz/collections/quiz-plays.json +0 -20
  42. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  43. package/plugins/quiz/config.js +0 -6
  44. package/plugins/quiz/docs/guide.md +0 -61
  45. package/plugins/quiz/inject/body.html +0 -1
  46. package/plugins/quiz/lib/engine.js +0 -115
  47. package/plugins/quiz/lib/quiz.js +0 -225
  48. package/plugins/quiz/lib/store.js +0 -56
  49. package/plugins/quiz/plugin.js +0 -275
  50. package/plugins/quiz/plugin.json +0 -79
  51. package/plugins/quiz/public/quiz.css +0 -36
  52. package/plugins/quiz/public/quiz.js +0 -372
@@ -1,115 +0,0 @@
1
- /**
2
- * Quiz - one play moving forward, by the server's clock. PURE: each function
3
- * takes the quiz, the play and `now`, changes the play in place and says
4
- * whether it changed (the route then saves it).
5
- *
6
- * catchUp(quiz, play, now) time that has run out while nobody asked: a question
7
- * past its limit is marked as not answered; past the
8
- * quiz's limit, the play ends
9
- * serve(quiz, play, now) the current question is now in front of the player
10
- * answer(quiz, play, qid, given, now) one answer, marked; the play moves on (or ends)
11
- * view(quiz, play, now) what the player may know of their play now
12
- *
13
- * @module quiz/lib/engine
14
- */
15
-
16
- import {GRACE_MS, leftOf, overdue} from '../../_lib/games/timing.js';
17
- import {mark, publicQuestion, questionLimitMs, rightText} from './quiz.js';
18
-
19
- const questionOf = (quiz, play) => {
20
- const slot = play.state.order[play.state.index];
21
- return slot ? quiz.questions.find(q => q.id === slot.q) || null : null;
22
- };
23
-
24
- const scoreOf = (play) => play.state.answers.reduce((s, a) => s + (a.points || 0), 0);
25
-
26
- /** Ends the play. Out of time: the time counted is the limit itself, never more. */
27
- export function end(quiz, play, now, {timeUp = false} = {}) {
28
- const limit = (quiz.timeLimit || 0) * 1000;
29
- play.status = 'finished';
30
- play.finishedAt = new Date(now).toISOString();
31
- play.score = scoreOf(play);
32
- const spent = Math.max(0, now - Date.parse(play.startedAt));
33
- play.timeMs = limit ? Math.min(spent, limit) : spent;
34
- play.state.timeUp = timeUp;
35
- return true;
36
- }
37
-
38
- function skip(quiz, play, now, why) {
39
- const q = questionOf(quiz, play);
40
- play.state.answers.push({q: q?.id || '', given: null, correct: false, points: 0, late: true, why, at: new Date(now).toISOString()});
41
- play.state.index += 1;
42
- if (play.state.index >= play.state.order.length) end(quiz, play, now);
43
- }
44
-
45
- /** Bring the play up to date with the clock. */
46
- export function catchUp(quiz, play, now = Date.now()) {
47
- if (play.status !== 'playing') return false;
48
- let changed = false;
49
- if (overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now)) return end(quiz, play, now, {timeUp: true});
50
- // Only a question that was SHOWN can run out: one never fetched has not started its clock.
51
- while (play.status === 'playing') {
52
- const q = questionOf(quiz, play);
53
- if (!q) { end(quiz, play, now); return true; }
54
- const served = play.state.servedAt[play.state.index];
55
- if (!served || !overdue(served, questionLimitMs(quiz, q), now)) break;
56
- skip(quiz, play, now, 'time');
57
- changed = true;
58
- }
59
- return changed;
60
- }
61
-
62
- /** The current question is on the player's screen from now - the first time only, so a reload does not reset its clock. */
63
- export function serve(quiz, play, now = Date.now()) {
64
- if (play.status !== 'playing' || play.state.servedAt[play.state.index]) return false;
65
- play.state.servedAt[play.state.index] = new Date(now).toISOString();
66
- return true;
67
- }
68
-
69
- /**
70
- * An answer to question `qid`. Refused (`error`) when it is not the question
71
- * they are on - an old tab, a replayed request. Late is marked wrong.
72
- *
73
- * @returns {{error?: string, correct?: boolean, late?: boolean, points?: number, q?: object}}
74
- */
75
- export function answer(quiz, play, qid, given, now = Date.now()) {
76
- if (play.status !== 'playing') return {error: 'This play has finished.'};
77
- const q = questionOf(quiz, play);
78
- if (!q || q.id !== String(qid)) return {error: 'That is not the question you are on. Carry on from where you are.', code: 'not-current'};
79
- const served = play.state.servedAt[play.state.index];
80
- if (!served) return {error: 'That question has not been shown yet.', code: 'not-current'};
81
- const late = overdue(served, questionLimitMs(quiz, q), now) || overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now);
82
- const result = late ? {correct: false, given: null} : mark(q, given);
83
- const points = result.correct ? q.points : 0;
84
- play.state.answers.push({q: q.id, given: result.given, correct: result.correct, points, late, at: new Date(now).toISOString()});
85
- play.state.index += 1;
86
- play.score = scoreOf(play);
87
- if (play.state.index >= play.state.order.length) end(quiz, play, now);
88
- else if (overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now)) end(quiz, play, now, {timeUp: true});
89
- return {correct: result.correct, late, points, q};
90
- }
91
-
92
- /** Feedback straight after an answer, when the quiz shows answers as you go. */
93
- export function feedbackFor(quiz, q, outcome) {
94
- if (quiz.showAnswers !== 'after-each' || !q) return null;
95
- return {correct: outcome.correct, late: outcome.late, rightAnswer: rightText(q), explanation: q.explanation || ''};
96
- }
97
-
98
- /** What the player may know of their play right now. */
99
- export function view(quiz, play, now = Date.now()) {
100
- const total = play.state.order.length;
101
- const base = {
102
- status: play.status, index: Math.min(play.state.index, total), total,
103
- elapsedMs: Math.max(0, (play.status === 'playing' ? now : Date.parse(play.finishedAt)) - Date.parse(play.startedAt)),
104
- score: play.score || 0, grace: GRACE_MS
105
- };
106
- if (play.status !== 'playing') return base;
107
- const q = publicQuestion(quiz, play.state);
108
- const served = play.state.servedAt[play.state.index];
109
- return {
110
- ...base,
111
- question: q,
112
- questionLeftMs: served ? leftOf(served, questionLimitMs(quiz, questionOf(quiz, play)), now) : null,
113
- totalLeftMs: leftOf(play.startedAt, (quiz.timeLimit || 0) * 1000, now)
114
- };
115
- }
@@ -1,225 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
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
- }