@dolphy-app/extension-sdk 0.2.0 → 0.4.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.
- package/README.md +25 -8
- package/dist/answer-element-BOQcuxYh.js +114 -0
- package/dist/answer-view-D6wnyThb.d.ts +28 -0
- package/dist/index.d.ts +107 -27
- package/dist/index.js +48 -117
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +24 -0
- package/dist/testing.d.ts +330 -11
- package/dist/testing.js +722 -11
- package/docs/debugging.md +210 -0
- package/docs/no-build.md +99 -0
- package/docs/quick-start.md +172 -0
- package/docs/recipe-command-panel.md +211 -0
- package/docs/recipe-event-storage.md +266 -0
- package/docs/recipe-exercise-type.md +379 -0
- package/docs/recipe-import-export.md +241 -0
- package/docs/recipe-settings.md +161 -0
- package/docs/recipe-theme.md +116 -0
- package/docs/recipe-ui-kit.md +172 -0
- package/docs/recipe-when-dependencies.md +158 -0
- package/package.json +10 -4
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Recipe: an exercise type
|
|
2
|
+
|
|
3
|
+
A new kind of task: the course author writes `spec` (what to ask), the learner
|
|
4
|
+
types an answer, your code grades it. This recipe is the `exercise` template
|
|
5
|
+
(`npx @dolphy-app/create-extension <dir> --id acme.hello --template exercise`,
|
|
6
|
+
also the default). The files below are exactly what the generator writes for the
|
|
7
|
+
id `acme.hello`. Read [quick-start.md](quick-start.md) first for the project
|
|
8
|
+
layout and commands.
|
|
9
|
+
|
|
10
|
+
The exercise type here compares the answer with an expected text. It has three
|
|
11
|
+
parts: a declaration, the grading code (`host`) and the answer input (`views`).
|
|
12
|
+
|
|
13
|
+
## The manifest
|
|
14
|
+
|
|
15
|
+
File `extension.json` (exercise):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
20
|
+
"id": "acme.hello",
|
|
21
|
+
"version": "0.1.0",
|
|
22
|
+
"apiVersion": 1,
|
|
23
|
+
"name": "Text match",
|
|
24
|
+
"description": "Exercise type: the learner types a string that is compared with the expected text.",
|
|
25
|
+
"author": "your-github-login",
|
|
26
|
+
"tags": ["learning"],
|
|
27
|
+
"contributes": {
|
|
28
|
+
"exerciseTypes": [
|
|
29
|
+
{
|
|
30
|
+
"id": "acme.hello",
|
|
31
|
+
"specSchema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"required": ["expected"],
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"expected": { "type": "string", "minLength": 1 },
|
|
37
|
+
"ignoreCase": { "type": "boolean" }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"answerSchema": { "type": "string" }
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"settings": [
|
|
44
|
+
{
|
|
45
|
+
"id": "acme.hello.trim",
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"label": "Ignore spaces around the answer",
|
|
48
|
+
"default": true
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"commands": [
|
|
52
|
+
{ "id": "acme.hello.status", "title": "Show how answers are compared" }
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `exerciseTypes[].id` is the name a course uses in its exercises. `specSchema`
|
|
59
|
+
and `answerSchema` are JSON Schemas: the app checks `spec` and the answer
|
|
60
|
+
against them before your code runs, so `grade` can trust their shape.
|
|
61
|
+
- `settings` declares a user setting; the code below reads it.
|
|
62
|
+
- The command `acme.hello.status` shows in the palette how answers are compared.
|
|
63
|
+
- No `element` key: the tag of the answer element defaults to one derived from
|
|
64
|
+
the id (`acme.hello` → `acme-hello-answer`), and the build defines the custom
|
|
65
|
+
element for you.
|
|
66
|
+
|
|
67
|
+
## The code
|
|
68
|
+
|
|
69
|
+
File `src/index.ts` (exercise):
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import {
|
|
73
|
+
defineAnswerView,
|
|
74
|
+
defineExerciseType,
|
|
75
|
+
defineExtension,
|
|
76
|
+
inActivate,
|
|
77
|
+
notify,
|
|
78
|
+
} from '@dolphy-app/extension-sdk';
|
|
79
|
+
import type { ExtensionViews } from '@dolphy-app/extension-sdk';
|
|
80
|
+
|
|
81
|
+
interface Spec {
|
|
82
|
+
expected: string;
|
|
83
|
+
ignoreCase?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// filled from the setting in `activate`, read by the handlers below
|
|
87
|
+
const options = { trim: true };
|
|
88
|
+
|
|
89
|
+
const matches = (answer: string, spec: Spec): boolean => {
|
|
90
|
+
const given = options.trim ? answer.trim() : answer;
|
|
91
|
+
if (spec.ignoreCase === true) {
|
|
92
|
+
return given.toLowerCase() === spec.expected.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
return given === spec.expected;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// extension code: runs in the extension process of the app
|
|
98
|
+
// the schemas from extension.json have already checked `spec` and the answer
|
|
99
|
+
// before the handlers run
|
|
100
|
+
// the ids come from extension.json: `dolphy-ext types` (and every build)
|
|
101
|
+
// writes them to .dolphy/ids.d.ts, so a misspelt id, a declared id without a
|
|
102
|
+
// handler or an undeclared setting fails `pnpm typecheck`
|
|
103
|
+
export const host = defineExtension({
|
|
104
|
+
exerciseTypes: {
|
|
105
|
+
'acme.hello': defineExerciseType<Spec, string, Record<string, never>>({
|
|
106
|
+
project: () => ({}),
|
|
107
|
+
grade: ({ spec, answer }) =>
|
|
108
|
+
matches(answer, spec)
|
|
109
|
+
? { outcome: 'passed' }
|
|
110
|
+
: { outcome: 'failed', reason: 'mismatch' },
|
|
111
|
+
referenceAnswer: ({ spec }) => spec.expected,
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
// this command is registered in `activate`: the marker names the id there
|
|
115
|
+
commands: { 'acme.hello.status': inActivate },
|
|
116
|
+
activate(ctx) {
|
|
117
|
+
options.trim = ctx.settings.get('acme.hello.trim');
|
|
118
|
+
ctx.settings.onDidChange((change) => {
|
|
119
|
+
if (change.id === 'acme.hello.trim') options.trim = change.value;
|
|
120
|
+
});
|
|
121
|
+
ctx.commands.register('acme.hello.status', () =>
|
|
122
|
+
notify(
|
|
123
|
+
options.trim
|
|
124
|
+
? 'Answers are compared without the spaces around them.'
|
|
125
|
+
: 'Answers are compared exactly as typed.',
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// the answer input: runs in the app window; the build defines the custom
|
|
132
|
+
// element with the tag from extension.json
|
|
133
|
+
export const views = {
|
|
134
|
+
'acme.hello': defineAnswerView((api, initial) => {
|
|
135
|
+
const input = document.createElement('input');
|
|
136
|
+
input.type = 'text';
|
|
137
|
+
input.spellcheck = false;
|
|
138
|
+
if (api.label !== null) input.setAttribute('aria-label', api.label);
|
|
139
|
+
|
|
140
|
+
const applyValue = (value: unknown) => {
|
|
141
|
+
input.value = typeof value === 'string' ? value : '';
|
|
142
|
+
};
|
|
143
|
+
let appliedValue = initial.value;
|
|
144
|
+
applyValue(appliedValue);
|
|
145
|
+
input.disabled = initial.disabled;
|
|
146
|
+
|
|
147
|
+
input.addEventListener('input', () => {
|
|
148
|
+
api.setAnswer(input.value, input.value.trim().length > 0);
|
|
149
|
+
});
|
|
150
|
+
input.addEventListener('keydown', (event) => {
|
|
151
|
+
if (event.key === 'Enter') api.submit();
|
|
152
|
+
});
|
|
153
|
+
api.root.append(input);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
update: (props) => {
|
|
157
|
+
input.disabled = props.disabled;
|
|
158
|
+
// apply the value only when the app really changed it
|
|
159
|
+
if (props.value !== appliedValue) {
|
|
160
|
+
appliedValue = props.value;
|
|
161
|
+
applyValue(appliedValue);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}),
|
|
166
|
+
} satisfies ExtensionViews;
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- `host` runs in the extension process. `defineExerciseType<Spec, Answer, Public>`
|
|
170
|
+
takes `project` (the part of `spec` the window may see; never put the expected
|
|
171
|
+
answer there), `grade` (the verdict) and, optionally, `referenceAnswer` (what
|
|
172
|
+
"show the answer" displays).
|
|
173
|
+
- A verdict is `{ outcome: 'passed' }` or `{ outcome: 'failed', reason }`.
|
|
174
|
+
- `inActivate` marks an id that is registered in `activate`, where `ctx` exists.
|
|
175
|
+
`ctx.settings.get(id)` is synchronous and typed by the manifest (`boolean`
|
|
176
|
+
here); `onDidChange` delivers a change from Settings → Extensions to the
|
|
177
|
+
running extension without a restart.
|
|
178
|
+
- `views` runs in the app window. `defineAnswerView(mount)` gets `api.root` (a
|
|
179
|
+
shadow root), `api.setAnswer(value, complete)` and `api.submit()`, and returns
|
|
180
|
+
`update(props)` for new `value` and `disabled`. The keys of `views` must be
|
|
181
|
+
exactly the declared exercise types; `satisfies ExtensionViews` makes the
|
|
182
|
+
compiler check it.
|
|
183
|
+
- `src/index.ts` has no side effects on import, so tests import it in plain
|
|
184
|
+
Node. The build splits it: `host` goes to `main.mjs`, `views` to `view.mjs`.
|
|
185
|
+
|
|
186
|
+
## The tests
|
|
187
|
+
|
|
188
|
+
File `test/index.test.ts` (exercise):
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// @vitest-environment happy-dom
|
|
192
|
+
import type { SettingContribution } from '@dolphy-app/extension-sdk';
|
|
193
|
+
import {
|
|
194
|
+
createMemorySettings,
|
|
195
|
+
createSchemaValidator,
|
|
196
|
+
loadCommands,
|
|
197
|
+
loadExerciseType,
|
|
198
|
+
loadView,
|
|
199
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
200
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
201
|
+
import manifest from '../extension.json';
|
|
202
|
+
import { host, views } from '../src/index.ts';
|
|
203
|
+
|
|
204
|
+
const [contribution] = manifest.contributes.exerciseTypes;
|
|
205
|
+
const validateSpec = createSchemaValidator(contribution.specSchema);
|
|
206
|
+
const validateAnswer = createSchemaValidator(contribution.answerSchema);
|
|
207
|
+
|
|
208
|
+
const spec = { expected: 'Hello' };
|
|
209
|
+
|
|
210
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
211
|
+
afterEach(async () => {
|
|
212
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const newSettings = () =>
|
|
216
|
+
createMemorySettings(manifest.contributes.settings as SettingContribution[]);
|
|
217
|
+
|
|
218
|
+
const load = async (settings = newSettings()) => {
|
|
219
|
+
const type = await loadExerciseType(host, 'acme.hello', { settings });
|
|
220
|
+
disposables.push(type);
|
|
221
|
+
return type;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const mount = async (label?: string) => {
|
|
225
|
+
const view = await loadView(views, 'acme.hello', label === undefined ? {} : { label });
|
|
226
|
+
disposables.push(view);
|
|
227
|
+
const input = view.query<HTMLInputElement>('input');
|
|
228
|
+
if (input === null) throw new Error('no input');
|
|
229
|
+
return { view, input };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
describe('acme.hello: handler', () => {
|
|
233
|
+
it('project does not reveal the reference', async () => {
|
|
234
|
+
const type = await load();
|
|
235
|
+
expect(await type.project(spec)).toEqual({});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('grade: a match passes, a mismatch does not', async () => {
|
|
239
|
+
const type = await load();
|
|
240
|
+
expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
|
|
241
|
+
outcome: 'passed',
|
|
242
|
+
});
|
|
243
|
+
expect(await type.grade({ spec, answer: 'hello' })).toEqual({
|
|
244
|
+
outcome: 'failed',
|
|
245
|
+
reason: 'mismatch',
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('grade: ignoreCase turns case sensitivity off', async () => {
|
|
250
|
+
const type = await load();
|
|
251
|
+
const relaxed = { ...spec, ignoreCase: true };
|
|
252
|
+
expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
|
|
253
|
+
outcome: 'passed',
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('referenceAnswer passes the check itself', async () => {
|
|
258
|
+
const type = await load();
|
|
259
|
+
const reference = await type.referenceAnswer(spec);
|
|
260
|
+
expect(reference).toEqual({ found: true, answer: 'Hello' });
|
|
261
|
+
if (!reference.found) throw new Error('reference expected');
|
|
262
|
+
expect(await type.grade({ spec, answer: reference.answer })).toEqual({
|
|
263
|
+
outcome: 'passed',
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('acme.hello: settings and commands', () => {
|
|
269
|
+
it('the trim setting decides whether the spaces around an answer count', async () => {
|
|
270
|
+
const settings = newSettings();
|
|
271
|
+
const type = await load(settings);
|
|
272
|
+
expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
|
|
273
|
+
outcome: 'passed',
|
|
274
|
+
});
|
|
275
|
+
await settings.set('acme.hello.trim', false);
|
|
276
|
+
expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
|
|
277
|
+
outcome: 'failed',
|
|
278
|
+
reason: 'mismatch',
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('the status command reports the current mode', async () => {
|
|
283
|
+
const settings = newSettings();
|
|
284
|
+
const commands = await loadCommands(host, {
|
|
285
|
+
declaredCommands: ['acme.hello.status'],
|
|
286
|
+
settings,
|
|
287
|
+
});
|
|
288
|
+
disposables.push(commands);
|
|
289
|
+
expect(await commands.run('acme.hello.status')).toEqual({
|
|
290
|
+
kind: 'notify',
|
|
291
|
+
text: 'Answers are compared without the spaces around them.',
|
|
292
|
+
});
|
|
293
|
+
await settings.set('acme.hello.trim', false);
|
|
294
|
+
expect(await commands.run('acme.hello.status')).toEqual({
|
|
295
|
+
kind: 'notify',
|
|
296
|
+
text: 'Answers are compared exactly as typed.',
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('acme.hello: schemas', () => {
|
|
302
|
+
it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
|
|
303
|
+
'spec %j is valid',
|
|
304
|
+
(value) => {
|
|
305
|
+
expect(validateSpec(value)).toEqual([]);
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
it.each([
|
|
310
|
+
['no expected', {}],
|
|
311
|
+
['empty expected', { expected: '' }],
|
|
312
|
+
['ignoreCase is not a boolean', { expected: 'a', ignoreCase: 'yes' }],
|
|
313
|
+
['an extra field', { expected: 'a', extra: 1 }],
|
|
314
|
+
])('spec: %s is rejected', (_name, value) => {
|
|
315
|
+
expect(validateSpec(value)).not.toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('answer: a string is valid, a number is not', () => {
|
|
319
|
+
expect(validateAnswer('text')).toEqual([]);
|
|
320
|
+
expect(validateAnswer(42)).not.toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('acme.hello: view', () => {
|
|
325
|
+
it('typing reports the answer; an empty input is incomplete', async () => {
|
|
326
|
+
const { view, input } = await mount();
|
|
327
|
+
input.value = 'Hello';
|
|
328
|
+
input.dispatchEvent(new Event('input'));
|
|
329
|
+
input.value = ' ';
|
|
330
|
+
input.dispatchEvent(new Event('input'));
|
|
331
|
+
expect(view.changes).toEqual([
|
|
332
|
+
{ value: 'Hello', complete: true },
|
|
333
|
+
{ value: ' ', complete: false },
|
|
334
|
+
]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('Enter submits the answer', async () => {
|
|
338
|
+
const { view, input } = await mount();
|
|
339
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
340
|
+
expect(view.submissions).toBe(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('disabled blocks the input', async () => {
|
|
344
|
+
const { view, input } = await mount();
|
|
345
|
+
await view.update({ disabled: true });
|
|
346
|
+
expect(input.disabled).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('value restores the answer without events', async () => {
|
|
350
|
+
const { view, input } = await mount();
|
|
351
|
+
await view.update({ value: 'Hello' });
|
|
352
|
+
expect(input.value).toBe('Hello');
|
|
353
|
+
expect(view.changes).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('the aria-label of the host goes to the input', async () => {
|
|
357
|
+
const { input } = await mount('Your answer');
|
|
358
|
+
expect(input.getAttribute('aria-label')).toBe('Your answer');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`loadExerciseType` activates `host` and gives `project` and `grade` the way the
|
|
364
|
+
app calls them, and checks the shape of the results. `loadView` mounts the view
|
|
365
|
+
in the test DOM (the `@vitest-environment happy-dom` comment) and reports
|
|
366
|
+
`changes` and `submissions`. `createSchemaValidator` checks fixtures against the
|
|
367
|
+
schemas of the manifest, so a change to a schema fails the test.
|
|
368
|
+
|
|
369
|
+
## Try and ship
|
|
370
|
+
|
|
371
|
+
```sh
|
|
372
|
+
pnpm install
|
|
373
|
+
pnpm test
|
|
374
|
+
pnpm dev
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
A course names the exercise type by its id (`acme.hello`) and supplies a `spec`
|
|
378
|
+
that matches `specSchema`. To make it yours, rename the id, change the schemas
|
|
379
|
+
and the grading, and keep the structure.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Recipe: an importer and an exporter
|
|
2
|
+
|
|
3
|
+
An importer turns a file the user picked into a new course; an exporter turns a
|
|
4
|
+
course (or the learning progress) into a file the user saves. Your code only
|
|
5
|
+
handles strings: the app shows the file dialogs, checks what you return with the
|
|
6
|
+
course compiler, and writes to disk. This recipe has no template of its own:
|
|
7
|
+
start from `blank` and replace the three files below, which are checked as a
|
|
8
|
+
whole project. See [quick-start.md](quick-start.md) for the commands.
|
|
9
|
+
|
|
10
|
+
## The manifest
|
|
11
|
+
|
|
12
|
+
File `extension.json` (import-export):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
17
|
+
"id": "acme.hello",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"apiVersion": 1,
|
|
20
|
+
"name": "Hello cards",
|
|
21
|
+
"description": "Import flashcards from a CSV file and export a course back to CSV.",
|
|
22
|
+
"author": "your-github-login",
|
|
23
|
+
"tags": ["content"],
|
|
24
|
+
"contributes": {
|
|
25
|
+
"importers": [
|
|
26
|
+
{ "id": "acme.hello.import", "title": "Cards from CSV", "accept": [".csv"] }
|
|
27
|
+
],
|
|
28
|
+
"exporters": [
|
|
29
|
+
{ "id": "acme.hello.export", "title": "Course to CSV", "scope": "course" }
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- An importer has an `id`, a `title` (up to 60 characters), `accept` (1–8
|
|
36
|
+
lower-case file extensions such as `.csv`) and an optional `input`: `text`
|
|
37
|
+
(the default, the handler gets the file as a UTF-8 string) or `bytes` (a
|
|
38
|
+
`Uint8Array`). An exporter has `scope`: `course` or `progress`.
|
|
39
|
+
- No permission is needed: the user choosing the file is the consent, and your
|
|
40
|
+
code never sees a path. A `progress` exporter reads `ctx.stats` and needs the
|
|
41
|
+
`learning.stats` permission.
|
|
42
|
+
- The importer appears in the palette as "Import: Cards from CSV", the exporter
|
|
43
|
+
as "Export: Course to CSV", and both have buttons in Settings → Library.
|
|
44
|
+
|
|
45
|
+
## The code
|
|
46
|
+
|
|
47
|
+
File `src/index.ts` (import-export):
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { defineExtension } from '@dolphy-app/extension-sdk';
|
|
51
|
+
import type {
|
|
52
|
+
CourseExportInput,
|
|
53
|
+
TextImportInput,
|
|
54
|
+
} from '@dolphy-app/extension-sdk';
|
|
55
|
+
|
|
56
|
+
const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
|
|
57
|
+
|
|
58
|
+
const courseIdOf = (fileName: string): string =>
|
|
59
|
+
fileName
|
|
60
|
+
.replace(/\.csv$/i, '')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-|-$/g, '') || 'cards';
|
|
64
|
+
|
|
65
|
+
export const host = defineExtension({
|
|
66
|
+
importers: {
|
|
67
|
+
// one line "front,back" is one flashcard; the paths are relative to the
|
|
68
|
+
// new course directory the app creates
|
|
69
|
+
'acme.hello.import': ({ name, text }: TextImportInput) => {
|
|
70
|
+
const rows = text.split(/\r?\n/).filter((line) => line.trim() !== '');
|
|
71
|
+
if (rows.length === 0) throw new Error('The file has no rows');
|
|
72
|
+
const course = courseIdOf(name);
|
|
73
|
+
const lesson = `${course}::cards`;
|
|
74
|
+
const files: Record<string, string> = {
|
|
75
|
+
[`${course}/course_manifest.json`]: json({
|
|
76
|
+
id: course,
|
|
77
|
+
name: name.replace(/\.csv$/i, ''),
|
|
78
|
+
dependencies: [],
|
|
79
|
+
encompassed: [],
|
|
80
|
+
superseded: [],
|
|
81
|
+
}),
|
|
82
|
+
[`${course}/cards/lesson_manifest.json`]: json({
|
|
83
|
+
id: lesson,
|
|
84
|
+
name: 'Cards',
|
|
85
|
+
course_id: course,
|
|
86
|
+
dependencies: [],
|
|
87
|
+
encompassed: [],
|
|
88
|
+
superseded: [],
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
rows.forEach((row, index) => {
|
|
92
|
+
const comma = row.indexOf(',');
|
|
93
|
+
if (comma <= 0 || comma === row.length - 1) {
|
|
94
|
+
throw new Error(`Row ${index + 1}: expected "front,back"`);
|
|
95
|
+
}
|
|
96
|
+
const dir = `${course}/cards/c${index + 1}`;
|
|
97
|
+
files[`${dir}/exercise_manifest.json`] = json({
|
|
98
|
+
id: `${lesson}::c${index + 1}`,
|
|
99
|
+
name: `Card ${index + 1}`,
|
|
100
|
+
lesson_id: lesson,
|
|
101
|
+
course_id: course,
|
|
102
|
+
exercise_type: 'Declarative',
|
|
103
|
+
exercise_asset: {
|
|
104
|
+
FlashcardAsset: { front_path: 'front.md', back_path: 'back.md' },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
files[`${dir}/front.md`] = `${row.slice(0, comma).trim()}\n`;
|
|
108
|
+
files[`${dir}/back.md`] = `${row.slice(comma + 1).trim()}\n`;
|
|
109
|
+
});
|
|
110
|
+
return { files };
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
exporters: {
|
|
114
|
+
// the snapshot holds the text files of the chosen course, paths relative
|
|
115
|
+
// to the course directory
|
|
116
|
+
'acme.hello.export': ({ title, files }: CourseExportInput) => {
|
|
117
|
+
const cell = (text: string): string => text.trim().replace(/\s+/g, ' ');
|
|
118
|
+
const fronts = Object.keys(files)
|
|
119
|
+
.filter((path) => path.endsWith('/front.md'))
|
|
120
|
+
.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
121
|
+
const rows = fronts.map((path) => {
|
|
122
|
+
const back = files[`${path.slice(0, -'front.md'.length)}back.md`];
|
|
123
|
+
return `${cell(files[path] ?? '')},${cell(back ?? '')}`;
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
filename: `${title.replace(/[\\/]/g, '-').slice(0, 100)}.csv`,
|
|
127
|
+
text: `${rows.join('\n')}\n`,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- An importer handler gets `{ name, text }` (or `{ name, bytes }`) and returns
|
|
135
|
+
`{ files: Record<path, text> }`: the files of a new directory in the library
|
|
136
|
+
(`imported/<extension id>-<file name>`). At most 5000 files, 2 MiB each and
|
|
137
|
+
20 MiB in all; a path is relative, uses `/`, and has no `..`, empty or
|
|
138
|
+
dot-leading segment, backslash, control character, or case-only duplicate.
|
|
139
|
+
Binary files are not supported: every file is text.
|
|
140
|
+
- The app compiles the tree before it writes anything and shows a summary
|
|
141
|
+
(courses, lessons, exercises) and diagnostics. With an error the user cannot
|
|
142
|
+
import, and nothing is left on disk. Importing the same file name again with
|
|
143
|
+
the same extension replaces the previous directory.
|
|
144
|
+
- An exporter handler gets `{ scope: 'course', courseId, title, files }` (or
|
|
145
|
+
`{ scope: 'progress' }`) and returns `{ filename, text }` or
|
|
146
|
+
`{ filename, bytes }` of at most 20 MiB. The file name has no path separator
|
|
147
|
+
and is at most 120 characters. The app asks the user where to save it.
|
|
148
|
+
- A handler has 30 seconds. Throw an `Error` to refuse: its message reaches the
|
|
149
|
+
user, and nothing is written.
|
|
150
|
+
- A handler that needs `ctx` is registered in `activate` with
|
|
151
|
+
`ctx.importers.register(id, handler)` / `ctx.exporters.register(id, handler)`
|
|
152
|
+
(`inActivate` in the record, as for commands).
|
|
153
|
+
|
|
154
|
+
## The tests
|
|
155
|
+
|
|
156
|
+
File `test/index.test.ts` (import-export):
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import {
|
|
160
|
+
loadExporters,
|
|
161
|
+
loadImporters,
|
|
162
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
163
|
+
import { describe, expect, it } from 'vitest';
|
|
164
|
+
import { host } from '../src/index.ts';
|
|
165
|
+
|
|
166
|
+
const CSV = 'hola,hello\nadiós,goodbye\n';
|
|
167
|
+
|
|
168
|
+
describe('acme.hello: importer', () => {
|
|
169
|
+
it('turns every row into a flashcard of one lesson', async () => {
|
|
170
|
+
const importers = await loadImporters(host, {
|
|
171
|
+
declaredImporters: [{ id: 'acme.hello.import' }],
|
|
172
|
+
});
|
|
173
|
+
const { files } = await importers.run('acme.hello.import', {
|
|
174
|
+
name: 'Spanish basics.csv',
|
|
175
|
+
text: CSV,
|
|
176
|
+
});
|
|
177
|
+
expect(Object.keys(files).sort()).toEqual([
|
|
178
|
+
'spanish-basics/cards/c1/back.md',
|
|
179
|
+
'spanish-basics/cards/c1/exercise_manifest.json',
|
|
180
|
+
'spanish-basics/cards/c1/front.md',
|
|
181
|
+
'spanish-basics/cards/c2/back.md',
|
|
182
|
+
'spanish-basics/cards/c2/exercise_manifest.json',
|
|
183
|
+
'spanish-basics/cards/c2/front.md',
|
|
184
|
+
'spanish-basics/cards/lesson_manifest.json',
|
|
185
|
+
'spanish-basics/course_manifest.json',
|
|
186
|
+
]);
|
|
187
|
+
expect(files['spanish-basics/cards/c2/front.md']).toBe('adiós\n');
|
|
188
|
+
await importers.dispose();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('refuses a row without an answer', async () => {
|
|
192
|
+
const importers = await loadImporters(host, {
|
|
193
|
+
declaredImporters: [{ id: 'acme.hello.import' }],
|
|
194
|
+
});
|
|
195
|
+
await expect(
|
|
196
|
+
importers.run('acme.hello.import', { name: 'x.csv', text: 'hola\n' }),
|
|
197
|
+
).rejects.toThrow('Row 1');
|
|
198
|
+
await importers.dispose();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('acme.hello: exporter', () => {
|
|
203
|
+
it('writes the cards of the snapshot back to CSV', async () => {
|
|
204
|
+
const exporters = await loadExporters(host, {
|
|
205
|
+
declaredExporters: [{ id: 'acme.hello.export', scope: 'course' }],
|
|
206
|
+
});
|
|
207
|
+
const result = await exporters.run('acme.hello.export', {
|
|
208
|
+
scope: 'course',
|
|
209
|
+
courseId: 'deck',
|
|
210
|
+
title: 'Deck / Spanish',
|
|
211
|
+
files: {
|
|
212
|
+
'cards/c1/front.md': 'hola\n',
|
|
213
|
+
'cards/c1/back.md': 'hello\n',
|
|
214
|
+
'cards/c10/front.md': 'diez\n',
|
|
215
|
+
'cards/c10/back.md': 'ten\n',
|
|
216
|
+
'cards/c2/front.md': 'adiós\n',
|
|
217
|
+
'cards/c2/back.md': 'goodbye\n',
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
expect(result).toEqual({
|
|
221
|
+
filename: 'Deck - Spanish.csv',
|
|
222
|
+
text: 'hola,hello\nadiós,goodbye\ndiez,ten\n',
|
|
223
|
+
});
|
|
224
|
+
await exporters.dispose();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`loadImporters` and `loadExporters` activate the module in memory and run a
|
|
230
|
+
handler with the rules of the host: the `text`/`bytes` form of a declared
|
|
231
|
+
importer, the `scope` of a declared exporter, the size of the input and the
|
|
232
|
+
shape and limits of the result. A broken result rejects with
|
|
233
|
+
`invalid import result: …` / `invalid export result: …`, so the test fails the
|
|
234
|
+
way the app would refuse it. For a `progress` exporter pass
|
|
235
|
+
`stats: createMemoryStats(…)`.
|
|
236
|
+
|
|
237
|
+
## Try and ship
|
|
238
|
+
|
|
239
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open the palette and
|
|
240
|
+
run "Import: Cards from CSV". After you confirm the summary the course shows up
|
|
241
|
+
in Courses without a restart. See [debugging.md](debugging.md) for the rest.
|