@vue-skuilder/db 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/core/index.d.mts +3 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +7906 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/index.mjs +7886 -0
- package/dist/core/index.mjs.map +1 -0
- package/dist/index-QMtzQI65.d.mts +734 -0
- package/dist/index-QMtzQI65.d.ts +734 -0
- package/dist/index.d.mts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +8726 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +8699 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.mjs +20 -0
- package/package.json +47 -0
- package/src/core/bulkImport/cardProcessor.ts +165 -0
- package/src/core/bulkImport/index.ts +2 -0
- package/src/core/bulkImport/types.ts +27 -0
- package/src/core/index.ts +9 -0
- package/src/core/interfaces/adminDB.ts +27 -0
- package/src/core/interfaces/classroomDB.ts +75 -0
- package/src/core/interfaces/contentSource.ts +64 -0
- package/src/core/interfaces/courseDB.ts +139 -0
- package/src/core/interfaces/dataLayerProvider.ts +46 -0
- package/src/core/interfaces/index.ts +7 -0
- package/src/core/interfaces/navigationStrategyManager.ts +46 -0
- package/src/core/interfaces/userDB.ts +183 -0
- package/src/core/navigators/elo.ts +76 -0
- package/src/core/navigators/index.ts +57 -0
- package/src/core/readme.md +9 -0
- package/src/core/types/contentNavigationStrategy.ts +21 -0
- package/src/core/types/db.ts +7 -0
- package/src/core/types/types-legacy.ts +155 -0
- package/src/core/types/user.ts +70 -0
- package/src/core/util/index.ts +42 -0
- package/src/factory.ts +86 -0
- package/src/impl/pouch/PouchDataLayerProvider.ts +102 -0
- package/src/impl/pouch/adminDB.ts +91 -0
- package/src/impl/pouch/auth.ts +48 -0
- package/src/impl/pouch/classroomDB.ts +306 -0
- package/src/impl/pouch/clientCache.ts +19 -0
- package/src/impl/pouch/courseAPI.ts +245 -0
- package/src/impl/pouch/courseDB.ts +772 -0
- package/src/impl/pouch/courseLookupDB.ts +135 -0
- package/src/impl/pouch/index.ts +235 -0
- package/src/impl/pouch/pouchdb-setup.ts +16 -0
- package/src/impl/pouch/types.ts +7 -0
- package/src/impl/pouch/updateQueue.ts +89 -0
- package/src/impl/pouch/user-course-relDB.ts +73 -0
- package/src/impl/pouch/userDB.ts +1097 -0
- package/src/index.ts +8 -0
- package/src/study/SessionController.ts +401 -0
- package/src/study/SpacedRepetition.ts +128 -0
- package/src/study/getCardDataShape.ts +34 -0
- package/src/study/index.ts +2 -0
- package/src/util/Loggable.ts +11 -0
- package/src/util/index.ts +1 -0
- package/src/util/logger.ts +55 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +17 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { CourseDBInterface, CourseInfo, CoursesDBInterface, UserDBInterface } from '@/core';
|
|
2
|
+
import { ScheduledCard } from '@/core/types/user';
|
|
3
|
+
import {
|
|
4
|
+
CourseConfig,
|
|
5
|
+
CourseElo,
|
|
6
|
+
DataShape,
|
|
7
|
+
EloToNumber,
|
|
8
|
+
Status,
|
|
9
|
+
blankCourseElo,
|
|
10
|
+
toCourseElo,
|
|
11
|
+
} from '@vue-skuilder/common';
|
|
12
|
+
import _ from 'lodash';
|
|
13
|
+
import { filterAllDocsByPrefix, getCourseDB, getCourseDoc, getCourseDocs } from '.';
|
|
14
|
+
import {
|
|
15
|
+
StudyContentSource,
|
|
16
|
+
StudySessionItem,
|
|
17
|
+
StudySessionNewItem,
|
|
18
|
+
StudySessionReviewItem,
|
|
19
|
+
} from '../../core/interfaces/contentSource';
|
|
20
|
+
import { CardData, DocType, SkuilderCourseData, Tag, TagStub } from '../../core/types/types-legacy';
|
|
21
|
+
import { logger } from '../../util/logger';
|
|
22
|
+
import { GET_CACHED } from './clientCache';
|
|
23
|
+
import { addNote55, addTagToCard, getCredentialledCourseConfig, getTagID } from './courseAPI';
|
|
24
|
+
import { DataLayerResult } from '@/core/types/db';
|
|
25
|
+
import { PouchError } from './types';
|
|
26
|
+
import CourseLookup from './courseLookupDB';
|
|
27
|
+
import { ContentNavigationStrategyData } from '@/core/types/contentNavigationStrategy';
|
|
28
|
+
import { ContentNavigator, Navigators } from '@/core/navigators';
|
|
29
|
+
|
|
30
|
+
export class CoursesDB implements CoursesDBInterface {
|
|
31
|
+
_courseIDs: string[] | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(courseIDs?: string[]) {
|
|
34
|
+
if (courseIDs && courseIDs.length > 0) {
|
|
35
|
+
this._courseIDs = courseIDs;
|
|
36
|
+
} else {
|
|
37
|
+
this._courseIDs = undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public async getCourseList(): Promise<CourseConfig[]> {
|
|
42
|
+
let crsList = await CourseLookup.allCourses();
|
|
43
|
+
logger.debug(`AllCourses: ${crsList.map((c) => c.name + ', ' + c._id + '\n\t')}`);
|
|
44
|
+
if (this._courseIDs) {
|
|
45
|
+
crsList = crsList.filter((c) => this._courseIDs!.includes(c._id));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logger.debug(`AllCourses.filtered: ${crsList.map((c) => c.name + ', ' + c._id + '\n\t')}`);
|
|
49
|
+
|
|
50
|
+
const cfgs = await Promise.all(
|
|
51
|
+
crsList.map(async (c) => {
|
|
52
|
+
try {
|
|
53
|
+
const cfg = await getCredentialledCourseConfig(c._id);
|
|
54
|
+
logger.debug(`Found cfg: ${JSON.stringify(cfg)}`);
|
|
55
|
+
return cfg;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
logger.warn(`Error fetching cfg for course ${c.name}, ${c._id}: ${e}`);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
return cfgs.filter((c) => !!c);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getCourseConfig(courseId: string): Promise<CourseConfig> {
|
|
66
|
+
if (this._courseIDs && this._courseIDs.length && !this._courseIDs.includes(courseId)) {
|
|
67
|
+
throw new Error(`Course ${courseId} not in course list`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cfg = await getCredentialledCourseConfig(courseId);
|
|
71
|
+
if (cfg === undefined) {
|
|
72
|
+
throw new Error(`Error fetching cfg for course ${courseId}`);
|
|
73
|
+
} else {
|
|
74
|
+
return cfg;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public async disambiguateCourse(courseId: string, disambiguator: string): Promise<void> {
|
|
79
|
+
await CourseLookup.updateDisambiguator(courseId, disambiguator);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function randIntWeightedTowardZero(n: number) {
|
|
84
|
+
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
88
|
+
// private log(msg: string): void {
|
|
89
|
+
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
90
|
+
// }
|
|
91
|
+
|
|
92
|
+
private db: PouchDB.Database;
|
|
93
|
+
private id: string;
|
|
94
|
+
private _getCurrentUser: () => Promise<UserDBInterface>;
|
|
95
|
+
|
|
96
|
+
constructor(id: string, userLookup: () => Promise<UserDBInterface>) {
|
|
97
|
+
this.id = id;
|
|
98
|
+
this.db = getCourseDB(this.id);
|
|
99
|
+
this._getCurrentUser = userLookup;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public getCourseID(): string {
|
|
103
|
+
return this.id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public async getCourseInfo(): Promise<CourseInfo> {
|
|
107
|
+
const cardCount = (
|
|
108
|
+
await this.db.find({
|
|
109
|
+
selector: {
|
|
110
|
+
docType: DocType.CARD,
|
|
111
|
+
},
|
|
112
|
+
limit: 1000,
|
|
113
|
+
})
|
|
114
|
+
).docs.length;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
cardCount,
|
|
118
|
+
registeredUsers: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public async getInexperiencedCards(limit: number = 2) {
|
|
123
|
+
return (
|
|
124
|
+
await this.db.query('cardsByInexperience', {
|
|
125
|
+
limit,
|
|
126
|
+
})
|
|
127
|
+
).rows.map((r) => {
|
|
128
|
+
const ret = {
|
|
129
|
+
courseId: this.id,
|
|
130
|
+
cardId: r.id,
|
|
131
|
+
count: r.key,
|
|
132
|
+
elo: r.value,
|
|
133
|
+
};
|
|
134
|
+
return ret;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public async getCardsByEloLimits(
|
|
139
|
+
options: {
|
|
140
|
+
low: number;
|
|
141
|
+
high: number;
|
|
142
|
+
limit: number;
|
|
143
|
+
page: number;
|
|
144
|
+
} = {
|
|
145
|
+
low: 0,
|
|
146
|
+
high: Number.MIN_SAFE_INTEGER,
|
|
147
|
+
limit: 25,
|
|
148
|
+
page: 0,
|
|
149
|
+
}
|
|
150
|
+
) {
|
|
151
|
+
return (
|
|
152
|
+
await this.db.query('elo', {
|
|
153
|
+
startkey: options.low,
|
|
154
|
+
endkey: options.high,
|
|
155
|
+
limit: options.limit,
|
|
156
|
+
skip: options.limit * options.page,
|
|
157
|
+
})
|
|
158
|
+
).rows.map((r) => {
|
|
159
|
+
return `${this.id}-${r.id}-${r.key}`;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
public async getCardEloData(id: string[]): Promise<CourseElo[]> {
|
|
163
|
+
const docs = await this.db.allDocs<CardData>({
|
|
164
|
+
keys: id,
|
|
165
|
+
include_docs: true,
|
|
166
|
+
});
|
|
167
|
+
const ret: CourseElo[] = [];
|
|
168
|
+
docs.rows.forEach((r) => {
|
|
169
|
+
// [ ] remove these ts-ignore directives.
|
|
170
|
+
if (isSuccessRow(r)) {
|
|
171
|
+
if (r.doc && r.doc.elo) {
|
|
172
|
+
ret.push(toCourseElo(r.doc.elo));
|
|
173
|
+
} else {
|
|
174
|
+
logger.warn('no elo data for card: ' + r.id);
|
|
175
|
+
ret.push(blankCourseElo());
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
logger.warn('no elo data for card: ' + JSON.stringify(r));
|
|
179
|
+
ret.push(blankCourseElo());
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return ret;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Returns the lowest and highest `global` ELO ratings in the course
|
|
187
|
+
*/
|
|
188
|
+
public async getELOBounds() {
|
|
189
|
+
const [low, high] = await Promise.all([
|
|
190
|
+
(
|
|
191
|
+
await this.db.query('elo', {
|
|
192
|
+
startkey: 0,
|
|
193
|
+
limit: 1,
|
|
194
|
+
include_docs: false,
|
|
195
|
+
})
|
|
196
|
+
).rows[0].key,
|
|
197
|
+
(
|
|
198
|
+
await this.db.query('elo', {
|
|
199
|
+
limit: 1,
|
|
200
|
+
descending: true,
|
|
201
|
+
startkey: 100_000,
|
|
202
|
+
})
|
|
203
|
+
).rows[0].key,
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
low: low,
|
|
208
|
+
high: high,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public async removeCard(id: string) {
|
|
213
|
+
const doc = await this.db.get<CardData>(id);
|
|
214
|
+
if (!doc.docType || !(doc.docType === DocType.CARD)) {
|
|
215
|
+
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
216
|
+
}
|
|
217
|
+
// TODO: remove card from tags lists (getTagsByCards)
|
|
218
|
+
return this.db.remove(doc);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public async getCardDisplayableDataIDs(id: string[]) {
|
|
222
|
+
logger.debug(id.join(', '));
|
|
223
|
+
const cards = await this.db.allDocs<CardData>({
|
|
224
|
+
keys: id,
|
|
225
|
+
include_docs: true,
|
|
226
|
+
});
|
|
227
|
+
const ret: { [card: string]: string[] } = {};
|
|
228
|
+
cards.rows.forEach((r) => {
|
|
229
|
+
if (isSuccessRow(r)) {
|
|
230
|
+
ret[r.id] = r.doc!.id_displayable_data;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await Promise.all(
|
|
235
|
+
cards.rows.map((r) => {
|
|
236
|
+
return async () => {
|
|
237
|
+
if (isSuccessRow(r)) {
|
|
238
|
+
ret[r.id] = r.doc!.id_displayable_data;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return ret;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async getCardsByELO(elo: number, cardLimit?: number) {
|
|
248
|
+
elo = parseInt(elo as any);
|
|
249
|
+
const limit = cardLimit ? cardLimit : 25;
|
|
250
|
+
|
|
251
|
+
const below: PouchDB.Query.Response<object> = await this.db.query('elo', {
|
|
252
|
+
limit: Math.ceil(limit / 2),
|
|
253
|
+
startkey: elo,
|
|
254
|
+
descending: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const aboveLimit = limit - below.rows.length;
|
|
258
|
+
|
|
259
|
+
const above: PouchDB.Query.Response<object> = await this.db.query('elo', {
|
|
260
|
+
limit: aboveLimit,
|
|
261
|
+
startkey: elo + 1,
|
|
262
|
+
});
|
|
263
|
+
// console.log(JSON.stringify(below));
|
|
264
|
+
|
|
265
|
+
let cards = below.rows;
|
|
266
|
+
cards = cards.concat(above.rows);
|
|
267
|
+
|
|
268
|
+
const ret = cards
|
|
269
|
+
.sort((a, b) => {
|
|
270
|
+
const s = Math.abs(a.key - elo) - Math.abs(b.key - elo);
|
|
271
|
+
if (s === 0) {
|
|
272
|
+
return Math.random() - 0.5;
|
|
273
|
+
} else {
|
|
274
|
+
return s;
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
.map((c) => `${this.id}-${c.id}-${c.key}`);
|
|
278
|
+
|
|
279
|
+
const str = `below:\n${below.rows.map((r) => `\t${r.id}-${r.key}\n`)}
|
|
280
|
+
|
|
281
|
+
above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
282
|
+
|
|
283
|
+
logger.debug(`Getting ${limit} cards centered around elo: ${elo}:\n\n` + str);
|
|
284
|
+
|
|
285
|
+
return ret;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async getCourseConfig(): Promise<CourseConfig> {
|
|
289
|
+
const ret = await getCredentialledCourseConfig(this.id);
|
|
290
|
+
if (ret) {
|
|
291
|
+
return ret;
|
|
292
|
+
} else {
|
|
293
|
+
throw new Error(`Course config not found for course ID: ${this.id}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async updateCourseConfig(cfg: CourseConfig): Promise<PouchDB.Core.Response> {
|
|
298
|
+
logger.debug(`Updating: ${JSON.stringify(cfg)}`);
|
|
299
|
+
// write both to the course DB:
|
|
300
|
+
try {
|
|
301
|
+
return await updateCredentialledCourseConfig(this.id, cfg);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
logger.error(`Error updating course config in course DB: ${error}`);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async updateCardElo(cardId: string, elo: CourseElo) {
|
|
309
|
+
const ret = await updateCardElo(this.id, cardId, elo);
|
|
310
|
+
if (ret) {
|
|
311
|
+
return ret;
|
|
312
|
+
} else {
|
|
313
|
+
throw new Error(`Failed to update card elo for card ID: ${cardId}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getAppliedTags(cardId: string): Promise<PouchDB.Query.Response<TagStub>> {
|
|
318
|
+
const ret = await getAppliedTags(this.id, cardId);
|
|
319
|
+
if (ret) {
|
|
320
|
+
return ret;
|
|
321
|
+
} else {
|
|
322
|
+
throw new Error(`Failed to find tags for card ${this.id}-${cardId}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async addTagToCard(
|
|
327
|
+
cardId: string,
|
|
328
|
+
tagId: string,
|
|
329
|
+
updateELO?: boolean
|
|
330
|
+
): Promise<PouchDB.Core.Response> {
|
|
331
|
+
return await addTagToCard(this.id, cardId, tagId, updateELO);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async removeTagFromCard(cardId: string, tagId: string): Promise<PouchDB.Core.Response> {
|
|
335
|
+
return await removeTagFromCard(this.id, cardId, tagId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async createTag(name: string): Promise<PouchDB.Core.Response> {
|
|
339
|
+
return await createTag(this.id, name);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async getTag(tagId: string): Promise<PouchDB.Core.GetMeta & PouchDB.Core.Document<Tag>> {
|
|
343
|
+
return await getTag(this.id, tagId);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async updateTag(tag: Tag): Promise<PouchDB.Core.Response> {
|
|
347
|
+
if (tag.course !== this.id) {
|
|
348
|
+
throw new Error(`Tag ${JSON.stringify(tag)} does not belong to course ${this.id}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return await updateTag(tag);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async getCourseTagStubs(): Promise<PouchDB.Core.AllDocsResponse<Tag>> {
|
|
355
|
+
return getCourseTagStubs(this.id);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async addNote(
|
|
359
|
+
codeCourse: string,
|
|
360
|
+
shape: DataShape,
|
|
361
|
+
data: unknown,
|
|
362
|
+
author: string,
|
|
363
|
+
tags: string[],
|
|
364
|
+
uploads?: { [key: string]: PouchDB.Core.FullAttachment },
|
|
365
|
+
elo: CourseElo = blankCourseElo()
|
|
366
|
+
): Promise<DataLayerResult> {
|
|
367
|
+
try {
|
|
368
|
+
const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
|
|
369
|
+
if (resp.ok) {
|
|
370
|
+
// Check if card creation failed (property added by addNote55)
|
|
371
|
+
if ((resp as any).cardCreationFailed) {
|
|
372
|
+
logger.warn(
|
|
373
|
+
`[courseDB.addNote] Note added but card creation failed: ${
|
|
374
|
+
(resp as any).cardCreationError
|
|
375
|
+
}`
|
|
376
|
+
);
|
|
377
|
+
return {
|
|
378
|
+
status: Status.error,
|
|
379
|
+
message: `Note was added but no cards were created: ${(resp as any).cardCreationError}`,
|
|
380
|
+
id: resp.id,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
status: Status.ok,
|
|
385
|
+
message: '',
|
|
386
|
+
id: resp.id,
|
|
387
|
+
};
|
|
388
|
+
} else {
|
|
389
|
+
return {
|
|
390
|
+
status: Status.error,
|
|
391
|
+
message: 'Unexpected error adding note',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
} catch (e) {
|
|
395
|
+
const err = e as PouchDB.Core.Error;
|
|
396
|
+
logger.error(
|
|
397
|
+
`[addNote] error ${err.name}\n\treason: ${err.reason}\n\tmessage: ${err.message}`
|
|
398
|
+
);
|
|
399
|
+
return {
|
|
400
|
+
status: Status.error,
|
|
401
|
+
message: `Error adding note to course. ${(e as PouchError).reason || err.message}`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async getCourseDoc<T extends SkuilderCourseData>(
|
|
407
|
+
id: string,
|
|
408
|
+
options?: PouchDB.Core.GetOptions
|
|
409
|
+
): Promise<PouchDB.Core.GetMeta & PouchDB.Core.Document<T>> {
|
|
410
|
+
return await getCourseDoc(this.id, id, options);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async getCourseDocs<T extends SkuilderCourseData>(
|
|
414
|
+
ids: string[],
|
|
415
|
+
options: PouchDB.Core.AllDocsOptions = {}
|
|
416
|
+
): Promise<PouchDB.Core.AllDocsWithKeysResponse<{} & T>> {
|
|
417
|
+
return await getCourseDocs(this.id, ids, options);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
////////////////////////////////////
|
|
421
|
+
// NavigationStrategyManager implementation
|
|
422
|
+
////////////////////////////////////
|
|
423
|
+
|
|
424
|
+
getNavigationStrategy(id: string): Promise<ContentNavigationStrategyData> {
|
|
425
|
+
logger.debug(`[courseDB] Getting navigation strategy: ${id}`);
|
|
426
|
+
// For now, just return the ELO strategy regardless of the ID
|
|
427
|
+
const strategy: ContentNavigationStrategyData = {
|
|
428
|
+
id: 'ELO',
|
|
429
|
+
docType: DocType.NAVIGATION_STRATEGY,
|
|
430
|
+
name: 'ELO',
|
|
431
|
+
description: 'ELO-based navigation strategy for ordering content by difficulty',
|
|
432
|
+
implementingClass: Navigators.ELO,
|
|
433
|
+
course: this.id,
|
|
434
|
+
serializedData: '', // serde is a noop for ELO navigator.
|
|
435
|
+
};
|
|
436
|
+
return Promise.resolve(strategy);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
getAllNavigationStrategies(): Promise<ContentNavigationStrategyData[]> {
|
|
440
|
+
logger.debug('[courseDB] Returning hard-coded navigation strategies');
|
|
441
|
+
const strategies: ContentNavigationStrategyData[] = [
|
|
442
|
+
{
|
|
443
|
+
id: 'ELO',
|
|
444
|
+
docType: DocType.NAVIGATION_STRATEGY,
|
|
445
|
+
name: 'ELO',
|
|
446
|
+
description: 'ELO-based navigation strategy for ordering content by difficulty',
|
|
447
|
+
implementingClass: Navigators.ELO,
|
|
448
|
+
course: this.id,
|
|
449
|
+
serializedData: '', // serde is a noop for ELO navigator.
|
|
450
|
+
},
|
|
451
|
+
];
|
|
452
|
+
return Promise.resolve(strategies);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
addNavigationStrategy(data: ContentNavigationStrategyData): Promise<void> {
|
|
456
|
+
logger.debug(`[courseDB] Adding navigation strategy: ${data.id}`);
|
|
457
|
+
// For now, just log the data and return success
|
|
458
|
+
logger.debug(JSON.stringify(data));
|
|
459
|
+
return Promise.resolve();
|
|
460
|
+
}
|
|
461
|
+
updateNavigationStrategy(id: string, data: ContentNavigationStrategyData): Promise<void> {
|
|
462
|
+
logger.debug(`[courseDB] Updating navigation strategy: ${id}`);
|
|
463
|
+
// For now, just log the data and return success
|
|
464
|
+
logger.debug(JSON.stringify(data));
|
|
465
|
+
return Promise.resolve();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData> {
|
|
469
|
+
logger.warn(`Returning hard-coded default ELO navigator`);
|
|
470
|
+
const ret: ContentNavigationStrategyData = {
|
|
471
|
+
id: 'ELO',
|
|
472
|
+
docType: DocType.NAVIGATION_STRATEGY,
|
|
473
|
+
name: 'ELO',
|
|
474
|
+
description: 'ELO-based navigation strategy',
|
|
475
|
+
implementingClass: Navigators.ELO,
|
|
476
|
+
course: this.id,
|
|
477
|
+
serializedData: '', // serde is a noop for ELO navigator.
|
|
478
|
+
};
|
|
479
|
+
return Promise.resolve(ret);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
////////////////////////////////////
|
|
483
|
+
// END NavigationStrategyManager implementation
|
|
484
|
+
////////////////////////////////////
|
|
485
|
+
|
|
486
|
+
////////////////////////////////////
|
|
487
|
+
// StudyContentSource implementation
|
|
488
|
+
////////////////////////////////////
|
|
489
|
+
|
|
490
|
+
public async getNewCards(limit: number = 99): Promise<StudySessionNewItem[]> {
|
|
491
|
+
const u = await this._getCurrentUser();
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const strategy = await this.surfaceNavigationStrategy();
|
|
495
|
+
const navigator = await ContentNavigator.create(u, this, strategy);
|
|
496
|
+
return navigator.getNewCards(limit);
|
|
497
|
+
} catch (e) {
|
|
498
|
+
logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
|
|
499
|
+
throw e;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
public async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
504
|
+
const u = await this._getCurrentUser();
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const strategy = await this.surfaceNavigationStrategy();
|
|
508
|
+
const navigator = await ContentNavigator.create(u, this, strategy);
|
|
509
|
+
return navigator.getPendingReviews();
|
|
510
|
+
} catch (e) {
|
|
511
|
+
logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
|
|
512
|
+
throw e;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
public async getCardsCenteredAtELO(
|
|
517
|
+
options: {
|
|
518
|
+
limit: number;
|
|
519
|
+
elo: 'user' | 'random' | number;
|
|
520
|
+
} = {
|
|
521
|
+
limit: 99,
|
|
522
|
+
elo: 'user',
|
|
523
|
+
},
|
|
524
|
+
filter?: (a: string) => boolean
|
|
525
|
+
): Promise<StudySessionItem[]> {
|
|
526
|
+
let targetElo: number;
|
|
527
|
+
|
|
528
|
+
if (options.elo === 'user') {
|
|
529
|
+
const u = await this._getCurrentUser();
|
|
530
|
+
|
|
531
|
+
targetElo = -1;
|
|
532
|
+
try {
|
|
533
|
+
const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
|
|
534
|
+
return c.courseID === this.id;
|
|
535
|
+
})!;
|
|
536
|
+
targetElo = EloToNumber(courseDoc.elo);
|
|
537
|
+
} catch {
|
|
538
|
+
targetElo = 1000;
|
|
539
|
+
}
|
|
540
|
+
} else if (options.elo === 'random') {
|
|
541
|
+
const bounds = await GET_CACHED(`elo-bounds-${this.id}`, () => this.getELOBounds());
|
|
542
|
+
targetElo = Math.round(bounds.low + Math.random() * (bounds.high - bounds.low));
|
|
543
|
+
// console.log(`Picked ${targetElo} from [${bounds.low}, ${bounds.high}]`);
|
|
544
|
+
} else {
|
|
545
|
+
targetElo = options.elo;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let cards: string[] = [];
|
|
549
|
+
let mult: number = 4;
|
|
550
|
+
let previousCount: number = -1;
|
|
551
|
+
let newCount: number = 0;
|
|
552
|
+
|
|
553
|
+
while (cards.length < options.limit && newCount !== previousCount) {
|
|
554
|
+
cards = await this.getCardsByELO(targetElo, mult * options.limit);
|
|
555
|
+
previousCount = newCount;
|
|
556
|
+
newCount = cards.length;
|
|
557
|
+
|
|
558
|
+
logger.debug(`Found ${cards.length} elo neighbor cards...`);
|
|
559
|
+
|
|
560
|
+
if (filter) {
|
|
561
|
+
cards = cards.filter(filter);
|
|
562
|
+
logger.debug(`Filtered to ${cards.length} cards...`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
mult *= 2;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const selectedCards: string[] = [];
|
|
569
|
+
|
|
570
|
+
while (selectedCards.length < options.limit && cards.length > 0) {
|
|
571
|
+
const index = randIntWeightedTowardZero(cards.length);
|
|
572
|
+
const card = cards.splice(index, 1)[0];
|
|
573
|
+
selectedCards.push(card);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return selectedCards.map((c) => {
|
|
577
|
+
const split = c.split('-');
|
|
578
|
+
return {
|
|
579
|
+
courseID: this.id,
|
|
580
|
+
cardID: split[1],
|
|
581
|
+
qualifiedID: `${split[0]}-${split[1]}`,
|
|
582
|
+
contentSourceType: 'course',
|
|
583
|
+
contentSourceID: this.id,
|
|
584
|
+
status: 'new',
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Returns a list of registered datashapes for the specified
|
|
592
|
+
* course.
|
|
593
|
+
* @param courseID The ID of the course
|
|
594
|
+
*/
|
|
595
|
+
export async function getCourseDataShapes(courseID: string) {
|
|
596
|
+
const cfg = await getCredentialledCourseConfig(courseID);
|
|
597
|
+
return cfg!.dataShapes;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export async function getCredentialledDataShapes(courseID: string) {
|
|
601
|
+
const cfg = await getCredentialledCourseConfig(courseID);
|
|
602
|
+
|
|
603
|
+
return cfg.dataShapes;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function getCourseQuestionTypes(courseID: string) {
|
|
607
|
+
const cfg = await getCredentialledCourseConfig(courseID);
|
|
608
|
+
return cfg!.questionTypes;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// todo: this is actually returning full tag docs now.
|
|
612
|
+
// - performance issue when tags have lots of
|
|
613
|
+
// applied docs
|
|
614
|
+
// - will require a computed couch DB view
|
|
615
|
+
export async function getCourseTagStubs(
|
|
616
|
+
courseID: string
|
|
617
|
+
): Promise<PouchDB.Core.AllDocsResponse<Tag>> {
|
|
618
|
+
logger.debug(`Getting tag stubs for course: ${courseID}`);
|
|
619
|
+
const stubs = await filterAllDocsByPrefix<Tag>(
|
|
620
|
+
getCourseDB(courseID),
|
|
621
|
+
DocType.TAG.valueOf() + '-'
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
stubs.rows.forEach((row) => {
|
|
625
|
+
logger.debug(`\tTag stub for doc: ${row.id}`);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
return stubs;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function deleteTag(courseID: string, tagName: string) {
|
|
632
|
+
tagName = getTagID(tagName);
|
|
633
|
+
const courseDB = getCourseDB(courseID);
|
|
634
|
+
const doc = await courseDB.get<Tag>(DocType.TAG.valueOf() + '-' + tagName);
|
|
635
|
+
const resp = await courseDB.remove(doc);
|
|
636
|
+
return resp;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export async function createTag(courseID: string, tagName: string) {
|
|
640
|
+
logger.debug(`Creating tag: ${tagName}...`);
|
|
641
|
+
const tagID = getTagID(tagName);
|
|
642
|
+
const courseDB = getCourseDB(courseID);
|
|
643
|
+
const resp = await courseDB.put<Tag>({
|
|
644
|
+
course: courseID,
|
|
645
|
+
docType: DocType.TAG,
|
|
646
|
+
name: tagName,
|
|
647
|
+
snippet: '',
|
|
648
|
+
taggedCards: [],
|
|
649
|
+
wiki: '',
|
|
650
|
+
_id: tagID,
|
|
651
|
+
});
|
|
652
|
+
return resp;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function updateTag(tag: Tag) {
|
|
656
|
+
const prior = await getTag(tag.course, tag.name);
|
|
657
|
+
return await getCourseDB(tag.course).put<Tag>({
|
|
658
|
+
...tag,
|
|
659
|
+
_rev: prior._rev,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export async function getTag(courseID: string, tagName: string) {
|
|
664
|
+
const tagID = getTagID(tagName);
|
|
665
|
+
const courseDB = getCourseDB(courseID);
|
|
666
|
+
return courseDB.get<Tag>(tagID);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export async function removeTagFromCard(courseID: string, cardID: string, tagID: string) {
|
|
670
|
+
// todo: possible future perf. hit if tags have large #s of taggedCards.
|
|
671
|
+
// In this case, should be converted to a server-request
|
|
672
|
+
tagID = getTagID(tagID);
|
|
673
|
+
const courseDB = getCourseDB(courseID);
|
|
674
|
+
const tag = await courseDB.get<Tag>(tagID);
|
|
675
|
+
_.remove(tag.taggedCards, (taggedID) => {
|
|
676
|
+
return cardID === taggedID;
|
|
677
|
+
});
|
|
678
|
+
return courseDB.put<Tag>(tag);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Returns an array of ancestor tag IDs, where:
|
|
683
|
+
* return[0] = parent,
|
|
684
|
+
* return[1] = grandparent,
|
|
685
|
+
* return[2] = great grandparent,
|
|
686
|
+
* etc.
|
|
687
|
+
*
|
|
688
|
+
* If ret is empty, the tag itself is a root
|
|
689
|
+
*/
|
|
690
|
+
export function getAncestorTagIDs(courseID: string, tagID: string): string[] {
|
|
691
|
+
tagID = getTagID(tagID);
|
|
692
|
+
const split = tagID.split('>');
|
|
693
|
+
if (split.length === 1) {
|
|
694
|
+
return [];
|
|
695
|
+
} else {
|
|
696
|
+
split.pop();
|
|
697
|
+
const parent = split.join('>');
|
|
698
|
+
return [parent].concat(getAncestorTagIDs(courseID, parent));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export async function getChildTagStubs(courseID: string, tagID: string) {
|
|
703
|
+
return await filterAllDocsByPrefix(getCourseDB(courseID), tagID + '>');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export async function getAppliedTags(id_course: string, id_card: string) {
|
|
707
|
+
const db = getCourseDB(id_course);
|
|
708
|
+
|
|
709
|
+
const result = await db.query<TagStub>('getTags', {
|
|
710
|
+
startkey: id_card,
|
|
711
|
+
endkey: id_card,
|
|
712
|
+
// include_docs: true
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// log(`getAppliedTags looked up: ${id_card}`);
|
|
716
|
+
// log(`getAppliedTags returning: ${JSON.stringify(result)}`);
|
|
717
|
+
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export async function updateCardElo(courseID: string, cardID: string, elo: CourseElo) {
|
|
722
|
+
if (elo) {
|
|
723
|
+
// checking against null, undefined, NaN
|
|
724
|
+
const cDB = getCourseDB(courseID);
|
|
725
|
+
const card = await cDB.get<CardData>(cardID);
|
|
726
|
+
logger.debug(`Replacing ${JSON.stringify(card.elo)} with ${JSON.stringify(elo)}`);
|
|
727
|
+
card.elo = elo;
|
|
728
|
+
return cDB.put(card); // race conditions - is it important? probably not (net-zero effect)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export async function updateCredentialledCourseConfig(courseID: string, config: CourseConfig) {
|
|
733
|
+
logger.debug(`Updating course config:
|
|
734
|
+
|
|
735
|
+
${JSON.stringify(config)}
|
|
736
|
+
`);
|
|
737
|
+
|
|
738
|
+
const db = getCourseDB(courseID);
|
|
739
|
+
const old = await getCredentialledCourseConfig(courseID);
|
|
740
|
+
|
|
741
|
+
return await db.put<CourseConfig>({
|
|
742
|
+
...config,
|
|
743
|
+
_rev: (old as any)._rev,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function isSuccessRow<T>(
|
|
748
|
+
row:
|
|
749
|
+
| {
|
|
750
|
+
key: PouchDB.Core.DocumentKey;
|
|
751
|
+
error: 'not_found';
|
|
752
|
+
}
|
|
753
|
+
| {
|
|
754
|
+
doc?: PouchDB.Core.ExistingDocument<PouchDB.Core.AllDocsMeta & T> | null | undefined;
|
|
755
|
+
id: PouchDB.Core.DocumentId;
|
|
756
|
+
key: PouchDB.Core.DocumentKey;
|
|
757
|
+
value: {
|
|
758
|
+
rev: PouchDB.Core.RevisionId;
|
|
759
|
+
deleted?: boolean | undefined;
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
): row is {
|
|
763
|
+
doc?: PouchDB.Core.ExistingDocument<PouchDB.Core.AllDocsMeta & T> | null | undefined;
|
|
764
|
+
id: PouchDB.Core.DocumentId;
|
|
765
|
+
key: PouchDB.Core.DocumentKey;
|
|
766
|
+
value: {
|
|
767
|
+
rev: PouchDB.Core.RevisionId;
|
|
768
|
+
deleted?: boolean | undefined;
|
|
769
|
+
};
|
|
770
|
+
} {
|
|
771
|
+
return 'doc' in row && row.doc !== null && row.doc !== undefined;
|
|
772
|
+
}
|