@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 ADDED
@@ -0,0 +1 @@
1
+ # common
@@ -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,2 @@
1
+ export * from './DataShapeNames.js';
2
+ export * from './FieldType.js';
@@ -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,7 @@
1
+ // eslint-disable-next-line
2
+ export interface Answer {}
3
+
4
+ export interface RadioMultipleChoiceAnswer extends Answer {
5
+ choiceList: string[];
6
+ selection: number;
7
+ }
@@ -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,6 @@
1
+ /**
2
+ * A tagger is *some function* that produces a list of tags - ie, strings.
3
+ */
4
+ export interface Tagger {
5
+ (x: unknown): string[];
6
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export * from './AnswerInterfaces.js';
2
+ export * from './DataShape.js';
3
+ export * from './FieldDefinition.js';
4
+ export * from './Tagger.js';
5
+ export * from './Validator.js';
6
+ export * from './ViewData.js';
package/src/logshim.ts ADDED
@@ -0,0 +1,4 @@
1
+ export const log = (...args: unknown[]): void => {
2
+ // eslint-disable-next-line no-console
3
+ console.log(...args);
4
+ };
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "outDir": "dist",
7
+ "rootDir": "src"
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist"]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist-esm",
7
+ "rootDir": "src"
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist", "dist-esm"]
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "module": "NodeNext",
7
+ "moduleResolution": "NodeNext"
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist"]
11
+ }