@vue-skuilder/common 0.1.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 +1 -0
- package/eslint.config.js +16 -0
- package/package.json +33 -0
- package/src/bulkImport/cardParser.ts +137 -0
- package/src/bulkImport/types.ts +28 -0
- package/src/course-data.ts +119 -0
- package/src/db.ts +181 -0
- package/src/elo.ts +193 -0
- package/src/enums/DataShapeNames.ts +43 -0
- package/src/enums/FieldType.ts +17 -0
- package/src/enums/index.ts +2 -0
- package/src/fieldConverters.ts +74 -0
- package/src/index.ts +17 -0
- package/src/interfaces/AnswerInterfaces.ts +7 -0
- package/src/interfaces/DataShape.ts +9 -0
- package/src/interfaces/FieldDefinition.ts +12 -0
- package/src/interfaces/Tagger.ts +6 -0
- package/src/interfaces/Validator.ts +27 -0
- package/src/interfaces/ViewData.ts +21 -0
- package/src/interfaces/index.ts +6 -0
- package/src/logshim.ts +4 -0
- package/src/namespacer.ts +71 -0
- package/src/validators.ts +25 -0
- package/src/wire-format.ts +159 -0
- package/tsconfig.cjs.json +11 -0
- package/tsconfig.esm.json +11 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# common
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import backendConfig from '../../eslint.config.backend.mjs';
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
...backendConfig,
|
|
5
|
+
{
|
|
6
|
+
ignores: ['node_modules/**', 'dist/**', 'dist-esm/**', 'eslint.config.js'],
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
languageOptions: {
|
|
10
|
+
parserOptions: {
|
|
11
|
+
project: './tsconfig.json',
|
|
12
|
+
tsconfigRootDir: import.meta.dirname,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vue-skuilder/common",
|
|
3
|
+
"private": false,
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"version": "0.1.0",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"module": "dist/index.mjs",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "rm -rf dist dist-esm && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && find dist-esm -name '*.js' -exec sed -i \"s/\\.js'/\\.mjs'/g; s/\\.js\\\"/\\.mjs\\\"/g\" {} \\; && find dist-esm -name '*.js' -exec sh -c 'mv \"$1\" \"${1%.js}.mjs\"' _ {} \\; && cp -r dist-esm/* dist/ && rm -rf dist-esm",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"lint": "npx eslint .",
|
|
23
|
+
"lint:fix": "npx eslint . --fix",
|
|
24
|
+
"lint:check": "npx eslint . --max-warnings 0"
|
|
25
|
+
},
|
|
26
|
+
"packageManager": "yarn@4.6.0",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "~5.7.2"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"moment": "^2.30.1"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ParsedCard } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for the bulk card parser
|
|
5
|
+
*/
|
|
6
|
+
export interface CardParserConfig {
|
|
7
|
+
/** Custom tag identifier (defaults to 'tags:') */
|
|
8
|
+
tagIdentifier?: string;
|
|
9
|
+
/** Custom ELO identifier (defaults to 'elo:') */
|
|
10
|
+
eloIdentifier?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default configuration for the card parser
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_PARSER_CONFIG: CardParserConfig = {
|
|
17
|
+
tagIdentifier: 'tags:',
|
|
18
|
+
eloIdentifier: 'elo:',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Card delimiter used to separate cards in bulk input
|
|
23
|
+
*/
|
|
24
|
+
export const CARD_DELIMITER = '\n---\n---\n';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parses a single card string into a structured object
|
|
28
|
+
*
|
|
29
|
+
* @param cardString - Raw string containing card content
|
|
30
|
+
* @param config - Optional parser configuration
|
|
31
|
+
* @returns ParsedCard object or null if parsing fails
|
|
32
|
+
*/
|
|
33
|
+
export function parseCard(
|
|
34
|
+
cardString: string,
|
|
35
|
+
config: CardParserConfig = DEFAULT_PARSER_CONFIG
|
|
36
|
+
): ParsedCard | null {
|
|
37
|
+
const trimmedCardString = cardString.trim();
|
|
38
|
+
if (!trimmedCardString) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines = trimmedCardString.split('\n');
|
|
43
|
+
let tags: string[] = [];
|
|
44
|
+
let elo: number | undefined = undefined;
|
|
45
|
+
const markdownLines = [...lines];
|
|
46
|
+
|
|
47
|
+
// Process the lines from bottom to top to handle metadata
|
|
48
|
+
let metadataLines = 0;
|
|
49
|
+
|
|
50
|
+
// Get the configured identifiers
|
|
51
|
+
const tagId = config.tagIdentifier || DEFAULT_PARSER_CONFIG.tagIdentifier;
|
|
52
|
+
const eloId = config.eloIdentifier || DEFAULT_PARSER_CONFIG.eloIdentifier;
|
|
53
|
+
|
|
54
|
+
// Check the last few lines for metadata (tags and elo)
|
|
55
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 2; i--) {
|
|
56
|
+
const line = lines[i].trim();
|
|
57
|
+
|
|
58
|
+
// Check for tags
|
|
59
|
+
if (line.toLowerCase().startsWith(tagId!.toLowerCase())) {
|
|
60
|
+
tags = line
|
|
61
|
+
.substring(tagId!.length)
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((tag) => tag.trim())
|
|
64
|
+
.filter((tag) => tag);
|
|
65
|
+
metadataLines++;
|
|
66
|
+
}
|
|
67
|
+
// Check for ELO
|
|
68
|
+
else if (line.toLowerCase().startsWith(eloId!.toLowerCase())) {
|
|
69
|
+
const eloValue = line.substring(eloId!.length).trim();
|
|
70
|
+
const parsedElo = parseInt(eloValue, 10);
|
|
71
|
+
if (!isNaN(parsedElo)) {
|
|
72
|
+
elo = parsedElo;
|
|
73
|
+
}
|
|
74
|
+
metadataLines++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Remove metadata lines from the end of the content
|
|
79
|
+
if (metadataLines > 0) {
|
|
80
|
+
markdownLines.splice(markdownLines.length - metadataLines);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const markdown = markdownLines.join('\n').trim();
|
|
84
|
+
if (!markdown) {
|
|
85
|
+
// Card must have some markdown content
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { markdown, tags, elo };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Splits a bulk text input into individual card strings
|
|
94
|
+
*
|
|
95
|
+
* @param bulkText - Raw string containing multiple cards
|
|
96
|
+
* @returns Array of card strings
|
|
97
|
+
*/
|
|
98
|
+
export function splitCardsText(bulkText: string): string[] {
|
|
99
|
+
return bulkText
|
|
100
|
+
.split(CARD_DELIMITER)
|
|
101
|
+
.map((card) => card.trim())
|
|
102
|
+
.filter((card) => card); // Filter out empty strings
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parses a bulk text input into an array of structured ParsedCard objects.
|
|
107
|
+
*
|
|
108
|
+
* @param bulkText - Raw string containing multiple cards.
|
|
109
|
+
* @param config - Optional parser configuration.
|
|
110
|
+
* @returns Array of ParsedCard objects. Filters out cards that fail to parse.
|
|
111
|
+
*/
|
|
112
|
+
export function parseBulkTextToCards(
|
|
113
|
+
bulkText: string,
|
|
114
|
+
config: CardParserConfig = DEFAULT_PARSER_CONFIG
|
|
115
|
+
): ParsedCard[] {
|
|
116
|
+
const cardStrings = splitCardsText(bulkText);
|
|
117
|
+
const parsedCards: ParsedCard[] = [];
|
|
118
|
+
|
|
119
|
+
for (const cardString of cardStrings) {
|
|
120
|
+
const parsedCard = parseCard(cardString, config);
|
|
121
|
+
if (parsedCard) {
|
|
122
|
+
parsedCards.push(parsedCard);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return parsedCards;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validates if a bulk text input has valid format
|
|
130
|
+
*
|
|
131
|
+
* @param bulkText - Raw string containing multiple cards
|
|
132
|
+
* @returns true if valid, false otherwise
|
|
133
|
+
*/
|
|
134
|
+
export function isValidBulkFormat(bulkText: string): boolean {
|
|
135
|
+
const cardStrings = splitCardsText(bulkText);
|
|
136
|
+
return cardStrings.length > 0 && cardStrings.some((card) => !!card.trim());
|
|
137
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// We no longer need to import DataShape since we've moved the interfaces that used it
|
|
2
|
+
// import { DataShape } from '@vue-skuilder/common';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interface representing a parsed card from bulk import
|
|
6
|
+
*/
|
|
7
|
+
export interface ParsedCard {
|
|
8
|
+
/** The markdown content of the card */
|
|
9
|
+
markdown: string;
|
|
10
|
+
/** Tags associated with the card */
|
|
11
|
+
tags: string[];
|
|
12
|
+
/** ELO rating for the card (optional) */
|
|
13
|
+
elo?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Interface for card data ready to be stored in the database
|
|
18
|
+
*/
|
|
19
|
+
export interface BulkImportCardData {
|
|
20
|
+
/** Card markdown content */
|
|
21
|
+
Input: string;
|
|
22
|
+
/** Card media uploads */
|
|
23
|
+
Uploads: unknown[];
|
|
24
|
+
/** Any additional fields can be added as needed */
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ImportResult and BulkCardProcessorConfig have been moved to @vue-skuilder/db
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { DisplayableData, DocType } from './db.js';
|
|
2
|
+
import { NameSpacer } from './namespacer.js';
|
|
3
|
+
import { DataShape } from './interfaces/DataShape.js';
|
|
4
|
+
import { FieldDefinition } from './interfaces/FieldDefinition.js';
|
|
5
|
+
|
|
6
|
+
import { FieldType } from './enums/FieldType.js';
|
|
7
|
+
|
|
8
|
+
export function prepareNote55(
|
|
9
|
+
courseID: string,
|
|
10
|
+
codeCourse: string,
|
|
11
|
+
shape: DataShape,
|
|
12
|
+
// [ ] add typing
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
data: any,
|
|
15
|
+
author: string,
|
|
16
|
+
_tags: string[],
|
|
17
|
+
uploads?: { [x: string]: PouchDB.Core.FullAttachment }
|
|
18
|
+
): DisplayableData {
|
|
19
|
+
const dataShapeId = NameSpacer.getDataShapeString({
|
|
20
|
+
course: codeCourse,
|
|
21
|
+
dataShape: shape.name,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const attachmentFields = shape.fields
|
|
25
|
+
.map((field) => {
|
|
26
|
+
// make a copy, in order NOT to append to the datashape
|
|
27
|
+
const copy: FieldDefinition = {
|
|
28
|
+
name: field.name,
|
|
29
|
+
type: field.type,
|
|
30
|
+
};
|
|
31
|
+
return copy;
|
|
32
|
+
})
|
|
33
|
+
.filter((field) => {
|
|
34
|
+
return field.type === FieldType.IMAGE || field.type === FieldType.AUDIO;
|
|
35
|
+
})
|
|
36
|
+
.concat([
|
|
37
|
+
{
|
|
38
|
+
name: 'autoplayAudio',
|
|
39
|
+
type: FieldType.AUDIO,
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
for (let i = 1; i < 11; i++) {
|
|
44
|
+
if (data[`audio-${i}`]) {
|
|
45
|
+
attachmentFields.push({
|
|
46
|
+
name: `audio-${i}`,
|
|
47
|
+
type: FieldType.AUDIO,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (data[`image-${i}`]) {
|
|
52
|
+
attachmentFields.push({
|
|
53
|
+
name: `image-${i}`,
|
|
54
|
+
type: FieldType.IMAGE,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (data[`audio-11`]) {
|
|
59
|
+
throw new Error('Too many audio attachments');
|
|
60
|
+
}
|
|
61
|
+
if (data[`image-11`]) {
|
|
62
|
+
throw new Error('Too many image attachments');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const attachments: { [index: string]: PouchDB.Core.FullAttachment } = {};
|
|
66
|
+
const payload: DisplayableData = {
|
|
67
|
+
course: courseID,
|
|
68
|
+
data: [],
|
|
69
|
+
docType: DocType.DISPLAYABLE_DATA,
|
|
70
|
+
id_datashape: dataShapeId,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (author) {
|
|
74
|
+
payload.author = author;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
attachmentFields.forEach((attField) => {
|
|
78
|
+
attachments[attField.name] = data[attField.name];
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
//
|
|
82
|
+
if (uploads) {
|
|
83
|
+
Object.keys(uploads).forEach((k) => {
|
|
84
|
+
attachments[k] = uploads[k];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (attachmentFields.length !== 0 || (uploads && Object.keys(uploads).length)) {
|
|
89
|
+
payload._attachments = attachments;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
shape.fields
|
|
93
|
+
.filter((field) => {
|
|
94
|
+
return field.type !== FieldType.IMAGE && field.type !== FieldType.AUDIO;
|
|
95
|
+
})
|
|
96
|
+
.forEach((field) => {
|
|
97
|
+
payload.data.push({
|
|
98
|
+
name: field.name,
|
|
99
|
+
data: data[field.name],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return payload;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Question components
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
export interface Evaluation {
|
|
111
|
+
isCorrect: boolean; // expand / contract the SRS
|
|
112
|
+
performance: Performance;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Performance =
|
|
116
|
+
| number
|
|
117
|
+
| {
|
|
118
|
+
[dimension: string]: Performance;
|
|
119
|
+
};
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Evaluation } from './course-data.js';
|
|
2
|
+
import { Answer } from './interfaces/AnswerInterfaces.js';
|
|
3
|
+
import { CourseElo } from './elo.js';
|
|
4
|
+
import { Moment } from 'moment';
|
|
5
|
+
|
|
6
|
+
export enum DocType {
|
|
7
|
+
DISPLAYABLE_DATA = 'DISPLAYABLE_DATA',
|
|
8
|
+
CARD = 'CARD',
|
|
9
|
+
DATASHAPE = 'DATASHAPE',
|
|
10
|
+
QUESTIONTYPE = 'QUESTION',
|
|
11
|
+
VIEW = 'VIEW',
|
|
12
|
+
PEDAGOGY = 'PEDAGOGY',
|
|
13
|
+
CARDRECORD = 'CARDRECORD',
|
|
14
|
+
SCHEDULED_CARD = 'SCHEDULED_CARD',
|
|
15
|
+
TAG = 'TAG',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Interface for all data on course content and pedagogy stored
|
|
20
|
+
* in the c/pouch database.
|
|
21
|
+
*/
|
|
22
|
+
export interface SkuilderCourseData {
|
|
23
|
+
course: string;
|
|
24
|
+
docType: DocType;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Tag extends SkuilderCourseData {
|
|
28
|
+
docType: DocType.TAG;
|
|
29
|
+
name: string;
|
|
30
|
+
snippet: string; // 200 char description of the tag
|
|
31
|
+
wiki: string; // 3000 char md-friendly description
|
|
32
|
+
taggedCards: PouchDB.Core.DocumentId[];
|
|
33
|
+
}
|
|
34
|
+
export interface TagStub {
|
|
35
|
+
name: string;
|
|
36
|
+
snippet: string;
|
|
37
|
+
count: number; // the number of cards that have this tag applied
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CardData extends SkuilderCourseData {
|
|
41
|
+
docType: DocType.CARD;
|
|
42
|
+
id_displayable_data: PouchDB.Core.DocumentId[];
|
|
43
|
+
id_view: PouchDB.Core.DocumentId;
|
|
44
|
+
elo: CourseElo;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A list of populated courses in the DB */
|
|
48
|
+
export interface CourseListData extends PouchDB.Core.Response {
|
|
49
|
+
courses: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The data used to hydrate viewable components (questions, info, etc)
|
|
54
|
+
*/
|
|
55
|
+
export interface DisplayableData extends SkuilderCourseData {
|
|
56
|
+
docType: DocType.DISPLAYABLE_DATA;
|
|
57
|
+
author?: string;
|
|
58
|
+
id_datashape: PouchDB.Core.DocumentId;
|
|
59
|
+
data: Field[];
|
|
60
|
+
_attachments?: { [index: string]: PouchDB.Core.FullAttachment };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Field {
|
|
64
|
+
data: unknown;
|
|
65
|
+
name: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DataShapeData extends SkuilderCourseData {
|
|
69
|
+
docType: DocType.DATASHAPE;
|
|
70
|
+
_id: PouchDB.Core.DocumentId;
|
|
71
|
+
questionTypes: PouchDB.Core.DocumentId[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface QuestionData extends SkuilderCourseData {
|
|
75
|
+
docType: DocType.QUESTIONTYPE;
|
|
76
|
+
_id: PouchDB.Core.DocumentId;
|
|
77
|
+
viewList: string[];
|
|
78
|
+
dataShapeList: PouchDB.Core.DocumentId[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cardHistoryPrefix = 'cardH';
|
|
82
|
+
|
|
83
|
+
export function getCardHistoryID(courseID: string, cardID: string): PouchDB.Core.DocumentId {
|
|
84
|
+
return `${cardHistoryPrefix}-${courseID}-${cardID}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseCardHistoryID(id: string): {
|
|
88
|
+
courseID: string;
|
|
89
|
+
cardID: string;
|
|
90
|
+
} {
|
|
91
|
+
const split = id.split('-');
|
|
92
|
+
let error: string = '';
|
|
93
|
+
error += split.length === 3 ? '' : `\n\tgiven ID has incorrect number of '-' characters`;
|
|
94
|
+
error +=
|
|
95
|
+
split[0] === cardHistoryPrefix ? '' : `\n\tgiven ID does not start with ${cardHistoryPrefix}`;
|
|
96
|
+
|
|
97
|
+
if (split.length === 3 && split[0] === cardHistoryPrefix) {
|
|
98
|
+
return {
|
|
99
|
+
courseID: split[1],
|
|
100
|
+
cardID: split[2],
|
|
101
|
+
};
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error('parseCardHistory Error:' + error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CardHistory<T extends CardRecord> {
|
|
108
|
+
_id: PouchDB.Core.DocumentId;
|
|
109
|
+
/**
|
|
110
|
+
* The CouchDB id of the card
|
|
111
|
+
*/
|
|
112
|
+
cardID: PouchDB.Core.DocumentId;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The ID of the course
|
|
116
|
+
*/
|
|
117
|
+
courseID: string;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The to-date largest interval between successful
|
|
121
|
+
* card reviews. `0` indicates no successful reviews.
|
|
122
|
+
*/
|
|
123
|
+
bestInterval: number;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The number of times that a card has been
|
|
127
|
+
* failed in review
|
|
128
|
+
*/
|
|
129
|
+
lapses: number;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The number of consecutive successful impressions
|
|
133
|
+
* on this card
|
|
134
|
+
*/
|
|
135
|
+
streak: number;
|
|
136
|
+
|
|
137
|
+
records: T[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface CardRecord {
|
|
141
|
+
/**
|
|
142
|
+
* The CouchDB id of the card
|
|
143
|
+
*/
|
|
144
|
+
cardID: string;
|
|
145
|
+
/**
|
|
146
|
+
* The ID of the course
|
|
147
|
+
*/
|
|
148
|
+
courseID: string;
|
|
149
|
+
/**
|
|
150
|
+
* Number of milliseconds that the user spent before dismissing
|
|
151
|
+
* the card (ie, "I've read this" or "here is my answer")
|
|
152
|
+
*
|
|
153
|
+
* //TODO: this (sometimes?) wants to be replaced with a rich
|
|
154
|
+
* recording of user activity in working the question
|
|
155
|
+
*/
|
|
156
|
+
timeSpent: number;
|
|
157
|
+
/**
|
|
158
|
+
* The date-time that the card was rendered. timeStamp + timeSpent will give the
|
|
159
|
+
* time of user submission.
|
|
160
|
+
*/
|
|
161
|
+
timeStamp: Moment;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface QuestionRecord extends CardRecord, Evaluation {
|
|
165
|
+
userAnswer: Answer;
|
|
166
|
+
/**
|
|
167
|
+
* The number of incorrect user submissions prededing this submisstion.
|
|
168
|
+
*
|
|
169
|
+
* eg, if a user is asked 7*6=__, submitting 46, 48, 42 will result in three
|
|
170
|
+
* records being created having 0, 1, and 2 as their recorded 'priorAttempts' values
|
|
171
|
+
*/
|
|
172
|
+
priorAttemps: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function areQuestionRecords(h: CardHistory<CardRecord>): h is CardHistory<QuestionRecord> {
|
|
176
|
+
return isQuestionRecord(h.records[0]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isQuestionRecord(c: CardRecord): c is QuestionRecord {
|
|
180
|
+
return (c as QuestionRecord).userAnswer !== undefined;
|
|
181
|
+
}
|
package/src/elo.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
export class EloRanker {
|
|
2
|
+
constructor(public k: number = 32) {}
|
|
3
|
+
|
|
4
|
+
setKFactor(k: number): void {
|
|
5
|
+
this.k = k;
|
|
6
|
+
}
|
|
7
|
+
getKFactor(): number {
|
|
8
|
+
return this.k;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
getExpected(a: number, b: number): number {
|
|
12
|
+
return 1 / (1 + Math.pow(10, (b - a) / 400));
|
|
13
|
+
}
|
|
14
|
+
updateRating(expected: number, actual: number, current: number): number {
|
|
15
|
+
return Math.round(current + this.k * (actual - expected));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CourseElo = {
|
|
20
|
+
global: EloRank;
|
|
21
|
+
tags: {
|
|
22
|
+
[tagID: string]: EloRank;
|
|
23
|
+
};
|
|
24
|
+
misc: {
|
|
25
|
+
[eloID: string]: EloRank;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type EloRank = {
|
|
30
|
+
score: number;
|
|
31
|
+
count: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type Eloish = number | EloRank | CourseElo;
|
|
35
|
+
|
|
36
|
+
export function blankCourseElo(): CourseElo {
|
|
37
|
+
return {
|
|
38
|
+
global: {
|
|
39
|
+
score: 990 + Math.round(Math.random() * 20),
|
|
40
|
+
count: 0,
|
|
41
|
+
},
|
|
42
|
+
tags: {},
|
|
43
|
+
misc: {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function EloToNumber(elo: Eloish): number {
|
|
48
|
+
if (typeof elo === 'number') {
|
|
49
|
+
return elo;
|
|
50
|
+
} else if (isCourseElo(elo)) {
|
|
51
|
+
return elo.global.score;
|
|
52
|
+
}
|
|
53
|
+
{
|
|
54
|
+
return elo.score;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function toElo(elo: number | EloRank): EloRank {
|
|
58
|
+
if (typeof elo === 'number') {
|
|
59
|
+
return {
|
|
60
|
+
score: elo,
|
|
61
|
+
count: 0,
|
|
62
|
+
};
|
|
63
|
+
} else {
|
|
64
|
+
return elo;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function toCourseElo(elo: Eloish | undefined): CourseElo {
|
|
68
|
+
if (typeof elo === 'string') {
|
|
69
|
+
throw new Error('unsuitiably typed input to toCourseElo');
|
|
70
|
+
}
|
|
71
|
+
if (typeof elo === 'number') {
|
|
72
|
+
return {
|
|
73
|
+
global: {
|
|
74
|
+
score: elo,
|
|
75
|
+
count: 0,
|
|
76
|
+
},
|
|
77
|
+
misc: {},
|
|
78
|
+
tags: {},
|
|
79
|
+
};
|
|
80
|
+
} else if (isCourseElo(elo)) {
|
|
81
|
+
return elo;
|
|
82
|
+
} else if (elo === undefined) {
|
|
83
|
+
return {
|
|
84
|
+
global: {
|
|
85
|
+
score: 995 + Math.random() * 10,
|
|
86
|
+
count: 0,
|
|
87
|
+
},
|
|
88
|
+
tags: {},
|
|
89
|
+
misc: {},
|
|
90
|
+
};
|
|
91
|
+
} else {
|
|
92
|
+
return {
|
|
93
|
+
global: elo,
|
|
94
|
+
tags: {},
|
|
95
|
+
misc: {},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isCourseElo(x: unknown): x is CourseElo {
|
|
101
|
+
if (!x || typeof x !== 'object') {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return 'global' in x && 'tags' in x;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Calculates updated ELO scores for users and content after they interact
|
|
110
|
+
*
|
|
111
|
+
* @param userElo current ELO score of the user
|
|
112
|
+
* @param cardElo current ELO score of the card
|
|
113
|
+
* @param userScore user performance against the card in range [0,1]
|
|
114
|
+
* @param k optional scaling factor. Higher values -> larger score adjustments. Default 32.
|
|
115
|
+
* @returns
|
|
116
|
+
*/
|
|
117
|
+
export function adjustCourseScores(
|
|
118
|
+
aElo: Eloish,
|
|
119
|
+
bElo: Eloish,
|
|
120
|
+
userScore: number,
|
|
121
|
+
options?: {
|
|
122
|
+
globalOnly: boolean;
|
|
123
|
+
}
|
|
124
|
+
): {
|
|
125
|
+
userElo: CourseElo;
|
|
126
|
+
cardElo: CourseElo;
|
|
127
|
+
} {
|
|
128
|
+
if (userScore < 0 || userScore > 1) {
|
|
129
|
+
throw new Error(`ELO performance rating must be between 0 and 1 - received ${userScore}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const userElo: CourseElo = toCourseElo(aElo);
|
|
133
|
+
const cardElo: CourseElo = toCourseElo(bElo);
|
|
134
|
+
|
|
135
|
+
if (options == undefined || !options.globalOnly) {
|
|
136
|
+
// grade on each tag present for the card
|
|
137
|
+
Object.keys(cardElo.tags).forEach((k) => {
|
|
138
|
+
const userTagElo: EloRank = userElo.tags[k]
|
|
139
|
+
? userElo.tags[k]
|
|
140
|
+
: {
|
|
141
|
+
count: 0,
|
|
142
|
+
score: userElo.global.score, // todo: 1000?
|
|
143
|
+
};
|
|
144
|
+
const adjusted = adjustScores(userTagElo, cardElo.tags[k], userScore);
|
|
145
|
+
userElo.tags[k] = adjusted.userElo;
|
|
146
|
+
cardElo.tags[k] = adjusted.cardElo;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const adjusted = adjustScores(userElo.global, cardElo.global, userScore);
|
|
151
|
+
userElo.global = adjusted.userElo;
|
|
152
|
+
cardElo.global = adjusted.cardElo;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
userElo,
|
|
156
|
+
cardElo,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function adjustScores(
|
|
161
|
+
userElo: EloRank,
|
|
162
|
+
cardElo: EloRank,
|
|
163
|
+
userScore: number
|
|
164
|
+
): {
|
|
165
|
+
userElo: EloRank;
|
|
166
|
+
cardElo: EloRank;
|
|
167
|
+
} {
|
|
168
|
+
if (userScore < 0 || userScore > 1) {
|
|
169
|
+
throw new Error(`ELO performance rating must be between 0 and 1 - received ${userScore}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// todo: how to calculate here?
|
|
173
|
+
// todo: should / must these be equal?
|
|
174
|
+
// todo: 176 - these K values should be a fcn of `.count` values of userElo and cardElo
|
|
175
|
+
const userRanker = new EloRanker(16);
|
|
176
|
+
const cardRanker = new EloRanker(16);
|
|
177
|
+
|
|
178
|
+
const exp = userRanker.getExpected(userElo.score, cardElo.score);
|
|
179
|
+
|
|
180
|
+
const updatedUserElo = userRanker.updateRating(exp, userScore, userElo.score);
|
|
181
|
+
const updatedCardElo = cardRanker.updateRating(1 - exp, 1 - userScore, cardElo.score);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
userElo: {
|
|
185
|
+
score: updatedUserElo,
|
|
186
|
+
count: userElo.count + 1,
|
|
187
|
+
},
|
|
188
|
+
cardElo: {
|
|
189
|
+
score: updatedCardElo,
|
|
190
|
+
count: cardElo.count + 1,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export enum DataShapeName {
|
|
2
|
+
BLANK = '',
|
|
3
|
+
// Shared base-course types
|
|
4
|
+
Basic = 'Basic',
|
|
5
|
+
Blanks = 'Blanks',
|
|
6
|
+
Default = 'Default',
|
|
7
|
+
|
|
8
|
+
// Math
|
|
9
|
+
MATH_SingleDigitAddition = 'SingleDigitAddition',
|
|
10
|
+
MATH_SingleDigitSubtraction = 'SingleDigitSubtraction',
|
|
11
|
+
MATH_SingleDigitDivision = 'SingleDigitDivision',
|
|
12
|
+
MATH_SingleDigitMultiplication = 'SingleDigitMultiplication',
|
|
13
|
+
MATH_EqualityTest = 'EqualityTest',
|
|
14
|
+
MATH_OneStepEquation = 'OneStepEquation',
|
|
15
|
+
MATH_AngleCategorize = 'AngleCategorize',
|
|
16
|
+
MATH_SupplimentaryAngles = 'SupplimentaryAngles',
|
|
17
|
+
MATH_CountBy = 'CountBy',
|
|
18
|
+
|
|
19
|
+
// French
|
|
20
|
+
FRENCH_AudioParse = 'AudioParse',
|
|
21
|
+
FRENCH_Vocab = 'Vocab',
|
|
22
|
+
|
|
23
|
+
// WordWork
|
|
24
|
+
WORDWORK_Spelling = 'WordWork_Spelling',
|
|
25
|
+
|
|
26
|
+
// Piano
|
|
27
|
+
PIANO_Echo = 'Piano_Echo',
|
|
28
|
+
PIANO_PlayNote = 'Piano_PlayNote',
|
|
29
|
+
|
|
30
|
+
// Pitch
|
|
31
|
+
PITCH_chroma = 'Pitch_chroma',
|
|
32
|
+
|
|
33
|
+
// SightSing
|
|
34
|
+
SIGHTSING_IdentifyKey = 'SightSing_IdentifyKey',
|
|
35
|
+
|
|
36
|
+
// Chess
|
|
37
|
+
CHESS_puzzle = 'CHESS_puzzle',
|
|
38
|
+
CHESS_forks = 'CHESS_forks',
|
|
39
|
+
|
|
40
|
+
// Typing
|
|
41
|
+
TYPING_singleLetter = 'TYPING_singleLetter',
|
|
42
|
+
TYPING_fallingLetters = 'TYPING_fallingLetters',
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* These are the defined types of user input that can hydrate a configured
|
|
3
|
+
* dataShape.
|
|
4
|
+
*
|
|
5
|
+
* These field types map to input elements and specific validation and processing functions.
|
|
6
|
+
*/
|
|
7
|
+
export enum FieldType {
|
|
8
|
+
STRING = 'string',
|
|
9
|
+
NUMBER = 'number',
|
|
10
|
+
INT = 'int',
|
|
11
|
+
IMAGE = 'image',
|
|
12
|
+
MARKDOWN = 'markdown',
|
|
13
|
+
AUDIO = 'audio',
|
|
14
|
+
MIDI = 'midi',
|
|
15
|
+
MEDIA_UPLOADS = 'uploads',
|
|
16
|
+
CHESS_PUZZLE = 'chess_puzzle',
|
|
17
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { FieldType } from './enums/FieldType.js';
|
|
2
|
+
|
|
3
|
+
const numberConverter: Converter = (value: string) => {
|
|
4
|
+
return parseFloat(value);
|
|
5
|
+
};
|
|
6
|
+
const intConverter: Converter = (value: string) => {
|
|
7
|
+
return parseInt(value, 10);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const fieldConverters: { [index in FieldType]: FieldConverter } = {
|
|
11
|
+
string: {
|
|
12
|
+
databaseConverter: (value: string) => value,
|
|
13
|
+
previewConverter: (value: string) => value,
|
|
14
|
+
},
|
|
15
|
+
chess_puzzle: {
|
|
16
|
+
databaseConverter: (value: string) => value,
|
|
17
|
+
previewConverter: (value: string) => value,
|
|
18
|
+
},
|
|
19
|
+
number: {
|
|
20
|
+
databaseConverter: numberConverter,
|
|
21
|
+
previewConverter: numberConverter,
|
|
22
|
+
},
|
|
23
|
+
int: {
|
|
24
|
+
databaseConverter: intConverter,
|
|
25
|
+
previewConverter: intConverter,
|
|
26
|
+
},
|
|
27
|
+
image: {
|
|
28
|
+
databaseConverter: (value) => value,
|
|
29
|
+
previewConverter: (value: { content_type: string; data: Blob }) => {
|
|
30
|
+
if (value) {
|
|
31
|
+
return value.data;
|
|
32
|
+
} else {
|
|
33
|
+
return new Blob();
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
audio: {
|
|
38
|
+
databaseConverter: (value) => value,
|
|
39
|
+
previewConverter: (value: { content_type: string; data: Blob }) => {
|
|
40
|
+
if (value) {
|
|
41
|
+
return value.data;
|
|
42
|
+
} else {
|
|
43
|
+
return new Blob();
|
|
44
|
+
}
|
|
45
|
+
// return '(audio)';
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
midi: {
|
|
49
|
+
databaseConverter: (value) => value,
|
|
50
|
+
previewConverter: (value) => value,
|
|
51
|
+
},
|
|
52
|
+
markdown: {
|
|
53
|
+
databaseConverter: (value) => value,
|
|
54
|
+
previewConverter: (value) => value,
|
|
55
|
+
},
|
|
56
|
+
uploads: {
|
|
57
|
+
databaseConverter: (value) => value,
|
|
58
|
+
previewConverter: (value) => value,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* FieldConverter contains functions to process raw user input
|
|
64
|
+
* from a DataInputForm into
|
|
65
|
+
* - database-ready format (databseConverter)
|
|
66
|
+
* - render-ready format (previewConverter)
|
|
67
|
+
*/
|
|
68
|
+
interface FieldConverter {
|
|
69
|
+
databaseConverter: Converter;
|
|
70
|
+
previewConverter: Converter;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
type Converter = (value: any) => string | number | boolean | Blob;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './wire-format.js';
|
|
2
|
+
export * from './course-data.js';
|
|
3
|
+
export * from './elo.js';
|
|
4
|
+
export * from './namespacer.js';
|
|
5
|
+
export * from './logshim.js';
|
|
6
|
+
export * from './validators.js';
|
|
7
|
+
export * from './fieldConverters.js';
|
|
8
|
+
export * from './db.js';
|
|
9
|
+
|
|
10
|
+
export * from './bulkImport/cardParser.js';
|
|
11
|
+
export * from './bulkImport/types.js';
|
|
12
|
+
|
|
13
|
+
// interfaces
|
|
14
|
+
export * from './interfaces/index.js';
|
|
15
|
+
|
|
16
|
+
// enums
|
|
17
|
+
export * from './enums/index.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// [ ] remove this file - duplicated in `common` package
|
|
2
|
+
|
|
3
|
+
import { FieldDefinition } from './FieldDefinition.js';
|
|
4
|
+
import { DataShapeName } from '../enums/DataShapeNames.js';
|
|
5
|
+
|
|
6
|
+
export interface DataShape {
|
|
7
|
+
name: DataShapeName;
|
|
8
|
+
fields: FieldDefinition[];
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Validator } from './Validator.js';
|
|
2
|
+
import { Tagger } from './Tagger.js';
|
|
3
|
+
import { FieldType } from '../enums/FieldType.js';
|
|
4
|
+
import { CourseElo } from '../elo.js';
|
|
5
|
+
|
|
6
|
+
export interface FieldDefinition {
|
|
7
|
+
name: string;
|
|
8
|
+
type: FieldType;
|
|
9
|
+
validator?: Validator;
|
|
10
|
+
tagger?: Tagger;
|
|
11
|
+
generateELO?: (x: unknown) => CourseElo;
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Status } from '../wire-format.js';
|
|
2
|
+
|
|
3
|
+
export type ValidatingFunction = (value: string) => ValidationResult;
|
|
4
|
+
export type VuetifyRule = (value: string) => true | string;
|
|
5
|
+
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
status: Status;
|
|
8
|
+
msg: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validationFunctionToVuetifyRule(f: ValidatingFunction): VuetifyRule {
|
|
12
|
+
return (value: string) => {
|
|
13
|
+
const result = f(value);
|
|
14
|
+
|
|
15
|
+
if (result.status === Status.ok) {
|
|
16
|
+
return true;
|
|
17
|
+
} else {
|
|
18
|
+
return result.msg;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Validator {
|
|
24
|
+
instructions?: string;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
test: ValidatingFunction;
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DisplayableData } from '../db.js';
|
|
2
|
+
|
|
3
|
+
export interface ViewData {
|
|
4
|
+
[index: string]: string | number | Blob | boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function displayableDataToViewData(data: DisplayableData): ViewData {
|
|
8
|
+
const ret: ViewData = {};
|
|
9
|
+
data.data.forEach((field) => {
|
|
10
|
+
ret[field.name] = field.data as string | number | boolean;
|
|
11
|
+
});
|
|
12
|
+
if (data._attachments) {
|
|
13
|
+
Object.getOwnPropertyNames(data._attachments).forEach((attachment) => {
|
|
14
|
+
// this 2nd check shouldn't be necessary, but TS is insisting
|
|
15
|
+
if (data._attachments) {
|
|
16
|
+
ret[attachment] = data._attachments[attachment].data as Blob;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return ret;
|
|
21
|
+
}
|
package/src/logshim.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export class NameSpacer {
|
|
2
|
+
public static getDataShapeDescriptor(shapeStr: string): ShapeDescriptor {
|
|
3
|
+
const splitArray = shapeStr.split('.');
|
|
4
|
+
|
|
5
|
+
if (splitArray.length !== 3) {
|
|
6
|
+
throw new Error('shapeStr not valid');
|
|
7
|
+
} else {
|
|
8
|
+
return {
|
|
9
|
+
course: splitArray[0],
|
|
10
|
+
dataShape: splitArray[2],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
public static getDataShapeString(shapeDescription: ShapeDescriptor): string {
|
|
15
|
+
return `${shapeDescription.course}.datashape.${shapeDescription.dataShape}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public static getViewDescriptor(viewStr: string): ViewDescriptor {
|
|
19
|
+
const splitArray = viewStr.split('.');
|
|
20
|
+
|
|
21
|
+
if (splitArray.length !== 4) {
|
|
22
|
+
throw new Error('viewStr not valid');
|
|
23
|
+
} else {
|
|
24
|
+
return {
|
|
25
|
+
course: splitArray[0],
|
|
26
|
+
questionType: splitArray[2],
|
|
27
|
+
view: splitArray[3],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public static getViewString(viewDescription: ViewDescriptor): string {
|
|
33
|
+
return (
|
|
34
|
+
`${viewDescription.course}.question.` +
|
|
35
|
+
`${viewDescription.questionType}.${viewDescription.view}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public static getQuestionDescriptor(questionStr: string): QuestionDescriptor {
|
|
40
|
+
const splitArray = questionStr.split('.');
|
|
41
|
+
|
|
42
|
+
if (splitArray.length !== 3) {
|
|
43
|
+
throw new Error('questionStr not valid');
|
|
44
|
+
} else {
|
|
45
|
+
return {
|
|
46
|
+
course: splitArray[0],
|
|
47
|
+
questionType: splitArray[2],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public static getQuestionString(questionDescription: QuestionDescriptor): string {
|
|
53
|
+
return `${questionDescription.course}.question.${questionDescription.questionType}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ShapeDescriptor {
|
|
58
|
+
course: string;
|
|
59
|
+
dataShape: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface QuestionDescriptor {
|
|
63
|
+
course: string;
|
|
64
|
+
questionType: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ViewDescriptor {
|
|
68
|
+
course: string;
|
|
69
|
+
questionType: string;
|
|
70
|
+
view: string;
|
|
71
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Validator } from './interfaces/Validator.js';
|
|
2
|
+
import { Status } from './wire-format.js';
|
|
3
|
+
|
|
4
|
+
interface ValidatorIndex {
|
|
5
|
+
[x: string]: Validator;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Validators: ValidatorIndex = {
|
|
9
|
+
NonEmptyString: {
|
|
10
|
+
instructions: '',
|
|
11
|
+
test: (input: string) => {
|
|
12
|
+
if (input.length !== 0) {
|
|
13
|
+
return {
|
|
14
|
+
status: Status.ok,
|
|
15
|
+
msg: '',
|
|
16
|
+
};
|
|
17
|
+
} else {
|
|
18
|
+
return {
|
|
19
|
+
status: Status.error,
|
|
20
|
+
msg: 'Input cannot be empty',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { DataShape } from './interfaces/DataShape.js';
|
|
2
|
+
|
|
3
|
+
export enum Status {
|
|
4
|
+
awaitingResponse = 'awaiting',
|
|
5
|
+
ok = 'ok',
|
|
6
|
+
warning = 'warning',
|
|
7
|
+
error = 'error',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IServerResponse {
|
|
11
|
+
errorText?: string;
|
|
12
|
+
status: Status;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IServerRequest {
|
|
17
|
+
type: ServerRequestType;
|
|
18
|
+
user: string;
|
|
19
|
+
response: IServerResponse | null;
|
|
20
|
+
/**
|
|
21
|
+
* milliseconds to wait for a request to complete before timing out
|
|
22
|
+
*/
|
|
23
|
+
timeout?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CreateClassroom extends IServerRequest {
|
|
27
|
+
type: ServerRequestType.CREATE_CLASSROOM;
|
|
28
|
+
data: ClassroomConfig;
|
|
29
|
+
response: {
|
|
30
|
+
status: Status;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
joincode: string;
|
|
33
|
+
uuid: string;
|
|
34
|
+
} | null;
|
|
35
|
+
}
|
|
36
|
+
export interface DeleteClassroom extends IServerRequest {
|
|
37
|
+
type: ServerRequestType.DELETE_CLASSROOM;
|
|
38
|
+
classID: string;
|
|
39
|
+
}
|
|
40
|
+
export interface JoinClassroom extends IServerRequest {
|
|
41
|
+
type: ServerRequestType.JOIN_CLASSROOM;
|
|
42
|
+
user: string;
|
|
43
|
+
data: {
|
|
44
|
+
joinCode: string;
|
|
45
|
+
registerAs: 'student' | 'teacher' | 'aide' | 'admin';
|
|
46
|
+
user: string;
|
|
47
|
+
};
|
|
48
|
+
response: {
|
|
49
|
+
errorText?: string;
|
|
50
|
+
status: Status;
|
|
51
|
+
ok: boolean;
|
|
52
|
+
id_course: string;
|
|
53
|
+
course_name: string;
|
|
54
|
+
} | null;
|
|
55
|
+
}
|
|
56
|
+
export interface LeaveClassroom extends IServerRequest {
|
|
57
|
+
type: ServerRequestType.LEAVE_CLASSROOM;
|
|
58
|
+
data: {
|
|
59
|
+
classID: string;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type NamespacedDatashape = string; // ${course}.datashape.${datashape}
|
|
64
|
+
|
|
65
|
+
export interface DataShape55 {
|
|
66
|
+
// [ ] rename this to something else - disambiguate from DataShape in base-course
|
|
67
|
+
name: NamespacedDatashape;
|
|
68
|
+
questionTypes: PouchDB.Core.DocumentId[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type NamespacedQuestion = string; // ${course}.question.${question}
|
|
72
|
+
export interface QuestionType55 {
|
|
73
|
+
name: NamespacedQuestion;
|
|
74
|
+
viewList: string[];
|
|
75
|
+
dataShapeList: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ClassroomConfig {
|
|
79
|
+
students: string[];
|
|
80
|
+
teachers: string[];
|
|
81
|
+
name: string;
|
|
82
|
+
birthYear?: number;
|
|
83
|
+
classMeetingSchedule: string;
|
|
84
|
+
peerAssist: boolean;
|
|
85
|
+
joinCode: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* metadata about a defined course
|
|
90
|
+
*
|
|
91
|
+
* Note: `courseID` is generated server-side. It is not present on
|
|
92
|
+
* new courses at the time of writing, client-side, but always
|
|
93
|
+
* present (!) when a CourseConfig is retrieved from the database
|
|
94
|
+
*/
|
|
95
|
+
export interface CourseConfig {
|
|
96
|
+
courseID?: string;
|
|
97
|
+
name: string;
|
|
98
|
+
description: string;
|
|
99
|
+
public: boolean;
|
|
100
|
+
deleted: boolean;
|
|
101
|
+
creator: string;
|
|
102
|
+
admins: string[];
|
|
103
|
+
moderators: string[];
|
|
104
|
+
dataShapes: DataShape55[];
|
|
105
|
+
questionTypes: QuestionType55[];
|
|
106
|
+
disambiguator?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface CreateCourse extends IServerRequest {
|
|
110
|
+
type: ServerRequestType.CREATE_COURSE;
|
|
111
|
+
data: CourseConfig;
|
|
112
|
+
response: {
|
|
113
|
+
status: Status;
|
|
114
|
+
ok: boolean;
|
|
115
|
+
courseID: string;
|
|
116
|
+
} | null;
|
|
117
|
+
}
|
|
118
|
+
export interface DeleteCourse extends IServerRequest {
|
|
119
|
+
type: ServerRequestType.DELETE_COURSE;
|
|
120
|
+
courseID: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface AddCourseDataPayload {
|
|
124
|
+
courseID: string;
|
|
125
|
+
codeCourse: string;
|
|
126
|
+
shape: DataShape;
|
|
127
|
+
data: unknown;
|
|
128
|
+
author: string;
|
|
129
|
+
tags: string[];
|
|
130
|
+
uploads?: { [x: string]: PouchDB.Core.FullAttachment };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface AddCourseData extends IServerRequest {
|
|
134
|
+
type: ServerRequestType.ADD_COURSE_DATA;
|
|
135
|
+
data: AddCourseDataPayload;
|
|
136
|
+
response: {
|
|
137
|
+
status: Status;
|
|
138
|
+
ok: boolean;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type ServerRequest =
|
|
143
|
+
| CreateClassroom
|
|
144
|
+
| DeleteClassroom
|
|
145
|
+
| JoinClassroom
|
|
146
|
+
| LeaveClassroom
|
|
147
|
+
| CreateCourse
|
|
148
|
+
| DeleteCourse
|
|
149
|
+
| AddCourseData;
|
|
150
|
+
|
|
151
|
+
export enum ServerRequestType {
|
|
152
|
+
CREATE_CLASSROOM = 'CREATE_CLASSROOM',
|
|
153
|
+
DELETE_CLASSROOM = 'DELETE_CLASSROOM',
|
|
154
|
+
JOIN_CLASSROOM = 'JOIN_CLASSROOM',
|
|
155
|
+
LEAVE_CLASSROOM = 'LEAVE_CLASSROOM',
|
|
156
|
+
CREATE_COURSE = 'CREATE_COURSE',
|
|
157
|
+
DELETE_COURSE = 'DELETE_COURSE',
|
|
158
|
+
ADD_COURSE_DATA = 'ADD_COURSE_DATA',
|
|
159
|
+
}
|
package/tsconfig.json
ADDED