@lincyaw/addon-classroom 0.0.0-stage → 1.0.0-rc.2
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/LICENSE +202 -0
- package/README.md +30 -2
- package/components/PmExercise.css +58 -0
- package/components/PmExercise.tsx +165 -0
- package/components/PmPredict.css +31 -0
- package/components/PmPredict.tsx +182 -0
- package/components/PmPulse.css +24 -0
- package/components/PmPulse.tsx +109 -0
- package/components/PmQuestionBox.css +16 -0
- package/components/PmQuestionBox.tsx +155 -0
- package/components/PmQuiz.css +43 -0
- package/components/PmQuiz.tsx +221 -0
- package/package.json +44 -3
- package/src/client/ClassroomOverlays.tsx +15 -0
- package/src/client/ClassroomPanel.css +32 -0
- package/src/client/ClassroomPanel.tsx +213 -0
- package/src/client/ClassroomToolbar.css +26 -0
- package/src/client/ClassroomToolbar.tsx +47 -0
- package/src/client/NoteSpot.css +50 -0
- package/src/client/NoteSpot.tsx +119 -0
- package/src/client/NotesLayer.css +9 -0
- package/src/client/NotesLayer.tsx +113 -0
- package/src/client/QuestionItem.css +17 -0
- package/src/client/QuestionItem.tsx +106 -0
- package/src/client/ai-answers.ts +29 -0
- package/src/client/browser-store.ts +86 -0
- package/src/client/composables.ts +43 -0
- package/src/client/env.d.ts +7 -0
- package/src/client/ids.ts +3 -0
- package/src/client/index.ts +4 -0
- package/src/client/plugin.ts +57 -0
- package/src/client/style.css +44 -0
- package/src/domain/config.ts +25 -0
- package/src/domain/exercise.ts +143 -0
- package/src/domain/index.ts +40 -0
- package/src/domain/messages.ts +298 -0
- package/src/domain/notes.ts +12 -0
- package/src/domain/place.ts +25 -0
- package/src/domain/predict.ts +34 -0
- package/src/domain/pulse.ts +19 -0
- package/src/domain/questions.ts +42 -0
- package/src/domain/quiz.ts +63 -0
- package/src/domain/records.ts +97 -0
- package/src/domain/source.ts +55 -0
- package/src/domain/summary.ts +96 -0
- package/src/domain/tally.ts +8 -0
- package/src/node/classroom-pass.ts +84 -0
- package/src/node/components.ts +10 -0
- package/src/node/directives.ts +77 -0
- package/src/node/fallbacks.ts +86 -0
- package/src/node/notebook.ts +32 -0
- package/src/node/presmium.ts +21 -0
- package/src/node/reveals.ts +29 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useMessages } from '@lincyaw/host'
|
|
4
|
+
import { MESSAGES } from '../domain/index.ts'
|
|
5
|
+
import type { Question } from '../domain/index.ts'
|
|
6
|
+
import type { AiAnswers } from './ai-answers.ts'
|
|
7
|
+
import './style.css'
|
|
8
|
+
import './QuestionItem.css'
|
|
9
|
+
export default function QuestionItem(
|
|
10
|
+
props: {
|
|
11
|
+
question: Question & { id: string }
|
|
12
|
+
active: boolean
|
|
13
|
+
ai: AiAnswers
|
|
14
|
+
} & {
|
|
15
|
+
children?: ReactNode
|
|
16
|
+
className?: string
|
|
17
|
+
onDone?(): void
|
|
18
|
+
onCited?(): void
|
|
19
|
+
onToggleAnswered?(): void
|
|
20
|
+
onSend?(): void
|
|
21
|
+
},
|
|
22
|
+
) {
|
|
23
|
+
const { question, active, ai } = props
|
|
24
|
+
|
|
25
|
+
const t = useMessages(MESSAGES)
|
|
26
|
+
const draft = props.ai.drafts.get(props.question.id)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<li className="pm-classroom-question" data-answered={question.answered ? '' : undefined}>
|
|
31
|
+
<strong>{question.name}</strong>
|
|
32
|
+
{t.colon}
|
|
33
|
+
{question.text}
|
|
34
|
+
{active ? (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className="pm-ui-button pm-ui-button--small"
|
|
38
|
+
onClick={(event) => {
|
|
39
|
+
props.onToggleAnswered?.()
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{question.answered ? t.questions.markUnanswered : t.questions.markAnswered}
|
|
43
|
+
</button>
|
|
44
|
+
) : null}
|
|
45
|
+
{active && ai.provider && !question.reply && !draft ? (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="pm-classroom-panel__ai pm-ui-button pm-ui-button--small"
|
|
49
|
+
title={t.questions.aiTitle(ai.provider)}
|
|
50
|
+
onClick={(event) => {
|
|
51
|
+
ai.draftReply(question.id, question)
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{t.questions.ai(ai.provider)}
|
|
55
|
+
</button>
|
|
56
|
+
) : null}
|
|
57
|
+
{draft ? (
|
|
58
|
+
<div className="pm-classroom-panel__draft" data-state={draft.state}>
|
|
59
|
+
{draft.state === 'running' ? (
|
|
60
|
+
<p className="pm-classroom-status" role="status">
|
|
61
|
+
{t.questions.aiAnswering}
|
|
62
|
+
</p>
|
|
63
|
+
) : draft.state === 'ready' ? (
|
|
64
|
+
<>
|
|
65
|
+
<p className="pm-classroom-status">{t.questions.aiDraft}</p>
|
|
66
|
+
<p className="pm-classroom-panel__answer">{draft.text}</p>
|
|
67
|
+
</>
|
|
68
|
+
) : (
|
|
69
|
+
<p className="pm-classroom-error" role="alert">
|
|
70
|
+
{t.questions.aiFailed(draft.error ?? '')}
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
<p className="pm-classroom-actions">
|
|
74
|
+
{draft.state === 'ready' ? (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
|
|
78
|
+
onClick={(event) => {
|
|
79
|
+
props.onSend?.()
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{t.questions.send}
|
|
83
|
+
</button>
|
|
84
|
+
) : null}
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className="pm-ui-button pm-ui-button--small"
|
|
88
|
+
onClick={(event) => {
|
|
89
|
+
ai.discard(question.id)
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{t.questions.discard}
|
|
93
|
+
</button>
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
{question.reply ? (
|
|
98
|
+
<p className="pm-classroom-reply">
|
|
99
|
+
{question.reply.provider ? t.questions.reply(question.reply.provider) : ''}
|
|
100
|
+
{question.reply.text}
|
|
101
|
+
</p>
|
|
102
|
+
) : null}
|
|
103
|
+
</li>
|
|
104
|
+
</>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useService } from '@lincyaw/host'
|
|
2
|
+
import { ASK, errorMessage } from '@lincyaw/kernel'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import type { Question } from '../domain/index.ts'
|
|
5
|
+
export interface Draft { state: 'running' | 'ready' | 'failed', text: string, error?: string, provider?: string, stop: AbortController }
|
|
6
|
+
const plainReply = (text: string) => text.replace(/\[([^\]\n]+)\]\(presmium:\/\/[^)\s]+\)/g, '$1').trim()
|
|
7
|
+
export function useAiAnswers() {
|
|
8
|
+
const ask = useService(ASK)
|
|
9
|
+
const [drafts, setDrafts] = useState(new Map<string, Draft>())
|
|
10
|
+
const current = useRef(drafts); current.current = drafts
|
|
11
|
+
const change = (id: string, draft?: Draft) => setDrafts(before => { const next = new Map(before); if (draft) next.set(id, draft); else next.delete(id); return next })
|
|
12
|
+
useEffect(() => () => current.current.forEach(d => d.stop.abort()), [])
|
|
13
|
+
function draftReply(id: string, question: Question): void {
|
|
14
|
+
if (!ask || current.current.has(id)) return
|
|
15
|
+
const stop = new AbortController()
|
|
16
|
+
const draft: Draft = { state: 'running', text: '', provider: ask.provider(), stop }
|
|
17
|
+
change(id, draft)
|
|
18
|
+
void (async () => {
|
|
19
|
+
let text = ''
|
|
20
|
+
try {
|
|
21
|
+
for await (const piece of ask.ask(question.text, { document: '', section: question.section, ...question.block ? { block: question.block } : {} }, stop.signal)) text += piece
|
|
22
|
+
if (!stop.signal.aborted) change(id, { ...draft, text: plainReply(text), state: text.trim() ? 'ready' : 'failed' })
|
|
23
|
+
} catch (error) { if (!stop.signal.aborted) change(id, { ...draft, state: 'failed', error: errorMessage(error) }) }
|
|
24
|
+
})()
|
|
25
|
+
}
|
|
26
|
+
function discard(id: string) { current.current.get(id)?.stop.abort(); change(id) }
|
|
27
|
+
return { offered: Boolean(ask), provider: ask?.provider(), drafts, draftReply, discard }
|
|
28
|
+
}
|
|
29
|
+
export type AiAnswers = ReturnType<typeof useAiAnswers>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { siteBase } from '@lincyaw/host'
|
|
2
|
+
import type { ClassroomStore } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
type Data = Record<string, Record<string, unknown>>
|
|
5
|
+
|
|
6
|
+
function localStorageOrNone(): Storage | undefined {
|
|
7
|
+
try {
|
|
8
|
+
return window.localStorage
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createBrowserStore(key: string, storage = localStorageOrNone()): ClassroomStore {
|
|
15
|
+
const listeners = new Map<string, Set<() => void>>()
|
|
16
|
+
const notify = (name?: string) => (name ? [listeners.get(name)] : [...listeners.values()]).forEach(set => set?.forEach(listener => listener()))
|
|
17
|
+
const read = (): Data => {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(storage?.getItem(key) ?? '{}') as Data ?? {}
|
|
20
|
+
} catch {
|
|
21
|
+
return {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const change = (name: string, apply: (items: Record<string, unknown>) => void) => {
|
|
25
|
+
const data = read()
|
|
26
|
+
const items = data[name] ?? {}
|
|
27
|
+
apply(items)
|
|
28
|
+
data[name] = items
|
|
29
|
+
try {
|
|
30
|
+
storage?.setItem(key, JSON.stringify(data))
|
|
31
|
+
} catch {}
|
|
32
|
+
notify(name)
|
|
33
|
+
}
|
|
34
|
+
window.addEventListener('storage', (event) => {
|
|
35
|
+
if (event.key === key) notify()
|
|
36
|
+
})
|
|
37
|
+
return {
|
|
38
|
+
list: <T>(name: string) => Object.entries(read()[name] ?? {}).map(([id, value]) => ({ id, value: value as T })),
|
|
39
|
+
get: <T>(name: string, id: string) => read()[name]?.[id] as T | undefined,
|
|
40
|
+
put(name, id, value) {
|
|
41
|
+
change(name, items => items[id] = JSON.parse(JSON.stringify(value)))
|
|
42
|
+
},
|
|
43
|
+
remove(name, id) {
|
|
44
|
+
change(name, items => delete items[id])
|
|
45
|
+
},
|
|
46
|
+
subscribe(name, listener) {
|
|
47
|
+
let set = listeners.get(name)
|
|
48
|
+
if (!set) listeners.set(name, set = new Set())
|
|
49
|
+
set.add(listener)
|
|
50
|
+
return () => {
|
|
51
|
+
set.delete(listener)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Writes to both stores; reads the first, then the second. */
|
|
58
|
+
export function layeredStore(first: ClassroomStore, second: ClassroomStore): ClassroomStore {
|
|
59
|
+
return {
|
|
60
|
+
list<T>(name: string) {
|
|
61
|
+
const items = new Map(second.list<T>(name).map(item => [item.id, item]))
|
|
62
|
+
for (const item of first.list<T>(name)) items.set(item.id, item)
|
|
63
|
+
return [...items.values()]
|
|
64
|
+
},
|
|
65
|
+
get: <T>(name: string, id: string) => first.get<T>(name, id) ?? second.get<T>(name, id),
|
|
66
|
+
put(name, id, value) {
|
|
67
|
+
second.put(name, id, value)
|
|
68
|
+
first.put(name, id, value)
|
|
69
|
+
},
|
|
70
|
+
remove(name, id) {
|
|
71
|
+
second.remove(name, id)
|
|
72
|
+
first.remove(name, id)
|
|
73
|
+
},
|
|
74
|
+
subscribe(name, listener) {
|
|
75
|
+
const stops = [first.subscribe(name, listener), second.subscribe(name, listener)]
|
|
76
|
+
return () => stops.forEach(stop => stop())
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let kept: ClassroomStore | undefined
|
|
82
|
+
|
|
83
|
+
export function browserStore(): ClassroomStore {
|
|
84
|
+
kept ??= createBrowserStore(`presmium.classroom:${new URL(siteBase(), location.href).pathname}`)
|
|
85
|
+
return kept
|
|
86
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useService, useSessionSnapshot } from '@lincyaw/host'
|
|
2
|
+
import { CLASSROOM_STORE, errorMessage } from '@lincyaw/kernel'
|
|
3
|
+
import type { ClassroomStore, StoredItem } from '@lincyaw/kernel'
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
5
|
+
import { browserStore, layeredStore } from './browser-store.ts'
|
|
6
|
+
|
|
7
|
+
export function useParticipation() {
|
|
8
|
+
const { session, snapshot } = useSessionSnapshot()
|
|
9
|
+
const me = snapshot?.me
|
|
10
|
+
const status = snapshot?.status ?? 'idle'
|
|
11
|
+
const joined = status !== 'idle' && me !== undefined
|
|
12
|
+
return { session, snapshot, me, status, joined, presenter: joined && me?.role === 'presenter' }
|
|
13
|
+
}
|
|
14
|
+
export function useClassroomStore(): ClassroomStore | undefined { return useService(CLASSROOM_STORE) }
|
|
15
|
+
|
|
16
|
+
/** Subscription snapshots are cached in state; a collection may return a fresh array on every list(). */
|
|
17
|
+
export function useCollection<T>(store: () => ClassroomStore | undefined, name: string, parse: (value: unknown) => T | undefined): StoredItem<T>[] {
|
|
18
|
+
const current = store()
|
|
19
|
+
const [items, setItems] = useState<StoredItem<T>[]>([])
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const update = () => setItems((current?.list<unknown>(name) ?? []).flatMap(({ id, value }) => {
|
|
22
|
+
const record = parse(value)
|
|
23
|
+
return record === undefined ? [] : [{ id, value: record }]
|
|
24
|
+
}))
|
|
25
|
+
update()
|
|
26
|
+
return current?.subscribe(name, update)
|
|
27
|
+
}, [current, name, parse])
|
|
28
|
+
return items
|
|
29
|
+
}
|
|
30
|
+
export function useNoteStore() {
|
|
31
|
+
const { session, status, joined } = useParticipation()
|
|
32
|
+
const inClassroom = joined && status === 'active' && session?.personal !== undefined
|
|
33
|
+
const store = useMemo(() => inClassroom ? layeredStore(session!.personal!, browserStore()) : browserStore(), [inClassroom, session])
|
|
34
|
+
return { store, inClassroom }
|
|
35
|
+
}
|
|
36
|
+
export function useGuardedWrite() {
|
|
37
|
+
const [failure, setFailure] = useState('')
|
|
38
|
+
function write(action: () => void): boolean {
|
|
39
|
+
setFailure('')
|
|
40
|
+
try { action(); return true } catch (error) { setFailure(errorMessage(error)); return false }
|
|
41
|
+
}
|
|
42
|
+
return { failure, write }
|
|
43
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { browserStore, createBrowserStore, layeredStore } from './browser-store.ts'
|
|
2
|
+
export { useClassroomStore, useCollection, useGuardedWrite, useNoteStore, useParticipation } from './composables.ts'
|
|
3
|
+
export { newId } from './ids.ts'
|
|
4
|
+
export { classroomPlugin } from './plugin.ts'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { currentLocale, STATUS, TOOLBAR } from '@lincyaw/host'
|
|
2
|
+
import { CLASSROOM_STORE, EXECUTOR, PRESENTER_PANELS, SESSION } from '@lincyaw/kernel'
|
|
3
|
+
import type { ClassroomStore, PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
|
|
4
|
+
import { version } from '../../package.json'
|
|
5
|
+
import { CLASSROOM_ID, classroomSummary, COLLECTIONS, MESSAGES, toAnswer, toPrediction, toPulseVote, toQuestion, toQuizState, toSectionNumbers, toSubmission } from '../domain/index.ts'
|
|
6
|
+
import type { ClassroomSummary } from '../domain/index.ts'
|
|
7
|
+
import ClassroomPanel from './ClassroomPanel.tsx'
|
|
8
|
+
import ClassroomToolbar from './ClassroomToolbar.tsx'
|
|
9
|
+
|
|
10
|
+
let cleanup: Unsubscribe[] = []
|
|
11
|
+
|
|
12
|
+
function records<T>(store: ClassroomStore | undefined, name: string, parse: (value: unknown) => T | undefined): (T & { id: string })[] {
|
|
13
|
+
return (store?.list<unknown>(name) ?? []).flatMap(({ id, value }) => {
|
|
14
|
+
const record = parse(value)
|
|
15
|
+
return record === undefined ? [] : [{ ...record, id }]
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const classroomPlugin: PluginDefinition = {
|
|
20
|
+
manifest: {
|
|
21
|
+
id: CLASSROOM_ID,
|
|
22
|
+
name: { 'zh-CN': '课堂', en: 'Classroom' },
|
|
23
|
+
version,
|
|
24
|
+
kernel: '^1.0.0',
|
|
25
|
+
requires: [SESSION, CLASSROOM_STORE, EXECUTOR, TOOLBAR, PRESENTER_PANELS, STATUS].map(token => ({ token, optional: true })),
|
|
26
|
+
},
|
|
27
|
+
setup(ctx) {
|
|
28
|
+
const title = MESSAGES[currentLocale()].interaction
|
|
29
|
+
const summary = (): ClassroomSummary => {
|
|
30
|
+
const store = ctx.tryGet(CLASSROOM_STORE)
|
|
31
|
+
const snapshot = ctx.tryGet(SESSION)?.snapshot()
|
|
32
|
+
return classroomSummary({
|
|
33
|
+
status: snapshot?.status ?? 'idle',
|
|
34
|
+
participants: snapshot?.participants ?? [],
|
|
35
|
+
questions: records(store, COLLECTIONS.questions, toQuestion),
|
|
36
|
+
quizzes: records(store, COLLECTIONS.quizzes, toQuizState),
|
|
37
|
+
answers: records(store, COLLECTIONS.answers, toAnswer),
|
|
38
|
+
predictions: records(store, COLLECTIONS.predictions, toPrediction),
|
|
39
|
+
votes: records(store, COLLECTIONS.pulse, toPulseVote),
|
|
40
|
+
submissions: records(store, COLLECTIONS.submissions, toSubmission),
|
|
41
|
+
numbers: toSectionNumbers(ctx.config),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
cleanup = [
|
|
45
|
+
// The toolbar button also mounts the notes beside the sections, once per page (ClassroomOverlays).
|
|
46
|
+
ctx.tryGet(TOOLBAR)?.register({ id: CLASSROOM_ID, title, component: ClassroomToolbar, order: 20 }),
|
|
47
|
+
ctx.tryGet(PRESENTER_PANELS)?.register({ id: CLASSROOM_ID, title, component: ClassroomPanel, order: 20 }),
|
|
48
|
+
ctx.tryGet(STATUS)?.register({ id: CLASSROOM_ID, read: summary }),
|
|
49
|
+
].filter(stop => stop !== undefined)
|
|
50
|
+
},
|
|
51
|
+
dispose() {
|
|
52
|
+
cleanup.forEach(stop => stop())
|
|
53
|
+
cleanup = []
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default classroomPlugin
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
.pm-classroom-card {
|
|
2
|
+
--pm-text-xs: 0.75em;
|
|
3
|
+
--pm-text-sm: 0.875em;
|
|
4
|
+
--pm-text-md: 0.9375em;
|
|
5
|
+
|
|
6
|
+
box-sizing: border-box;
|
|
7
|
+
margin: 0.75em 0;
|
|
8
|
+
padding: 0.75em 1em;
|
|
9
|
+
border: var(--pm-size-hairline) solid var(--pm-color-line);
|
|
10
|
+
border-radius: var(--pm-radius-card);
|
|
11
|
+
background: var(--pm-color-surface);
|
|
12
|
+
color: var(--pm-color-ink);
|
|
13
|
+
font-family: var(--pm-font-sans);
|
|
14
|
+
font-size: 0.9em;
|
|
15
|
+
line-height: 1.5;
|
|
16
|
+
text-align: left;
|
|
17
|
+
}
|
|
18
|
+
.pm-classroom-card p {
|
|
19
|
+
margin: 0.35em 0;
|
|
20
|
+
}
|
|
21
|
+
.pm-classroom-actions {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-wrap: wrap;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 0.5em;
|
|
26
|
+
}
|
|
27
|
+
.pm-classroom-card .pm-ui-textarea,
|
|
28
|
+
.pm-note-spot .pm-ui-textarea {
|
|
29
|
+
width: 100%;
|
|
30
|
+
}
|
|
31
|
+
.pm-classroom-status {
|
|
32
|
+
color: var(--pm-color-muted);
|
|
33
|
+
}
|
|
34
|
+
.pm-classroom-error {
|
|
35
|
+
color: var(--pm-color-danger);
|
|
36
|
+
}
|
|
37
|
+
.pm-classroom-reply {
|
|
38
|
+
margin: 0.35em 0;
|
|
39
|
+
padding: 0.35em 0.75em;
|
|
40
|
+
border-left: var(--pm-size-rule) solid var(--pm-color-accent);
|
|
41
|
+
border-radius: 0 var(--pm-radius-small) var(--pm-radius-small) 0;
|
|
42
|
+
background: var(--pm-color-accent-soft);
|
|
43
|
+
white-space: pre-wrap;
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const CLASSROOM_ID = 'presmium.classroom'
|
|
2
|
+
|
|
3
|
+
export const COLLECTIONS = {
|
|
4
|
+
quizzes: 'quizzes',
|
|
5
|
+
answers: 'answers',
|
|
6
|
+
submissions: 'submissions',
|
|
7
|
+
questions: 'questions',
|
|
8
|
+
pulse: 'pulse',
|
|
9
|
+
predictions: 'predictions',
|
|
10
|
+
notes: 'notes',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
const STUDENT_AUDIENCE = 'student'
|
|
14
|
+
|
|
15
|
+
export function isForStudents(section: { audience?: readonly string[] }): boolean {
|
|
16
|
+
return !section.audience?.length || section.audience.includes(STUDENT_AUDIENCE)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SectionNumbers = Readonly<Record<string, number>>
|
|
20
|
+
|
|
21
|
+
export function toSectionNumbers(config: Record<string, unknown> | undefined): SectionNumbers {
|
|
22
|
+
const sections = config?.sections
|
|
23
|
+
if (typeof sections !== 'object' || sections === null || Array.isArray(sections)) return {}
|
|
24
|
+
return Object.fromEntries(Object.entries(sections).filter((entry): entry is [string, number] => Number.isInteger(entry[1])))
|
|
25
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { RunRequest, RunResult } from '@lincyaw/kernel'
|
|
2
|
+
import type { ClassroomMessages } from './messages.ts'
|
|
3
|
+
import type { Place } from './place.ts'
|
|
4
|
+
|
|
5
|
+
const MARKER = '@@presmium-check@@'
|
|
6
|
+
const SUBMISSION = 'submission.py'
|
|
7
|
+
const CHECKS = 'checks.py'
|
|
8
|
+
|
|
9
|
+
const CHECK_PROGRAM = `import ast as _pm_ast, json as _pm_json
|
|
10
|
+
|
|
11
|
+
def _pm_describe(error):
|
|
12
|
+
line = error.lineno if isinstance(error, SyntaxError) and error.filename == ${JSON.stringify(SUBMISSION)} else None
|
|
13
|
+
tb = error.__traceback__
|
|
14
|
+
while tb is not None:
|
|
15
|
+
if tb.tb_frame.f_code.co_filename == ${JSON.stringify(SUBMISSION)}:
|
|
16
|
+
line = tb.tb_lineno
|
|
17
|
+
tb = tb.tb_next
|
|
18
|
+
return {'type': type(error).__name__, 'message': str(error), 'line': line}
|
|
19
|
+
|
|
20
|
+
def _pm_check():
|
|
21
|
+
with open(${JSON.stringify(SUBMISSION)}, encoding='utf-8') as f:
|
|
22
|
+
submission = f.read()
|
|
23
|
+
with open(${JSON.stringify(CHECKS)}, encoding='utf-8') as f:
|
|
24
|
+
checks = f.read()
|
|
25
|
+
scope = {'__name__': '__main__'}
|
|
26
|
+
try:
|
|
27
|
+
exec(compile(submission, ${JSON.stringify(SUBMISSION)}, 'exec'), scope)
|
|
28
|
+
except BaseException as error:
|
|
29
|
+
return {'error': _pm_describe(error), 'cases': []}
|
|
30
|
+
cases = []
|
|
31
|
+
for node in _pm_ast.parse(checks).body:
|
|
32
|
+
case = {'line': node.lineno, 'code': _pm_ast.get_source_segment(checks, node) or '', 'ok': True}
|
|
33
|
+
try:
|
|
34
|
+
exec(compile(_pm_ast.Module(body=[node], type_ignores=[]), ${JSON.stringify(CHECKS)}, 'exec'), scope)
|
|
35
|
+
except BaseException as error:
|
|
36
|
+
case['ok'] = False
|
|
37
|
+
case['error'] = _pm_describe(error)
|
|
38
|
+
cases.append(case)
|
|
39
|
+
return {'error': None, 'cases': cases}
|
|
40
|
+
|
|
41
|
+
print('\\n${MARKER}' + _pm_json.dumps(_pm_check(), ensure_ascii=False))
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
export function checkRequest(code: string, checks: string, timeoutMs?: number): RunRequest {
|
|
45
|
+
return { lang: 'python', code: CHECK_PROGRAM, files: { [SUBMISSION]: code, [CHECKS]: checks }, ...timeoutMs ? { timeoutMs } : {} }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CheckError {
|
|
49
|
+
type: string
|
|
50
|
+
message: string
|
|
51
|
+
line?: number
|
|
52
|
+
kind: string
|
|
53
|
+
hint: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CheckCase {
|
|
57
|
+
line: number
|
|
58
|
+
code: string
|
|
59
|
+
ok: boolean
|
|
60
|
+
error?: CheckError
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CheckReport {
|
|
64
|
+
status: 'passed' | 'failed' | 'error'
|
|
65
|
+
passed: number
|
|
66
|
+
total: number
|
|
67
|
+
cases: CheckCase[]
|
|
68
|
+
error?: CheckError
|
|
69
|
+
output: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function typicalError(type: string, message: string, t: ClassroomMessages, line?: number): CheckError {
|
|
73
|
+
const { kind, hint } = t.exercise.typical[type] ?? { kind: type, hint: t.exercise.unknown }
|
|
74
|
+
return { type, message, ...line ? { line } : {}, kind, hint }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RawError { type: string, message: string, line?: number | null }
|
|
78
|
+
interface RawReport {
|
|
79
|
+
error: RawError | null
|
|
80
|
+
cases: { line: number, code: string, ok: boolean, error?: RawError }[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function checkReport(result: RunResult, t: ClassroomMessages): CheckReport {
|
|
84
|
+
const checkError = (raw: RawError) => typicalError(raw.type, raw.message, t, raw.line ?? undefined)
|
|
85
|
+
const at = result.stdout.lastIndexOf(`\n${MARKER}`)
|
|
86
|
+
const output = at >= 0 ? result.stdout.slice(0, at) : result.stdout
|
|
87
|
+
const failed = (error: CheckError): CheckReport => ({ status: 'error', passed: 0, total: 0, cases: [], error, output })
|
|
88
|
+
if (result.timedOut) return failed(typicalError('Timeout', result.stderr.trim(), t))
|
|
89
|
+
let raw: RawReport | undefined
|
|
90
|
+
try {
|
|
91
|
+
if (at >= 0) raw = JSON.parse(result.stdout.slice(at + MARKER.length + 1)) as RawReport
|
|
92
|
+
} catch {}
|
|
93
|
+
if (!raw || !Array.isArray(raw.cases)) {
|
|
94
|
+
const { kind, hint } = t.exercise.unchecked
|
|
95
|
+
return failed({ type: 'Error', message: result.stderr.trim() || t.exercise.noResult, kind, hint })
|
|
96
|
+
}
|
|
97
|
+
if (raw.error) return failed(checkError(raw.error))
|
|
98
|
+
const cases = raw.cases.map(c => ({ line: c.line, code: c.code, ok: c.ok, ...c.error ? { error: checkError(c.error) } : {} }))
|
|
99
|
+
const passed = cases.filter(c => c.ok).length
|
|
100
|
+
return { status: passed === cases.length ? 'passed' : 'failed', passed, total: cases.length, cases, output }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Submission extends Place {
|
|
104
|
+
exerciseId: string
|
|
105
|
+
participant: string
|
|
106
|
+
name: string
|
|
107
|
+
status: CheckReport['status']
|
|
108
|
+
passed: number
|
|
109
|
+
total: number
|
|
110
|
+
kinds: string[]
|
|
111
|
+
at: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function submissionId(exerciseId: string, participant: string): string {
|
|
115
|
+
return `${exerciseId}:${participant}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function reportKinds(report: CheckReport): string[] {
|
|
119
|
+
const errors = report.error ? [report.error] : report.cases.flatMap(c => (c.error ? [c.error] : []))
|
|
120
|
+
return [...new Set(errors.map(e => e.kind))]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ExerciseTally {
|
|
124
|
+
submitted: number
|
|
125
|
+
passed: number
|
|
126
|
+
kinds: [kind: string, count: number][]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function exerciseTally(submissions: Submission[]): ExerciseTally {
|
|
130
|
+
const counts = new Map<string, number>()
|
|
131
|
+
for (const kind of submissions.flatMap(s => s.kinds)) counts.set(kind, (counts.get(kind) ?? 0) + 1)
|
|
132
|
+
return {
|
|
133
|
+
submitted: submissions.length,
|
|
134
|
+
passed: submissions.filter(s => s.status === 'passed').length,
|
|
135
|
+
kinds: [...counts].sort((a, b) => b[1] - a[1]),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function exerciseTallyText(tally: ExerciseTally, t: ClassroomMessages): string {
|
|
140
|
+
const { exercise, list, people } = t
|
|
141
|
+
const common = tally.kinds.map(([kind, n]) => `${kind} ${people(n)}`).join(list)
|
|
142
|
+
return exercise.tally(tally.submitted, tally.passed) + (common ? exercise.common(common) : '')
|
|
143
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export { CLASSROOM_ID, COLLECTIONS, isForStudents, toSectionNumbers } from './config.ts'
|
|
2
|
+
export type { SectionNumbers } from './config.ts'
|
|
3
|
+
export {
|
|
4
|
+
acceptsAnswers,
|
|
5
|
+
answerId,
|
|
6
|
+
countedAnswers,
|
|
7
|
+
isCorrect,
|
|
8
|
+
lateAnswers,
|
|
9
|
+
optionLetter,
|
|
10
|
+
quizSwitched,
|
|
11
|
+
quizTally,
|
|
12
|
+
} from './quiz.ts'
|
|
13
|
+
export type { Answer, QuizState, QuizTally } from './quiz.ts'
|
|
14
|
+
export {
|
|
15
|
+
checkReport,
|
|
16
|
+
checkRequest,
|
|
17
|
+
exerciseTally,
|
|
18
|
+
exerciseTallyText,
|
|
19
|
+
reportKinds,
|
|
20
|
+
submissionId,
|
|
21
|
+
} from './exercise.ts'
|
|
22
|
+
export type { CheckCase, CheckError, CheckReport, ExerciseTally, Submission } from './exercise.ts'
|
|
23
|
+
export { toAnswer, toNote, toPrediction, toPulseVote, toQuestion, toQuizState, toSavedPrediction, toSubmission } from './records.ts'
|
|
24
|
+
export { notesAt } from './notes.ts'
|
|
25
|
+
export type { Note } from './notes.ts'
|
|
26
|
+
export { isAuthorLabel, placeIndex, placeKey, placeText } from './place.ts'
|
|
27
|
+
export type { Place } from './place.ts'
|
|
28
|
+
export { MESSAGES } from './messages.ts'
|
|
29
|
+
export type { ClassroomMessages } from './messages.ts'
|
|
30
|
+
export { pulseTally, voteId } from './pulse.ts'
|
|
31
|
+
export type { PulseVote } from './pulse.ts'
|
|
32
|
+
export { predictionId, predictionTally } from './predict.ts'
|
|
33
|
+
export type { Prediction, PredictionTally, SavedPrediction } from './predict.ts'
|
|
34
|
+
export { questionsByPlace } from './questions.ts'
|
|
35
|
+
export type { Question, QuestionGroup, QuestionReply } from './questions.ts'
|
|
36
|
+
export { exerciseSource, optionList, quizSource } from './source.ts'
|
|
37
|
+
export type { CodeSource, ExerciseSource, QuizSource } from './source.ts'
|
|
38
|
+
export { classroomRows, classroomSummary, unansweredCount } from './summary.ts'
|
|
39
|
+
export type { ClassroomRecords, ClassroomRows, ClassroomSummary, SummaryInput } from './summary.ts'
|
|
40
|
+
export { latestPerParticipant } from './tally.ts'
|