@lincyaw/addon-classroom 0.0.0-stage → 1.0.0-rc.1

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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -2
  3. package/components/PmExercise.css +58 -0
  4. package/components/PmExercise.tsx +165 -0
  5. package/components/PmPredict.css +31 -0
  6. package/components/PmPredict.tsx +182 -0
  7. package/components/PmPulse.css +24 -0
  8. package/components/PmPulse.tsx +109 -0
  9. package/components/PmQuestionBox.css +16 -0
  10. package/components/PmQuestionBox.tsx +155 -0
  11. package/components/PmQuiz.css +43 -0
  12. package/components/PmQuiz.tsx +221 -0
  13. package/package.json +44 -3
  14. package/src/client/ClassroomOverlays.tsx +15 -0
  15. package/src/client/ClassroomPanel.css +32 -0
  16. package/src/client/ClassroomPanel.tsx +213 -0
  17. package/src/client/ClassroomToolbar.css +26 -0
  18. package/src/client/ClassroomToolbar.tsx +47 -0
  19. package/src/client/NoteSpot.css +50 -0
  20. package/src/client/NoteSpot.tsx +119 -0
  21. package/src/client/NotesLayer.css +9 -0
  22. package/src/client/NotesLayer.tsx +113 -0
  23. package/src/client/QuestionItem.css +17 -0
  24. package/src/client/QuestionItem.tsx +106 -0
  25. package/src/client/ai-answers.ts +29 -0
  26. package/src/client/browser-store.ts +86 -0
  27. package/src/client/composables.ts +43 -0
  28. package/src/client/env.d.ts +7 -0
  29. package/src/client/ids.ts +3 -0
  30. package/src/client/index.ts +4 -0
  31. package/src/client/plugin.ts +57 -0
  32. package/src/client/style.css +44 -0
  33. package/src/domain/config.ts +25 -0
  34. package/src/domain/exercise.ts +143 -0
  35. package/src/domain/index.ts +40 -0
  36. package/src/domain/messages.ts +298 -0
  37. package/src/domain/notes.ts +12 -0
  38. package/src/domain/place.ts +25 -0
  39. package/src/domain/predict.ts +34 -0
  40. package/src/domain/pulse.ts +19 -0
  41. package/src/domain/questions.ts +42 -0
  42. package/src/domain/quiz.ts +63 -0
  43. package/src/domain/records.ts +97 -0
  44. package/src/domain/source.ts +55 -0
  45. package/src/domain/summary.ts +96 -0
  46. package/src/domain/tally.ts +8 -0
  47. package/src/node/classroom-pass.ts +84 -0
  48. package/src/node/components.ts +10 -0
  49. package/src/node/directives.ts +77 -0
  50. package/src/node/fallbacks.ts +86 -0
  51. package/src/node/notebook.ts +32 -0
  52. package/src/node/presmium.ts +21 -0
  53. package/src/node/reveals.ts +29 -0
@@ -0,0 +1,298 @@
1
+ import type { Locale } from '@lincyaw/kernel'
2
+
3
+ interface Typical {
4
+ kind: string
5
+ hint: string
6
+ }
7
+
8
+ const zh = {
9
+ interaction: '课堂互动',
10
+ close: '关闭',
11
+ toolbar: {
12
+ button: '互动',
13
+ title: '课堂互动:提问、理解度、测验、预测与练习',
14
+ unanswered: (n: number) => `${n} 个未回答的提问`,
15
+ },
16
+ place: (index: number, title: string, block?: string) => `第 ${index} 页 · ${title}${block ? ` · ${block}` : ''}`,
17
+ people: (n: number) => `${n} 人`,
18
+ list: ',',
19
+ colon: ':',
20
+ quiz: {
21
+ selfTest: '自测:选择后提交,立即看到对错',
22
+ right: '回答正确',
23
+ wrong: (letters: string) => `回答错误,正确答案是 ${letters}`,
24
+ answering: '作答中',
25
+ closed: '作答已关闭',
26
+ notOpen: '尚未开放作答',
27
+ ended: '课堂已结束',
28
+ yours: (right: boolean) => `,你的答案${right ? '正确' : '错误'}`,
29
+ lateMine: ';关闭后送达的作答未计入',
30
+ waiting: '等待主讲开放作答',
31
+ submitted: (letters: string) => `已提交 ${letters},作答关闭前可以修改`,
32
+ open: '作答中:选择后提交',
33
+ submit: '提交',
34
+ openButton: '开放作答',
35
+ closeButton: '关闭作答',
36
+ tally: (respondents: number, right: number) => `已作答 ${respondents} 人,答对 ${right} 人`,
37
+ late: (n: number) => `关闭后送达的作答 ${n} 份,未计入`,
38
+ letters: '、',
39
+ },
40
+ exercise: {
41
+ passed: (passed: number, total: number) => `通过 ${passed}/${total}`,
42
+ failed: (failed: number, total: number) => `未通过 ${failed}/${total}`,
43
+ notRun: (kind: string) => `无法运行:${kind}`,
44
+ code: '练习代码',
45
+ checks: '校验用例',
46
+ checking: '正在校验…',
47
+ submit: '提交',
48
+ noExecutor: '此处无法运行代码',
49
+ error: (hint: string, type: string, message: string, line?: number) => `${hint}。${type}${message ? `: ${message}` : ''}${line ? `(第 ${line} 行)` : ''}`,
50
+ tally: (submitted: number, passed: number) => `已提交 ${submitted} 人,通过 ${passed} 人`,
51
+ ended: '课堂已结束:结果只在本机显示,不再提交',
52
+ common: (kinds: string) => `;常见错误:${kinds}`,
53
+ noResult: '校验程序没有给出结果',
54
+ unchecked: { kind: '无法校验', hint: '校验未完成,请查看错误信息' } as Typical,
55
+ unknown: '查看错误信息',
56
+ typical: {
57
+ SyntaxError: { kind: '语法错误', hint: '检查括号、冒号与引号是否成对' },
58
+ IndentationError: { kind: '缩进错误', hint: '同一代码块的缩进要一致' },
59
+ TabError: { kind: '缩进错误', hint: '不要混用空格与制表符' },
60
+ NameError: { kind: '名字未定义', hint: '检查拼写,变量与函数要先定义再使用' },
61
+ NotImplementedError: { kind: '尚未实现', hint: '把占位代码换成实现' },
62
+ AssertionError: { kind: '结果不对', hint: '对照校验用例检查返回值' },
63
+ TypeError: { kind: '类型不匹配', hint: '检查参数个数与值的类型,例如是否返回了 None' },
64
+ ZeroDivisionError: { kind: '除以零', hint: '先处理除数为零的情况,例如空列表' },
65
+ IndexError: { kind: '下标越界', hint: '检查下标范围与空序列' },
66
+ KeyError: { kind: '键不存在', hint: '先检查字典中是否有这个键' },
67
+ AttributeError: { kind: '属性不存在', hint: '检查对象的类型与属性名' },
68
+ ValueError: { kind: '取值不合法', hint: '检查输入值的范围与格式' },
69
+ RecursionError: { kind: '递归太深', hint: '检查递归的结束条件' },
70
+ Timeout: { kind: '运行超时', hint: '可能有死循环,检查循环的结束条件' },
71
+ } as Record<string, Typical>,
72
+ },
73
+ pulse: {
74
+ question: '这一页理解了吗?',
75
+ levels: ['懂了', '有点模糊', '没懂'],
76
+ joinFirst: '加入课堂后,可以在这里告诉主讲你的理解程度。',
77
+ distribution: '理解度分布',
78
+ },
79
+ predict: {
80
+ yours: '你的预测',
81
+ invalidTargets: (property: string) => `预测的 ${property} 中每个子内容块只能列出一次。`,
82
+ missingTarget: (property: string, target: string, block: string) => `预测的 ${property} 目标 ${target} 不是 ${block} 的直接子内容块。`,
83
+ placeholder: '写下你的预测',
84
+ save: '保存预测',
85
+ saved: (what: string) => `你的预测:${what}`,
86
+ hidden: '保存预测之后,这里显示答案。',
87
+ kept: '预测保存在这个浏览器中',
88
+ sent: '预测保存在这个浏览器中,并已交给主讲',
89
+ },
90
+ questions: {
91
+ question: '问题',
92
+ placeholder: '写下你的问题',
93
+ ask: '提问',
94
+ attachedTo: (where: string) => `问题将关联到 ${where}`,
95
+ joinFirst: '加入课堂后,可以在这里向主讲提问。',
96
+ ended: '课堂已结束,不再接受提问。',
97
+ here: '这里的提问',
98
+ mine: '我的提问',
99
+ answered: '(已回答)',
100
+ markAnswered: '标为已回答',
101
+ markUnanswered: '标为未回答',
102
+ ai: (provider: string) => `AI 回答(${provider})`,
103
+ aiTitle: (provider: string) => `请 AI 回答这个问题(由 ${provider} 的模型生成,发给课堂之前由你确认)`,
104
+ aiAnswering: 'AI 正在回答…',
105
+ aiDraft: 'AI 的回答,发给课堂之前请确认:',
106
+ aiFailed: (message: string) => `无法获得 AI 的回答:${message}`,
107
+ aiSetup: '如需 AI 回答提问,请先在“提问”窗格中设置你自己的模型服务。',
108
+ send: '发送到课堂',
109
+ discard: '放弃',
110
+ reply: (provider: string) => `AI 回答(${provider}):`,
111
+ },
112
+ panel: {
113
+ waiting: '主讲创建课堂后,这里按位置汇总提问、理解度、测验、预测与练习。',
114
+ questions: '提问',
115
+ unanswered: (n: number) => `提问(未回答 ${n})`,
116
+ none: '还没有提问',
117
+ pulse: '理解度',
118
+ quizzes: '测验',
119
+ predictions: '预测',
120
+ predicted: (place: string, respondents: number) => `${place}:已预测 ${respondents} 人`,
121
+ exercises: '练习',
122
+ quiz: (place: string, open: boolean, respondents: number, right: number) => `${place}:${open ? '作答中' : '已关闭'},已作答 ${respondents} 人,答对 ${right} 人`,
123
+ exercise: (place: string, tally: string) => `${place}:${tally}`,
124
+ levels: ' · ',
125
+ },
126
+ notes: {
127
+ button: '笔记',
128
+ label: (what: string) => `笔记:${what}`,
129
+ blockOf: (section: string, kind: string) => `${section} · ${kind}`,
130
+ kinds: { table: '表格', code: '代码' },
131
+ mine: '我的笔记',
132
+ remove: '删除',
133
+ removeLabel: (text: string) => `删除笔记:${text}`,
134
+ new: '新建笔记',
135
+ save: '保存笔记',
136
+ inClassroom: '保存在课堂与这个浏览器中,只有你能看到',
137
+ inBrowser: '保存在这个浏览器中,只有你能看到',
138
+ },
139
+ fallback: {
140
+ answer: '答案',
141
+ checks: '校验',
142
+ exercise: '练习',
143
+ predict: '预测',
144
+ write: '先写下你的预测。',
145
+ reveal: '写下预测后显示',
146
+ checkCell: '# 校验:先运行上面的单元格,再运行这个单元格;没有报错就是通过。',
147
+ questions: { label: '提问', text: ':加入课堂后,在网页或幻灯片中向主讲提问。' },
148
+ pulse: { label: '理解度投票', text: (question: string, levels: string[]) => `:${question}(${levels.join(' / ')})` },
149
+ },
150
+ }
151
+
152
+ export type ClassroomMessages = typeof zh
153
+
154
+ const en: ClassroomMessages = {
155
+ interaction: 'Classroom Interaction',
156
+ close: 'Close',
157
+ toolbar: {
158
+ button: 'Interaction',
159
+ title: 'Classroom interaction: questions, understanding, quizzes, predictions, and exercises',
160
+ unanswered: n => `${n} unanswered question${n === 1 ? '' : 's'}`,
161
+ },
162
+ place: (index, title, block) => `Page ${index} · ${title}${block ? ` · ${block}` : ''}`,
163
+ people: n => String(n),
164
+ list: ', ',
165
+ colon: ': ',
166
+ quiz: {
167
+ selfTest: 'Self-test: choose, then submit to see whether you are right',
168
+ right: 'Right',
169
+ wrong: letters => `Wrong; the right answer is ${letters}`,
170
+ answering: 'Answering',
171
+ closed: 'Answering closed',
172
+ notOpen: 'Not open for answers yet',
173
+ ended: 'The classroom has ended',
174
+ yours: right => `, your answer is ${right ? 'right' : 'wrong'}`,
175
+ lateMine: '; answers that arrived after closing were not counted',
176
+ waiting: 'Waiting for the presenter to open the quiz',
177
+ submitted: letters => `Submitted ${letters}; you can change it until answering closes`,
178
+ open: 'Answering: choose, then submit',
179
+ submit: 'Submit',
180
+ openButton: 'Open Answering',
181
+ closeButton: 'Close Answering',
182
+ tally: (respondents, right) => `${respondents} answered, ${right} right`,
183
+ late: n => `${n} answer${n === 1 ? '' : 's'} arrived after closing, not counted`,
184
+ letters: ', ',
185
+ },
186
+ exercise: {
187
+ passed: (passed, total) => `Passed ${passed}/${total}`,
188
+ failed: (failed, total) => `Failed ${failed}/${total}`,
189
+ notRun: kind => `Couldn’t run: ${kind}`,
190
+ code: 'Exercise code',
191
+ checks: 'Checks',
192
+ checking: 'Checking…',
193
+ submit: 'Submit',
194
+ noExecutor: 'Code can’t run here',
195
+ error: (hint, type, message, line) => `${hint}. ${type}${message ? `: ${message}` : ''}${line ? ` (line ${line})` : ''}`,
196
+ tally: (submitted, passed) => `${submitted} submitted, ${passed} passed`,
197
+ ended: 'The classroom has ended: the result shows here only and is not submitted',
198
+ common: kinds => `; common errors: ${kinds}`,
199
+ noResult: 'The checks gave no result',
200
+ unchecked: { kind: 'Not checked', hint: 'The checks didn’t finish; read the error message' },
201
+ unknown: 'Read the error message',
202
+ typical: {
203
+ SyntaxError: { kind: 'Syntax error', hint: 'Check that brackets, colons, and quotes are paired' },
204
+ IndentationError: { kind: 'Indentation error', hint: 'Indent a block consistently' },
205
+ TabError: { kind: 'Indentation error', hint: 'Do not mix spaces and tabs' },
206
+ NameError: { kind: 'Undefined name', hint: 'Check the spelling; define variables and functions before using them' },
207
+ NotImplementedError: { kind: 'Not implemented', hint: 'Replace the placeholder with an implementation' },
208
+ AssertionError: { kind: 'Wrong result', hint: 'Compare the return value with the checks' },
209
+ TypeError: { kind: 'Type mismatch', hint: 'Check the number of arguments and the types, e.g. a missing return' },
210
+ ZeroDivisionError: { kind: 'Division by zero', hint: 'Handle a zero divisor first, e.g. an empty list' },
211
+ IndexError: { kind: 'Index out of range', hint: 'Check the index range and empty sequences' },
212
+ KeyError: { kind: 'Missing key', hint: 'Check that the dictionary has the key first' },
213
+ AttributeError: { kind: 'Missing attribute', hint: 'Check the type of the object and the attribute name' },
214
+ ValueError: { kind: 'Invalid value', hint: 'Check the range and format of the input' },
215
+ RecursionError: { kind: 'Recursion too deep', hint: 'Check where the recursion ends' },
216
+ Timeout: { kind: 'Timed out', hint: 'There may be an endless loop; check where loops end' },
217
+ },
218
+ },
219
+ pulse: {
220
+ question: 'Did you follow this page?',
221
+ levels: ['Got it', 'A bit unclear', 'Lost'],
222
+ joinFirst: 'Join the classroom to tell the presenter how well you follow.',
223
+ distribution: 'Understanding',
224
+ },
225
+ predict: {
226
+ yours: 'Your prediction',
227
+ invalidTargets: property => `Prediction ${property} must list each child block only once.`,
228
+ missingTarget: (property, target, block) => `Prediction ${property} target ${target} is not a direct child block of ${block}.`,
229
+ placeholder: 'Write down your prediction',
230
+ save: 'Save Prediction',
231
+ saved: what => `Your prediction: ${what}`,
232
+ hidden: 'Once you save your prediction, the answer shows here.',
233
+ kept: 'Your prediction is saved in this browser',
234
+ sent: 'Your prediction is saved in this browser and sent to the presenter',
235
+ },
236
+ questions: {
237
+ question: 'Question',
238
+ placeholder: 'Write your question',
239
+ ask: 'Ask',
240
+ attachedTo: where => `The question goes to ${where}`,
241
+ joinFirst: 'Join the classroom to ask the presenter here.',
242
+ ended: 'The classroom has ended and takes no more questions.',
243
+ here: 'Questions Here',
244
+ mine: 'My Questions',
245
+ answered: ' (answered)',
246
+ markAnswered: 'Mark as Answered',
247
+ markUnanswered: 'Mark as Unanswered',
248
+ ai: provider => `AI answer (${provider})`,
249
+ aiTitle: provider => `Ask the AI to answer this question (a model of ${provider}; you confirm before the classroom gets it)`,
250
+ aiAnswering: 'The AI is answering…',
251
+ aiDraft: 'The AI’s answer, to confirm before the classroom gets it:',
252
+ aiFailed: message => `Couldn’t get the AI’s answer: ${message}`,
253
+ aiSetup: 'To have the AI answer questions, set up your own model service in the Ask pane first.',
254
+ send: 'Send to Classroom',
255
+ discard: 'Discard',
256
+ reply: provider => `AI answer (${provider}): `,
257
+ },
258
+ panel: {
259
+ waiting: 'Once the presenter creates a classroom, its questions, understanding, quizzes, predictions, and exercises are gathered here by place.',
260
+ questions: 'Questions',
261
+ unanswered: n => `Questions (${n} unanswered)`,
262
+ none: 'No questions yet',
263
+ pulse: 'Understanding',
264
+ quizzes: 'Quizzes',
265
+ predictions: 'Predictions',
266
+ predicted: (place, respondents) => `${place}: ${respondents} predicted`,
267
+ exercises: 'Exercises',
268
+ quiz: (place, open, respondents, right) => `${place}: ${open ? 'answering' : 'closed'}, ${respondents} answered, ${right} right`,
269
+ exercise: (place, tally) => `${place}: ${tally}`,
270
+ levels: ' · ',
271
+ },
272
+ notes: {
273
+ button: 'Notes',
274
+ label: what => `Notes: ${what}`,
275
+ blockOf: (section, kind) => `${section} · ${kind}`,
276
+ kinds: { table: 'table', code: 'code' },
277
+ mine: 'My Notes',
278
+ remove: 'Delete',
279
+ removeLabel: text => `Delete note: ${text}`,
280
+ new: 'New Note',
281
+ save: 'Save Note',
282
+ inClassroom: 'Saved in the classroom and in this browser; only you can see it',
283
+ inBrowser: 'Saved in this browser; only you can see it',
284
+ },
285
+ fallback: {
286
+ answer: 'Answer',
287
+ checks: 'Checks',
288
+ exercise: 'Exercise',
289
+ predict: 'Predict',
290
+ write: 'Write down your prediction first.',
291
+ reveal: 'Show After You Predict',
292
+ checkCell: '# Check: run the cell above, then this one; no error means it passes.',
293
+ questions: { label: 'Questions', text: ': join the classroom to ask the presenter on the web page or in the slides.' },
294
+ pulse: { label: 'Understanding vote', text: (question, levels) => `: ${question} (${levels.join(' / ')})` },
295
+ },
296
+ }
297
+
298
+ export const MESSAGES: Record<Locale, ClassroomMessages> = { 'zh-CN': zh, en }
@@ -0,0 +1,12 @@
1
+ export interface Note {
2
+ section: string
3
+ block?: string
4
+ text: string
5
+ at: number
6
+ }
7
+
8
+ export function notesAt<T extends { value: Note }>(notes: Iterable<T>, section: string, block?: string): T[] {
9
+ return [...notes]
10
+ .filter(({ value }) => value.section === section && value.block === block)
11
+ .sort((a, b) => a.value.at - b.value.at)
12
+ }
@@ -0,0 +1,25 @@
1
+ import type { SectionNumbers } from './config.ts'
2
+ import type { ClassroomMessages } from './messages.ts'
3
+
4
+ export interface Place {
5
+ section: string
6
+ block: string
7
+ title: string
8
+ index: number
9
+ }
10
+
11
+ export function placeKey(place: Pick<Place, 'section' | 'block'>): string {
12
+ return `${place.section}/${place.block}`
13
+ }
14
+
15
+ export function placeIndex(place: { section: string, index: number }, numbers: SectionNumbers = {}): number {
16
+ return numbers[place.section] ?? place.index
17
+ }
18
+
19
+ export function placeText(place: { section: string, index: number, title: string, block?: string }, t: ClassroomMessages, numbers?: SectionNumbers): string {
20
+ return t.place(placeIndex(place, numbers), place.title, place.block && isAuthorLabel(place.block) ? place.block : undefined)
21
+ }
22
+
23
+ export function isAuthorLabel(blockId: string): boolean {
24
+ return !/-\d+$/.test(blockId)
25
+ }
@@ -0,0 +1,34 @@
1
+ import type { Place } from './place.ts'
2
+ import { latestPerParticipant } from './tally.ts'
3
+
4
+ export interface SavedPrediction {
5
+ choice?: number
6
+ text?: string
7
+ at: number
8
+ }
9
+
10
+ export interface Prediction extends Place, SavedPrediction {
11
+ predictId: string
12
+ participant: string
13
+ question: string
14
+ choices?: string[]
15
+ }
16
+
17
+ export function predictionId(predictId: string, participant: string): string {
18
+ return `${predictId}:${participant}`
19
+ }
20
+
21
+ export interface PredictionTally {
22
+ counts: number[]
23
+ respondents: number
24
+ texts: string[]
25
+ }
26
+
27
+ export function predictionTally(predictId: string, choices: number, predictions: Iterable<Prediction>): PredictionTally {
28
+ const last = latestPerParticipant([...predictions].filter(p => p.predictId === predictId)).sort((a, b) => a.at - b.at)
29
+ return {
30
+ counts: Array.from({ length: choices }, (_, i) => last.filter(p => p.choice === i).length),
31
+ respondents: last.length,
32
+ texts: last.flatMap(p => (p.text ? [p.text] : [])),
33
+ }
34
+ }
@@ -0,0 +1,19 @@
1
+ import type { Place } from './place.ts'
2
+ import { latestPerParticipant } from './tally.ts'
3
+
4
+ export interface PulseVote extends Place {
5
+ pulseId: string
6
+ participant: string
7
+ level: number
8
+ levels: string[]
9
+ at: number
10
+ }
11
+
12
+ export function voteId(pulseId: string, participant: string): string {
13
+ return `${pulseId}:${participant}`
14
+ }
15
+
16
+ export function pulseTally(pulseId: string, levels: number, votes: Iterable<PulseVote>): number[] {
17
+ const last = latestPerParticipant([...votes].filter(v => v.pulseId === pulseId))
18
+ return Array.from({ length: levels }, (_, i) => last.filter(v => v.level === i).length)
19
+ }
@@ -0,0 +1,42 @@
1
+ import type { SectionNumbers } from './config.ts'
2
+ import type { ClassroomMessages } from './messages.ts'
3
+ import { placeIndex, placeText } from './place.ts'
4
+
5
+ export interface Question {
6
+ section: string
7
+ block?: string
8
+ title: string
9
+ index: number
10
+ text: string
11
+ participant: string
12
+ name: string
13
+ at: number
14
+ answered?: boolean
15
+ reply?: QuestionReply
16
+ }
17
+
18
+ export interface QuestionReply {
19
+ text: string
20
+ provider?: string
21
+ at: number
22
+ }
23
+
24
+ export interface QuestionGroup<T extends Question> {
25
+ label: string
26
+ section: string
27
+ block?: string
28
+ questions: T[]
29
+ }
30
+
31
+ export function questionsByPlace<T extends Question>(questions: Iterable<T>, t: ClassroomMessages, numbers?: SectionNumbers): QuestionGroup<T>[] {
32
+ const groups = new Map<string, QuestionGroup<T> & { index: number }>()
33
+ for (const q of [...questions].sort((a, b) => a.at - b.at)) {
34
+ const key = `${q.section}\n${q.block ?? ''}`
35
+ let group = groups.get(key)
36
+ if (!group) groups.set(key, group = { label: placeText(q, t, numbers), section: q.section, block: q.block, index: placeIndex(q, numbers), questions: [] })
37
+ group.questions.push(q)
38
+ }
39
+ return [...groups.values()]
40
+ .sort((a, b) => a.index - b.index || (a.block ?? '').localeCompare(b.block ?? ''))
41
+ .map(({ index: _index, ...group }) => group)
42
+ }
@@ -0,0 +1,63 @@
1
+ import type { SessionStatus } from '@lincyaw/kernel'
2
+ import type { Place } from './place.ts'
3
+ import { latestPerParticipant } from './tally.ts'
4
+
5
+ export interface QuizState extends Place {
6
+ open: boolean
7
+ options: string[]
8
+ correct: number[]
9
+ openedAt?: number
10
+ closedAt?: number
11
+ final?: Answer[]
12
+ }
13
+
14
+ export interface Answer {
15
+ quizId: string
16
+ participant: string
17
+ name: string
18
+ choice: number[]
19
+ at: number
20
+ }
21
+
22
+ export interface QuizTally {
23
+ counts: number[]
24
+ respondents: number
25
+ correct: number
26
+ }
27
+
28
+ export function answerId(quizId: string, participant: string): string {
29
+ return `${quizId}:${participant}`
30
+ }
31
+
32
+ export function isCorrect(choice: readonly number[], correct: readonly number[]): boolean {
33
+ const chosen = new Set(choice)
34
+ return chosen.size === correct.length && correct.every(i => chosen.has(i))
35
+ }
36
+
37
+ export function countedAnswers(quizId: string, state: QuizState | undefined, answers: Iterable<Answer>): Answer[] {
38
+ if (state?.open === false && state.final) return state.final
39
+ return latestPerParticipant([...answers].filter(a => a.quizId === quizId))
40
+ }
41
+
42
+ export function lateAnswers(quizId: string, state: QuizState | undefined, answers: Iterable<Answer>): Answer[] {
43
+ if (state?.open !== false || !state.final) return []
44
+ const counted = new Map(state.final.map(a => [a.participant, a.at]))
45
+ return latestPerParticipant([...answers].filter(a => a.quizId === quizId)).filter(a => counted.get(a.participant) !== a.at)
46
+ }
47
+
48
+ export function quizTally(options: number, correct: readonly number[], counted: readonly Answer[]): QuizTally {
49
+ const counts = Array.from({ length: options }, (_, i) => counted.filter(a => a.choice.includes(i)).length)
50
+ return { counts, respondents: counted.length, correct: counted.filter(a => isCorrect(a.choice, correct)).length }
51
+ }
52
+
53
+ export function quizSwitched(quiz: Omit<QuizState, 'open' | 'closedAt' | 'final'>, open: boolean, counted: readonly Answer[], now: number): QuizState {
54
+ return open ? { ...quiz, open, openedAt: now } : { ...quiz, open, closedAt: now, final: [...counted] }
55
+ }
56
+
57
+ export function acceptsAnswers(status: SessionStatus | undefined, state: QuizState | undefined): boolean {
58
+ return status === 'active' && state?.open === true
59
+ }
60
+
61
+ export function optionLetter(i: number): string {
62
+ return String.fromCharCode(65 + i)
63
+ }
@@ -0,0 +1,97 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+ import type { Submission } from './exercise.ts'
3
+ import type { Note } from './notes.ts'
4
+ import type { Prediction, SavedPrediction } from './predict.ts'
5
+ import type { PulseVote } from './pulse.ts'
6
+ import type { Question } from './questions.ts'
7
+ import type { Answer, QuizState } from './quiz.ts'
8
+
9
+ const isString = (value: unknown): value is string => typeof value === 'string'
10
+ const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
11
+ const isCount = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0
12
+ const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString)
13
+ const isCounts = (value: unknown): value is number[] => Array.isArray(value) && value.every(isCount)
14
+
15
+ function isPlace(value: Record<string, unknown>): boolean {
16
+ return isString(value.section) && isString(value.block) && isString(value.title) && isCount(value.index)
17
+ }
18
+
19
+ /**
20
+ * A record with the fields that earlier versions wrote under another name renamed: a classroom
21
+ * whose windows run different versions, or an archive, holds both.
22
+ */
23
+ function renamed(value: Record<string, unknown>, before: string, now: string): Record<string, unknown> {
24
+ if (!(before in value)) return value
25
+ const { [before]: old, ...rest } = value
26
+ return now in rest ? rest : { ...rest, [now]: old }
27
+ }
28
+
29
+ export function toAnswer(value: unknown): Answer | undefined {
30
+ if (!isRecord(value)) return undefined
31
+ const answer = renamed(value, 'quiz', 'quizId')
32
+ return isString(answer.quizId) && isString(answer.participant) && isString(answer.name) && isCounts(answer.choice) && isTime(answer.at)
33
+ ? answer as unknown as Answer
34
+ : undefined
35
+ }
36
+
37
+ export function toQuizState(value: unknown): QuizState | undefined {
38
+ if (!isRecord(value)) return undefined
39
+ const state = renamed(value, 'answer', 'correct')
40
+ if (!(isPlace(state) && typeof state.open === 'boolean' && isStrings(state.options) && isCounts(state.correct))) return undefined
41
+ if (state.final === undefined) return state as unknown as QuizState
42
+ const final = Array.isArray(state.final) ? state.final.map(toAnswer) : undefined
43
+ return final?.every(answer => answer !== undefined) ? { ...state, final } as unknown as QuizState : undefined
44
+ }
45
+
46
+ export function toPulseVote(value: unknown): PulseVote | undefined {
47
+ if (!isRecord(value)) return undefined
48
+ const vote = renamed(value, 'pulse', 'pulseId')
49
+ return isPlace(vote) && isString(vote.pulseId) && isString(vote.participant) && isCount(vote.level) && isStrings(vote.levels) && isTime(vote.at)
50
+ ? vote as unknown as PulseVote
51
+ : undefined
52
+ }
53
+
54
+ export function toSubmission(value: unknown): Submission | undefined {
55
+ if (!isRecord(value)) return undefined
56
+ const submission = renamed(value, 'exercise', 'exerciseId')
57
+ return isPlace(submission) && isString(submission.exerciseId) && isString(submission.participant) && isString(submission.name)
58
+ && ['passed', 'failed', 'error'].includes(submission.status as string) && isCount(submission.passed) && isCount(submission.total)
59
+ && isStrings(submission.kinds) && isTime(submission.at)
60
+ ? submission as unknown as Submission
61
+ : undefined
62
+ }
63
+
64
+ export function toQuestion(value: unknown): Question | undefined {
65
+ if (!isRecord(value)) return undefined
66
+ const question = renamed(value, 'author', 'participant')
67
+ const { reply } = question
68
+ return isString(question.section) && (question.block === undefined || isString(question.block)) && isString(question.title)
69
+ && isCount(question.index) && isString(question.text) && isString(question.participant) && isString(question.name) && isTime(question.at)
70
+ && (question.answered === undefined || typeof question.answered === 'boolean')
71
+ && (reply === undefined || (isRecord(reply) && isString(reply.text) && isTime(reply.at) && (reply.provider === undefined || isString(reply.provider))))
72
+ ? question as unknown as Question
73
+ : undefined
74
+ }
75
+
76
+ export function toNote(value: unknown): Note | undefined {
77
+ return isRecord(value) && isString(value.section) && (value.block === undefined || isString(value.block)) && isString(value.text) && isTime(value.at)
78
+ ? value as unknown as Note
79
+ : undefined
80
+ }
81
+
82
+ function isSavedPrediction(value: Record<string, unknown>): boolean {
83
+ return (isCount(value.choice) || isString(value.text)) && isTime(value.at)
84
+ }
85
+
86
+ export function toSavedPrediction(value: unknown): SavedPrediction | undefined {
87
+ return isRecord(value) && isSavedPrediction(value) ? value as unknown as SavedPrediction : undefined
88
+ }
89
+
90
+ export function toPrediction(value: unknown): Prediction | undefined {
91
+ if (!isRecord(value)) return undefined
92
+ const prediction = renamed(value, 'predict', 'predictId')
93
+ return isPlace(prediction) && isString(prediction.predictId) && isString(prediction.participant) && isString(prediction.question)
94
+ && (prediction.choices === undefined || isStrings(prediction.choices)) && isSavedPrediction(prediction)
95
+ ? prediction as unknown as Prediction
96
+ : undefined
97
+ }
@@ -0,0 +1,55 @@
1
+ import type { MdastNode } from '@lincyaw/kernel'
2
+
3
+ export interface QuizSource {
4
+ prompt: MdastNode[]
5
+ options: string[]
6
+ correct: number[]
7
+ }
8
+
9
+ function plainText(node: MdastNode): string {
10
+ return typeof node.value === 'string' ? node.value : (node.children ?? []).map(plainText).join('')
11
+ }
12
+
13
+ export function quizSource(body: MdastNode): QuizSource {
14
+ const prompt: MdastNode[] = []
15
+ const options: string[] = []
16
+ const correct: number[] = []
17
+ for (const node of body.children ?? []) {
18
+ const items = node.type === 'list' ? (node.children ?? []).filter(item => typeof item.checked === 'boolean') : []
19
+ if (items.length === 0) {
20
+ prompt.push(node)
21
+ continue
22
+ }
23
+ for (const item of items) {
24
+ if (item.checked) correct.push(options.length)
25
+ options.push(plainText(item).trim())
26
+ }
27
+ }
28
+ return { prompt, options, correct }
29
+ }
30
+
31
+ export interface CodeSource {
32
+ lang: string
33
+ value: string
34
+ }
35
+
36
+ export interface ExerciseSource {
37
+ prompt: MdastNode[]
38
+ code: CodeSource[]
39
+ }
40
+
41
+ export function exerciseSource(body: MdastNode): ExerciseSource {
42
+ const prompt: MdastNode[] = []
43
+ const code: CodeSource[] = []
44
+ for (const node of body.children ?? []) {
45
+ if (node.type === 'code') code.push({ lang: typeof node.lang === 'string' ? node.lang : '', value: String(node.value ?? '') })
46
+ else prompt.push(node)
47
+ }
48
+ return { prompt, code }
49
+ }
50
+
51
+ export function optionList(value: unknown): string[] | undefined {
52
+ const items = Array.isArray(value) ? value.map(String) : typeof value === 'string' ? value.split(/[,,]/) : []
53
+ const options = items.map(item => item.trim()).filter(Boolean)
54
+ return options.length >= 2 ? options : undefined
55
+ }