@mdsrs/core 0.0.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 +33 -0
- package/dist/deck-tree.d.ts +3 -0
- package/dist/deck-tree.d.ts.map +1 -0
- package/dist/deck-tree.js +56 -0
- package/dist/hash.d.ts +5 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/parse.d.ts +9 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +166 -0
- package/dist/queue.d.ts +9 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +38 -0
- package/dist/render.d.ts +4 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +30 -0
- package/dist/schedule.d.ts +12 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +75 -0
- package/dist/stats.d.ts +48 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +126 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @mdsrs/core
|
|
2
|
+
|
|
3
|
+
Core primitives for Markdown-native spaced repetition.
|
|
4
|
+
|
|
5
|
+
This package contains:
|
|
6
|
+
|
|
7
|
+
- `parseDeck` and `parseCollection` for `Q:/A:` and `C:` Markdown cards.
|
|
8
|
+
- `hashCardContent` and `hashCardFamily` for content-addressed card identity.
|
|
9
|
+
- `scheduleReview` for deterministic FSRS-style scheduling.
|
|
10
|
+
- `buildReviewQueue` for due-card ordering and cloze sibling burial.
|
|
11
|
+
- `buildDeckTree` for human-readable deck navigation.
|
|
12
|
+
|
|
13
|
+
## Example
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { buildReviewQueue, parseCollection, scheduleReview } from '@mdsrs/core';
|
|
17
|
+
|
|
18
|
+
const cards = parseCollection([
|
|
19
|
+
{
|
|
20
|
+
deckName: 'Example',
|
|
21
|
+
filePath: 'example.md',
|
|
22
|
+
text: 'Q: What is the source of truth?\nA: Markdown.'
|
|
23
|
+
}
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const queue = buildReviewQueue(cards, new Map());
|
|
27
|
+
const result = scheduleReview(queue[0]?.performance, 'good', new Date('2026-01-01T00:00:00.000Z'));
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Runtime note
|
|
31
|
+
|
|
32
|
+
`@mdsrs/core` currently uses Node's built-in `crypto` module for synchronous SHA-256 hashing.
|
|
33
|
+
The API is otherwise framework-agnostic and does not depend on a database, renderer, or web framework.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deck-tree.d.ts","sourceRoot":"","sources":["../src/deck-tree.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAyBrD,eAAO,MAAM,aAAa,GAAI,OAAO,IAAI,EAAE,KAAG,YAAY,EAyCzD,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const titleize = (value) => value
|
|
2
|
+
.split(/[-_\s]+/)
|
|
3
|
+
.filter(Boolean)
|
|
4
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
5
|
+
.join(' ');
|
|
6
|
+
const ensureNode = (nodes, path, fallbackName) => {
|
|
7
|
+
let node = nodes.get(path);
|
|
8
|
+
if (node)
|
|
9
|
+
return node;
|
|
10
|
+
node = {
|
|
11
|
+
path,
|
|
12
|
+
name: fallbackName,
|
|
13
|
+
cardCount: 0,
|
|
14
|
+
totalCardCount: 0,
|
|
15
|
+
children: []
|
|
16
|
+
};
|
|
17
|
+
nodes.set(path, node);
|
|
18
|
+
return node;
|
|
19
|
+
};
|
|
20
|
+
export const buildDeckTree = (cards) => {
|
|
21
|
+
const nodes = new Map();
|
|
22
|
+
nodes.set('', { path: '', name: 'All', cardCount: 0, totalCardCount: 0, children: [] });
|
|
23
|
+
for (const card of cards) {
|
|
24
|
+
const nodePath = card.nodePath;
|
|
25
|
+
const parts = nodePath.split('/').filter(Boolean);
|
|
26
|
+
for (let index = 0; index < parts.length; index++) {
|
|
27
|
+
const path = parts.slice(0, index + 1).join('/');
|
|
28
|
+
ensureNode(nodes, path, index === parts.length - 1 ? card.displayName : titleize(parts[index] ?? ''));
|
|
29
|
+
}
|
|
30
|
+
const node = ensureNode(nodes, nodePath, card.displayName);
|
|
31
|
+
node.name = card.displayName;
|
|
32
|
+
node.cardCount += 1;
|
|
33
|
+
for (let index = 0; index <= parts.length; index++) {
|
|
34
|
+
const ancestorPath = parts.slice(0, index).join('/');
|
|
35
|
+
const ancestor = nodes.get(ancestorPath);
|
|
36
|
+
if (ancestor)
|
|
37
|
+
ancestor.totalCardCount += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const node of nodes.values()) {
|
|
41
|
+
node.children = [];
|
|
42
|
+
}
|
|
43
|
+
for (const node of nodes.values()) {
|
|
44
|
+
if (!node.path)
|
|
45
|
+
continue;
|
|
46
|
+
const parentPath = node.path.split('/').slice(0, -1).join('/');
|
|
47
|
+
nodes.get(parentPath)?.children.push(node);
|
|
48
|
+
}
|
|
49
|
+
const sortNodes = (items) => {
|
|
50
|
+
items.sort((left, right) => left.name.localeCompare(right.name));
|
|
51
|
+
for (const item of items)
|
|
52
|
+
sortNodes(item.children);
|
|
53
|
+
return items;
|
|
54
|
+
};
|
|
55
|
+
return sortNodes(nodes.get('')?.children ?? []);
|
|
56
|
+
};
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CardContent } from './types.js';
|
|
2
|
+
export declare const hashText: (text: string) => string;
|
|
3
|
+
export declare const hashCardContent: (content: CardContent) => string;
|
|
4
|
+
export declare const hashCardFamily: (content: CardContent) => string | null;
|
|
5
|
+
//# sourceMappingURL=hash.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,WAAoD,CAAC;AAE1F,eAAO,MAAM,eAAe,GAAI,SAAS,WAAW,WAMnD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,SAAS,WAAW,kBACkB,CAAC"}
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const hashText = (text) => createHash('sha256').update(text).digest('hex');
|
|
3
|
+
export const hashCardContent = (content) => {
|
|
4
|
+
if (content.type === 'basic') {
|
|
5
|
+
return hashText(`Basic\0${content.question}\0${content.answer}`);
|
|
6
|
+
}
|
|
7
|
+
return hashText(`Cloze\0${content.text}\0${content.start}\0${content.end}`);
|
|
8
|
+
};
|
|
9
|
+
export const hashCardFamily = (content) => content.type === 'cloze' ? hashText(`Cloze\0${content.text}`) : null;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './deck-tree.js';
|
|
2
|
+
export * from './hash.js';
|
|
3
|
+
export * from './parse.js';
|
|
4
|
+
export * from './queue.js';
|
|
5
|
+
export * from './render.js';
|
|
6
|
+
export * from './schedule.js';
|
|
7
|
+
export * from './stats.js';
|
|
8
|
+
export * from './types.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
|
package/dist/index.js
ADDED
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Card, DeckSource } from './types.js';
|
|
2
|
+
export declare class MdsrsParseError extends Error {
|
|
3
|
+
readonly filePath: string;
|
|
4
|
+
readonly line: number;
|
|
5
|
+
constructor(message: string, filePath: string, line: number);
|
|
6
|
+
}
|
|
7
|
+
export declare const parseDeck: (source: DeckSource) => Card[];
|
|
8
|
+
export declare const parseCollection: (sources: DeckSource[]) => Card[];
|
|
9
|
+
//# sourceMappingURL=parse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAe,UAAU,EAAE,MAAM,YAAY,CAAC;AAEhE,qBAAa,eAAgB,SAAQ,KAAK;IAGxC,QAAQ,CAAC,QAAQ,EAAE,MAAM;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBAFrB,OAAO,EAAE,MAAM,EACN,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM;CAItB;AA6BD,eAAO,MAAM,SAAS,GAAI,QAAQ,UAAU,KAAG,IAAI,EAiGlD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,SAAS,UAAU,EAAE,WAA4C,CAAC"}
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { hashCardContent, hashCardFamily } from './hash.js';
|
|
2
|
+
import { renderBackMarkdown, renderFrontMarkdown } from './render.js';
|
|
3
|
+
export class MdsrsParseError extends Error {
|
|
4
|
+
filePath;
|
|
5
|
+
line;
|
|
6
|
+
constructor(message, filePath, line) {
|
|
7
|
+
super(`${message} Location: ${filePath}:${line + 1}`);
|
|
8
|
+
this.filePath = filePath;
|
|
9
|
+
this.line = line;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const defaultNodePath = (filePath) => {
|
|
13
|
+
const parts = filePath.replace(/\.md$/, '').split('/').filter(Boolean);
|
|
14
|
+
return (parts.at(-1) === 'index' ? parts.slice(0, -1) : parts).join('/');
|
|
15
|
+
};
|
|
16
|
+
const lineKind = (line) => {
|
|
17
|
+
if (line.startsWith('Q:'))
|
|
18
|
+
return ['question', line.slice(2).trim()];
|
|
19
|
+
if (line.startsWith('A:'))
|
|
20
|
+
return ['answer', line.slice(2).trim()];
|
|
21
|
+
if (line.startsWith('C:'))
|
|
22
|
+
return ['cloze', line.slice(2).trim()];
|
|
23
|
+
if (line.trim() === '---')
|
|
24
|
+
return ['separator', ''];
|
|
25
|
+
return ['text', line];
|
|
26
|
+
};
|
|
27
|
+
const makeCard = (source, range, content) => ({
|
|
28
|
+
hash: hashCardContent(content),
|
|
29
|
+
familyHash: hashCardFamily(content),
|
|
30
|
+
deckName: source.deckName,
|
|
31
|
+
filePath: source.filePath,
|
|
32
|
+
folderPath: source.folderPath ?? '',
|
|
33
|
+
nodePath: source.nodePath ?? defaultNodePath(source.filePath),
|
|
34
|
+
displayName: source.displayName ?? source.deckName,
|
|
35
|
+
range,
|
|
36
|
+
content,
|
|
37
|
+
frontMarkdown: renderFrontMarkdown(content),
|
|
38
|
+
backMarkdown: renderBackMarkdown(content)
|
|
39
|
+
});
|
|
40
|
+
export const parseDeck = (source) => {
|
|
41
|
+
const cards = [];
|
|
42
|
+
let state = { type: 'start' };
|
|
43
|
+
const lines = source.text.split(/\r?\n/);
|
|
44
|
+
const finishBasic = (line, question, answer, startLine) => {
|
|
45
|
+
cards.push(makeCard(source, [startLine, line], {
|
|
46
|
+
type: 'basic',
|
|
47
|
+
question: question.trim(),
|
|
48
|
+
answer: answer.trim()
|
|
49
|
+
}));
|
|
50
|
+
};
|
|
51
|
+
const finishCloze = (line, text, startLine) => {
|
|
52
|
+
cards.push(...parseClozeCards(source, [startLine, line], text));
|
|
53
|
+
};
|
|
54
|
+
for (const [lineNumber, rawLine] of lines.entries()) {
|
|
55
|
+
const [kind, text] = lineKind(rawLine);
|
|
56
|
+
if (state.type === 'start') {
|
|
57
|
+
if (kind === 'question')
|
|
58
|
+
state = { type: 'question', question: text, startLine: lineNumber };
|
|
59
|
+
else if (kind === 'answer')
|
|
60
|
+
throw new MdsrsParseError('Found answer tag without a question.', source.filePath, lineNumber);
|
|
61
|
+
else if (kind === 'cloze')
|
|
62
|
+
state = { type: 'cloze', text, startLine: lineNumber };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (state.type === 'question') {
|
|
66
|
+
if (kind === 'answer')
|
|
67
|
+
state = {
|
|
68
|
+
type: 'answer',
|
|
69
|
+
question: state.question,
|
|
70
|
+
answer: text,
|
|
71
|
+
startLine: state.startLine
|
|
72
|
+
};
|
|
73
|
+
else if (kind === 'text')
|
|
74
|
+
state = { ...state, question: `${state.question}\n${text}` };
|
|
75
|
+
else if (kind === 'question')
|
|
76
|
+
throw new MdsrsParseError('New question without answer.', source.filePath, lineNumber);
|
|
77
|
+
else
|
|
78
|
+
throw new MdsrsParseError('Found invalid tag while reading a question.', source.filePath, lineNumber);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (state.type === 'answer') {
|
|
82
|
+
if (kind === 'text')
|
|
83
|
+
state = { ...state, answer: `${state.answer}\n${text}` };
|
|
84
|
+
else {
|
|
85
|
+
finishBasic(lineNumber, state.question, state.answer, state.startLine);
|
|
86
|
+
if (kind === 'question')
|
|
87
|
+
state = { type: 'question', question: text, startLine: lineNumber };
|
|
88
|
+
else if (kind === 'cloze')
|
|
89
|
+
state = { type: 'cloze', text, startLine: lineNumber };
|
|
90
|
+
else if (kind === 'separator')
|
|
91
|
+
state = { type: 'start' };
|
|
92
|
+
else
|
|
93
|
+
throw new MdsrsParseError('Found answer tag while reading an answer.', source.filePath, lineNumber);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (kind === 'text')
|
|
98
|
+
state = { ...state, text: `${state.text}\n${text}` };
|
|
99
|
+
else {
|
|
100
|
+
finishCloze(lineNumber, state.text, state.startLine);
|
|
101
|
+
if (kind === 'question')
|
|
102
|
+
state = { type: 'question', question: text, startLine: lineNumber };
|
|
103
|
+
else if (kind === 'cloze')
|
|
104
|
+
state = { type: 'cloze', text, startLine: lineNumber };
|
|
105
|
+
else if (kind === 'separator')
|
|
106
|
+
state = { type: 'start' };
|
|
107
|
+
else
|
|
108
|
+
throw new MdsrsParseError('Found answer tag while reading a cloze card.', source.filePath, lineNumber);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const lastLine = Math.max(lines.length - 1, 0);
|
|
112
|
+
if (state.type === 'question') {
|
|
113
|
+
throw new MdsrsParseError('File ended while reading a question without an answer.', source.filePath, lastLine);
|
|
114
|
+
}
|
|
115
|
+
if (state.type === 'answer')
|
|
116
|
+
finishBasic(lastLine, state.question, state.answer, state.startLine);
|
|
117
|
+
if (state.type === 'cloze')
|
|
118
|
+
finishCloze(lastLine, state.text, state.startLine);
|
|
119
|
+
return uniqueCards(cards);
|
|
120
|
+
};
|
|
121
|
+
export const parseCollection = (sources) => uniqueCards(sources.flatMap(parseDeck));
|
|
122
|
+
const uniqueCards = (cards) => {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
return cards
|
|
125
|
+
.filter((card) => {
|
|
126
|
+
if (seen.has(card.hash))
|
|
127
|
+
return false;
|
|
128
|
+
seen.add(card.hash);
|
|
129
|
+
return true;
|
|
130
|
+
})
|
|
131
|
+
.sort((left, right) => left.hash.localeCompare(right.hash));
|
|
132
|
+
};
|
|
133
|
+
const parseClozeCards = (source, range, rawText) => {
|
|
134
|
+
const text = rawText.trim();
|
|
135
|
+
let cleanText = '';
|
|
136
|
+
let open = null;
|
|
137
|
+
const ranges = [];
|
|
138
|
+
for (let index = 0; index < text.length; index++) {
|
|
139
|
+
const char = text[index];
|
|
140
|
+
const previous = text[index - 1];
|
|
141
|
+
const next = text[index + 1];
|
|
142
|
+
const isImageBracket = char === '[' && previous === '!';
|
|
143
|
+
const isEscapedBracket = (char === '[' || char === ']') && previous === '\\';
|
|
144
|
+
if (char === '\\' && (next === '[' || next === ']'))
|
|
145
|
+
continue;
|
|
146
|
+
if (char === '[' && !isImageBracket && !isEscapedBracket) {
|
|
147
|
+
open = cleanText.length;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (char === ']' && open !== null && !isEscapedBracket) {
|
|
151
|
+
ranges.push([open, cleanText.length - 1]);
|
|
152
|
+
open = null;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
cleanText += char;
|
|
156
|
+
}
|
|
157
|
+
if (ranges.length === 0) {
|
|
158
|
+
throw new MdsrsParseError('Cloze card must contain at least one cloze deletion.', source.filePath, range[0]);
|
|
159
|
+
}
|
|
160
|
+
return ranges.map(([start, end]) => makeCard(source, range, {
|
|
161
|
+
type: 'cloze',
|
|
162
|
+
text: cleanText,
|
|
163
|
+
start,
|
|
164
|
+
end
|
|
165
|
+
}));
|
|
166
|
+
};
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Card, CardPerformance, ReviewQueueItem } from './types.js';
|
|
2
|
+
export interface BuildReviewQueueOptions {
|
|
3
|
+
now?: Date;
|
|
4
|
+
limit?: number;
|
|
5
|
+
burySiblings?: boolean;
|
|
6
|
+
deckName?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const buildReviewQueue: (cards: Card[], performances: ReadonlyMap<string, CardPerformance>, options?: BuildReviewQueueOptions) => ReviewQueueItem[];
|
|
9
|
+
//# sourceMappingURL=queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEzE,MAAM,WAAW,uBAAuB;IACvC,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,eAAO,MAAM,gBAAgB,GAC5B,OAAO,IAAI,EAAE,EACb,cAAc,WAAW,CAAC,MAAM,EAAE,eAAe,CAAC,EAClD,UAAS,uBAA4B,KACnC,eAAe,EAsBjB,CAAC"}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isDue, toDateString } from './schedule.js';
|
|
2
|
+
export const buildReviewQueue = (cards, performances, options = {}) => {
|
|
3
|
+
const now = options.now ?? new Date();
|
|
4
|
+
const seenFamilies = new Set();
|
|
5
|
+
const today = toDateString(now);
|
|
6
|
+
const dueCards = cards
|
|
7
|
+
.filter((card) => options.deckName == null || card.deckName === options.deckName)
|
|
8
|
+
.map((card) => ({ card, performance: performances.get(card.hash) ?? null }))
|
|
9
|
+
.filter((item) => isDue(item.performance, now))
|
|
10
|
+
.sort(compareQueueItems(today));
|
|
11
|
+
const queue = [];
|
|
12
|
+
for (const item of dueCards) {
|
|
13
|
+
const familyHash = item.card.familyHash;
|
|
14
|
+
if (options.burySiblings !== false && familyHash) {
|
|
15
|
+
if (seenFamilies.has(familyHash))
|
|
16
|
+
continue;
|
|
17
|
+
seenFamilies.add(familyHash);
|
|
18
|
+
}
|
|
19
|
+
queue.push(item);
|
|
20
|
+
if (options.limit != null && queue.length >= options.limit)
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
return queue;
|
|
24
|
+
};
|
|
25
|
+
const compareQueueItems = (today) => (left, right) => {
|
|
26
|
+
const leftDue = left.performance?.dueDate ?? '';
|
|
27
|
+
const rightDue = right.performance?.dueDate ?? '';
|
|
28
|
+
if (leftDue !== rightDue)
|
|
29
|
+
return leftDue.localeCompare(rightDue);
|
|
30
|
+
if ((left.performance?.reviewCount ?? 0) !== (right.performance?.reviewCount ?? 0)) {
|
|
31
|
+
return (left.performance?.reviewCount ?? 0) - (right.performance?.reviewCount ?? 0);
|
|
32
|
+
}
|
|
33
|
+
if ((left.performance?.dueDate == null ? today : left.performance.dueDate) !==
|
|
34
|
+
(right.performance?.dueDate == null ? today : right.performance.dueDate)) {
|
|
35
|
+
return (left.performance?.dueDate ?? today).localeCompare(right.performance?.dueDate ?? today);
|
|
36
|
+
}
|
|
37
|
+
return left.card.hash.localeCompare(right.card.hash);
|
|
38
|
+
};
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,mBAAmB,GAAI,SAAS,WAAW,WAGvD,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,SAAS,WAAW,WAItD,CAAC"}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const renderFrontMarkdown = (content) => {
|
|
2
|
+
if (content.type === 'basic')
|
|
3
|
+
return content.question;
|
|
4
|
+
return `${content.text.slice(0, content.start)}[...]${content.text.slice(content.end + 1)}`;
|
|
5
|
+
};
|
|
6
|
+
export const renderBackMarkdown = (content) => {
|
|
7
|
+
if (content.type === 'basic')
|
|
8
|
+
return content.answer;
|
|
9
|
+
if (isInsideInlineMath(content.text, content.start, content.end))
|
|
10
|
+
return content.text;
|
|
11
|
+
return `${content.text.slice(0, content.start)}**${content.text.slice(content.start, content.end + 1)}**${content.text.slice(content.end + 1)}`;
|
|
12
|
+
};
|
|
13
|
+
const isInsideInlineMath = (text, start, end) => {
|
|
14
|
+
let inMath = false;
|
|
15
|
+
for (let index = 0; index < text.length; index++) {
|
|
16
|
+
const char = text[index];
|
|
17
|
+
const previous = text[index - 1];
|
|
18
|
+
if (char !== '$' || previous === '\\')
|
|
19
|
+
continue;
|
|
20
|
+
if (!inMath && index < start) {
|
|
21
|
+
inMath = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (inMath && index > end)
|
|
25
|
+
return true;
|
|
26
|
+
if (inMath)
|
|
27
|
+
inMath = false;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CardPerformance, Grade, ReviewResult } from './types.js';
|
|
2
|
+
export declare const retrievability: (intervalDays: number, stability: number) => number;
|
|
3
|
+
export declare const interval: (recall: number, stability: number) => number;
|
|
4
|
+
export declare const initialStability: (grade: Grade) => 0.40255 | 1.18385 | 3.173 | 15.69105;
|
|
5
|
+
export declare const initialDifficulty: (grade: Grade) => number;
|
|
6
|
+
export declare const newDifficulty: (difficulty: number, grade: Grade) => number;
|
|
7
|
+
export declare const newStability: (difficulty: number, stability: number, recall: number, grade: Grade) => number;
|
|
8
|
+
export declare const scheduleReview: (performance: CardPerformance | null | undefined, grade: Grade, reviewedAt?: Date) => ReviewResult;
|
|
9
|
+
export declare const toTimestamp: (date: Date) => string;
|
|
10
|
+
export declare const toDateString: (date: Date) => string;
|
|
11
|
+
export declare const isDue: (performance: Pick<CardPerformance, "dueDate"> | null | undefined, now?: Date) => boolean;
|
|
12
|
+
//# sourceMappingURL=schedule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../src/schedule.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAqBvE,eAAO,MAAM,cAAc,GAAI,cAAc,MAAM,EAAE,WAAW,MAAM,WACnB,CAAC;AAEpD,eAAO,MAAM,QAAQ,GAAI,QAAQ,MAAM,EAAE,WAAW,MAAM,WACP,CAAC;AAEpD,eAAO,MAAM,gBAAgB,GAAI,OAAO,KAAK,yCAMnC,CAAC;AAEX,eAAO,MAAM,iBAAiB,GAAI,OAAO,KAAK,WACmC,CAAC;AAOlF,eAAO,MAAM,aAAa,GAAI,YAAY,MAAM,EAAE,OAAO,KAAK,WAG5D,CAAC;AAyBH,eAAO,MAAM,YAAY,GACxB,YAAY,MAAM,EAClB,WAAW,MAAM,EACjB,QAAQ,MAAM,EACd,OAAO,KAAK,WAI6C,CAAC;AAE3D,eAAO,MAAM,cAAc,GAC1B,aAAa,eAAe,GAAG,IAAI,GAAG,SAAS,EAC/C,OAAO,KAAK,EACZ,iBAAuB,KACrB,YA6BF,CAAC;AAEF,eAAO,MAAM,WAAW,GAAI,MAAM,IAAI,WAAuB,CAAC;AAE9D,eAAO,MAAM,YAAY,GAAI,MAAM,IAAI,WAAoC,CAAC;AAE5E,eAAO,MAAM,KAAK,GACjB,aAAa,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,EAChE,UAAgB,YAC4D,CAAC"}
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const weights = [
|
|
2
|
+
0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 1.54575, 0.1192, 1.01925,
|
|
3
|
+
1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 0.6621
|
|
4
|
+
];
|
|
5
|
+
const desiredRecall = 0.9;
|
|
6
|
+
const factor = 19 / 81;
|
|
7
|
+
const decay = -0.5;
|
|
8
|
+
const gradeValue = (grade) => ({
|
|
9
|
+
forgot: 1,
|
|
10
|
+
hard: 2,
|
|
11
|
+
good: 3,
|
|
12
|
+
easy: 4
|
|
13
|
+
})[grade];
|
|
14
|
+
const clampDifficulty = (difficulty) => Math.min(10, Math.max(1, difficulty));
|
|
15
|
+
export const retrievability = (intervalDays, stability) => (1 + factor * (intervalDays / stability)) ** decay;
|
|
16
|
+
export const interval = (recall, stability) => (stability / factor) * (recall ** (1 / decay) - 1);
|
|
17
|
+
export const initialStability = (grade) => ({
|
|
18
|
+
forgot: weights[0],
|
|
19
|
+
hard: weights[1],
|
|
20
|
+
good: weights[2],
|
|
21
|
+
easy: weights[3]
|
|
22
|
+
})[grade];
|
|
23
|
+
export const initialDifficulty = (grade) => clampDifficulty(weights[4] - Math.exp(weights[5] * (gradeValue(grade) - 1)) + 1);
|
|
24
|
+
const deltaDifficulty = (grade) => -weights[6] * (gradeValue(grade) - 3);
|
|
25
|
+
const difficultyPrime = (difficulty, grade) => difficulty + deltaDifficulty(grade) * ((10 - difficulty) / 9);
|
|
26
|
+
export const newDifficulty = (difficulty, grade) => clampDifficulty(weights[7] * initialDifficulty('easy') + (1 - weights[7]) * difficultyPrime(difficulty, grade));
|
|
27
|
+
const successStability = (difficulty, stability, recall, grade) => {
|
|
28
|
+
const hardPenalty = grade === 'hard' ? weights[15] : 1;
|
|
29
|
+
const easyBonus = grade === 'easy' ? weights[16] : 1;
|
|
30
|
+
const alpha = 1 +
|
|
31
|
+
(11 - difficulty) *
|
|
32
|
+
stability ** -weights[9] *
|
|
33
|
+
(Math.exp(weights[10] * (1 - recall)) - 1) *
|
|
34
|
+
hardPenalty *
|
|
35
|
+
easyBonus *
|
|
36
|
+
Math.exp(weights[8]);
|
|
37
|
+
return stability * alpha;
|
|
38
|
+
};
|
|
39
|
+
const failStability = (difficulty, stability, recall) => Math.min(difficulty ** -weights[12] *
|
|
40
|
+
((stability + 1) ** weights[13] - 1) *
|
|
41
|
+
Math.exp(weights[14] * (1 - recall)) *
|
|
42
|
+
weights[11], stability);
|
|
43
|
+
export const newStability = (difficulty, stability, recall, grade) => grade === 'forgot'
|
|
44
|
+
? failStability(difficulty, stability, recall)
|
|
45
|
+
: successStability(difficulty, stability, recall, grade);
|
|
46
|
+
export const scheduleReview = (performance, grade, reviewedAt = new Date()) => {
|
|
47
|
+
const previousInterval = performance?.intervalDays ?? 0;
|
|
48
|
+
const previousStability = performance?.stability;
|
|
49
|
+
const previousDifficulty = performance?.difficulty;
|
|
50
|
+
const stability = previousStability == null || previousDifficulty == null
|
|
51
|
+
? initialStability(grade)
|
|
52
|
+
: newStability(previousDifficulty, previousStability, retrievability(Math.max(previousInterval, 1), previousStability), grade);
|
|
53
|
+
const difficulty = previousDifficulty == null ? initialDifficulty(grade) : newDifficulty(previousDifficulty, grade);
|
|
54
|
+
const intervalRaw = interval(desiredRecall, stability);
|
|
55
|
+
const intervalDays = Math.max(Math.round(intervalRaw), 1);
|
|
56
|
+
const dueDate = addDays(reviewedAt, intervalDays);
|
|
57
|
+
return {
|
|
58
|
+
lastReviewedAt: toTimestamp(reviewedAt),
|
|
59
|
+
stability,
|
|
60
|
+
difficulty,
|
|
61
|
+
intervalRaw,
|
|
62
|
+
intervalDays,
|
|
63
|
+
dueDate,
|
|
64
|
+
reviewCount: (performance?.reviewCount ?? 0) + 1,
|
|
65
|
+
grade
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
export const toTimestamp = (date) => date.toISOString();
|
|
69
|
+
export const toDateString = (date) => date.toISOString().slice(0, 10);
|
|
70
|
+
export const isDue = (performance, now = new Date()) => performance?.dueDate == null || performance.dueDate <= toDateString(now);
|
|
71
|
+
const addDays = (date, days) => {
|
|
72
|
+
const next = new Date(date);
|
|
73
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
74
|
+
return toDateString(next);
|
|
75
|
+
};
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Card, DeckTreeNode, Grade } from './types.js';
|
|
2
|
+
export interface CollectionCardState {
|
|
3
|
+
cardHash: string;
|
|
4
|
+
active?: boolean;
|
|
5
|
+
addedAt?: Date | string | null;
|
|
6
|
+
dueDate: string | null;
|
|
7
|
+
reviewCount?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CollectionReviewState {
|
|
10
|
+
cardHash: string;
|
|
11
|
+
reviewedAt: Date | string;
|
|
12
|
+
grade: Grade | string;
|
|
13
|
+
}
|
|
14
|
+
export interface DeckStats {
|
|
15
|
+
path: string;
|
|
16
|
+
name: string;
|
|
17
|
+
cardCount: number;
|
|
18
|
+
totalCardCount: number;
|
|
19
|
+
activeCards: number;
|
|
20
|
+
dueCards: number;
|
|
21
|
+
queuedCards: number;
|
|
22
|
+
overdueCards: number;
|
|
23
|
+
newCards: number;
|
|
24
|
+
cardsAddedLast7Days: number;
|
|
25
|
+
reviewsLast7Days: number;
|
|
26
|
+
hitRateLast30Days: number | null;
|
|
27
|
+
children: DeckStats[];
|
|
28
|
+
}
|
|
29
|
+
export interface CollectionStats {
|
|
30
|
+
totalCards: number;
|
|
31
|
+
activeCards: number;
|
|
32
|
+
dueCards: number;
|
|
33
|
+
queuedCards: number;
|
|
34
|
+
overdueCards: number;
|
|
35
|
+
newCards: number;
|
|
36
|
+
cardsAddedLast7Days: number;
|
|
37
|
+
reviewsToday: number;
|
|
38
|
+
reviewsLast7Days: number;
|
|
39
|
+
reviewActiveDaysLast14: number;
|
|
40
|
+
hitRateLast30Days: number | null;
|
|
41
|
+
daysSinceLastReview: number | null;
|
|
42
|
+
decks: DeckStats[];
|
|
43
|
+
}
|
|
44
|
+
export interface BuildCollectionStatsOptions {
|
|
45
|
+
now?: Date;
|
|
46
|
+
}
|
|
47
|
+
export declare const buildCollectionStats: (cards: Card[], deckTree: DeckTreeNode[], cardStates: CollectionCardState[], reviews: CollectionReviewState[], options?: BuildCollectionStatsOptions) => CollectionStats;
|
|
48
|
+
//# sourceMappingURL=stats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../src/stats.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAmB,YAAY,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAE7E,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IAC/B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,IAAI,GAAG,MAAM,CAAC;IAC1B,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,QAAQ,EAAE,SAAS,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,KAAK,EAAE,SAAS,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,2BAA2B;IAC3C,GAAG,CAAC,EAAE,IAAI,CAAC;CACX;AAkBD,eAAO,MAAM,oBAAoB,GAChC,OAAO,IAAI,EAAE,EACb,UAAU,YAAY,EAAE,EACxB,YAAY,mBAAmB,EAAE,EACjC,SAAS,qBAAqB,EAAE,EAChC,UAAS,2BAAgC,KACvC,eAmDF,CAAC"}
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { toDateString } from './schedule.js';
|
|
2
|
+
import { buildReviewQueue } from './queue.js';
|
|
3
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
4
|
+
const startOfDay = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
5
|
+
const toDate = (value) => (value instanceof Date ? value : new Date(value));
|
|
6
|
+
const daysBetween = (left, right) => Math.max(0, Math.floor((startOfDay(left).getTime() - startOfDay(right).getTime()) / dayMs));
|
|
7
|
+
const hitRate = (reviews) => {
|
|
8
|
+
if (reviews.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const hits = reviews.filter((review) => review.grade !== 'forgot').length;
|
|
11
|
+
return hits / reviews.length;
|
|
12
|
+
};
|
|
13
|
+
export const buildCollectionStats = (cards, deckTree, cardStates, reviews, options = {}) => {
|
|
14
|
+
const now = options.now ?? new Date();
|
|
15
|
+
const today = toDateString(now);
|
|
16
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * dayMs);
|
|
17
|
+
const fourteenDaysAgo = new Date(now.getTime() - 14 * dayMs);
|
|
18
|
+
const thirtyDaysAgo = new Date(now.getTime() - 30 * dayMs);
|
|
19
|
+
const cardStateByHash = new Map(cardStates.map((card) => [card.cardHash, card]));
|
|
20
|
+
const hasCardStates = cardStateByHash.size > 0;
|
|
21
|
+
const activeCards = cards.filter((card) => isActiveCard(card, cardStateByHash, hasCardStates));
|
|
22
|
+
const activeCardHashes = new Set(activeCards.map((card) => card.hash));
|
|
23
|
+
const dueCards = activeCards.filter((card) => isDueCard(card, cardStateByHash, hasCardStates, today));
|
|
24
|
+
const performances = cardStatePerformances(activeCards, cardStateByHash);
|
|
25
|
+
const queuedCards = buildReviewQueue(activeCards, performances, { now });
|
|
26
|
+
const overdueCards = activeCards.filter((card) => isOverdueCard(card, cardStateByHash, today));
|
|
27
|
+
const newCards = activeCards.filter((card) => isNewCard(card, cardStateByHash, hasCardStates));
|
|
28
|
+
const cardsAddedLast7Days = activeCards.filter((card) => wasAddedSince(card, cardStateByHash, hasCardStates, sevenDaysAgo));
|
|
29
|
+
const activeReviews = reviews.filter((review) => activeCardHashes.has(review.cardHash));
|
|
30
|
+
const reviewsToday = activeReviews.filter((review) => toDate(review.reviewedAt).toISOString().slice(0, 10) === today);
|
|
31
|
+
const reviewsLast7Days = activeReviews.filter((review) => toDate(review.reviewedAt) >= sevenDaysAgo);
|
|
32
|
+
const reviewsLast14Days = activeReviews.filter((review) => toDate(review.reviewedAt) >= fourteenDaysAgo);
|
|
33
|
+
const reviewsLast30Days = activeReviews.filter((review) => toDate(review.reviewedAt) >= thirtyDaysAgo);
|
|
34
|
+
const activeDays = new Set(reviewsLast14Days.map((review) => toDate(review.reviewedAt).toISOString().slice(0, 10)));
|
|
35
|
+
const lastReview = activeReviews
|
|
36
|
+
.map((review) => toDate(review.reviewedAt))
|
|
37
|
+
.sort((left, right) => right.getTime() - left.getTime())[0];
|
|
38
|
+
return {
|
|
39
|
+
totalCards: cards.length,
|
|
40
|
+
activeCards: activeCards.length,
|
|
41
|
+
dueCards: dueCards.length,
|
|
42
|
+
queuedCards: queuedCards.length,
|
|
43
|
+
overdueCards: overdueCards.length,
|
|
44
|
+
newCards: newCards.length,
|
|
45
|
+
cardsAddedLast7Days: cardsAddedLast7Days.length,
|
|
46
|
+
reviewsToday: reviewsToday.length,
|
|
47
|
+
reviewsLast7Days: reviewsLast7Days.length,
|
|
48
|
+
reviewActiveDaysLast14: activeDays.size,
|
|
49
|
+
hitRateLast30Days: hitRate(reviewsLast30Days),
|
|
50
|
+
daysSinceLastReview: lastReview ? daysBetween(now, lastReview) : null,
|
|
51
|
+
decks: buildDeckStats(deckTree, cards, cardStateByHash, activeReviews, hasCardStates, now)
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const isActiveCard = (card, cardStateByHash, hasCardStates) => {
|
|
55
|
+
if (!hasCardStates)
|
|
56
|
+
return true;
|
|
57
|
+
return cardStateByHash.get(card.hash)?.active !== false && cardStateByHash.has(card.hash);
|
|
58
|
+
};
|
|
59
|
+
const isDueCard = (card, cardStateByHash, hasCardStates, today) => {
|
|
60
|
+
if (!hasCardStates)
|
|
61
|
+
return true;
|
|
62
|
+
const dueDate = cardStateByHash.get(card.hash)?.dueDate;
|
|
63
|
+
return dueDate == null || dueDate <= today;
|
|
64
|
+
};
|
|
65
|
+
const isOverdueCard = (card, cardStateByHash, today) => {
|
|
66
|
+
const dueDate = cardStateByHash.get(card.hash)?.dueDate;
|
|
67
|
+
return dueDate != null && dueDate < today;
|
|
68
|
+
};
|
|
69
|
+
const isNewCard = (card, cardStateByHash, hasCardStates) => !hasCardStates || cardStateByHash.get(card.hash)?.dueDate == null;
|
|
70
|
+
const wasAddedSince = (card, cardStateByHash, hasCardStates, since) => {
|
|
71
|
+
if (!hasCardStates)
|
|
72
|
+
return true;
|
|
73
|
+
const addedAt = cardStateByHash.get(card.hash)?.addedAt;
|
|
74
|
+
return addedAt ? toDate(addedAt) >= since : false;
|
|
75
|
+
};
|
|
76
|
+
const cardStatePerformances = (cards, cardStateByHash) => new Map(cards.map((card) => {
|
|
77
|
+
const state = cardStateByHash.get(card.hash);
|
|
78
|
+
return [
|
|
79
|
+
card.hash,
|
|
80
|
+
{
|
|
81
|
+
lastReviewedAt: null,
|
|
82
|
+
stability: null,
|
|
83
|
+
difficulty: null,
|
|
84
|
+
intervalRaw: null,
|
|
85
|
+
intervalDays: null,
|
|
86
|
+
dueDate: state?.dueDate ?? null,
|
|
87
|
+
reviewCount: state?.reviewCount ?? 0
|
|
88
|
+
}
|
|
89
|
+
];
|
|
90
|
+
}));
|
|
91
|
+
const nodeCardsForStats = (cards, node) => cards.filter((card) => card.nodePath === node.path || card.nodePath.startsWith(`${node.path}/`));
|
|
92
|
+
const buildDeckStats = (nodes, cards, cardStateByHash, reviews, hasCardStates, now) => {
|
|
93
|
+
const today = toDateString(now);
|
|
94
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * dayMs);
|
|
95
|
+
const thirtyDaysAgo = new Date(now.getTime() - 30 * dayMs);
|
|
96
|
+
const reviewsByCard = new Map();
|
|
97
|
+
for (const review of reviews) {
|
|
98
|
+
const cardReviews = reviewsByCard.get(review.cardHash) ?? [];
|
|
99
|
+
cardReviews.push(review);
|
|
100
|
+
reviewsByCard.set(review.cardHash, cardReviews);
|
|
101
|
+
}
|
|
102
|
+
return nodes.map((node) => {
|
|
103
|
+
const nodeCards = nodeCardsForStats(cards, node);
|
|
104
|
+
const activeCards = nodeCards.filter((card) => isActiveCard(card, cardStateByHash, hasCardStates));
|
|
105
|
+
const performances = cardStatePerformances(activeCards, cardStateByHash);
|
|
106
|
+
const nodeReviews = activeCards.flatMap((card) => reviewsByCard.get(card.hash) ?? []);
|
|
107
|
+
const reviewsLast30Days = nodeReviews.filter((review) => toDate(review.reviewedAt) >= thirtyDaysAgo);
|
|
108
|
+
return {
|
|
109
|
+
path: node.path,
|
|
110
|
+
name: node.name,
|
|
111
|
+
cardCount: node.cardCount,
|
|
112
|
+
totalCardCount: node.totalCardCount,
|
|
113
|
+
activeCards: activeCards.length,
|
|
114
|
+
dueCards: activeCards.filter((card) => isDueCard(card, cardStateByHash, hasCardStates, today))
|
|
115
|
+
.length,
|
|
116
|
+
queuedCards: buildReviewQueue(activeCards, performances, { now }).length,
|
|
117
|
+
overdueCards: activeCards.filter((card) => isOverdueCard(card, cardStateByHash, today)).length,
|
|
118
|
+
newCards: activeCards.filter((card) => isNewCard(card, cardStateByHash, hasCardStates)).length,
|
|
119
|
+
cardsAddedLast7Days: activeCards.filter((card) => wasAddedSince(card, cardStateByHash, hasCardStates, sevenDaysAgo)).length,
|
|
120
|
+
reviewsLast7Days: nodeReviews.filter((review) => toDate(review.reviewedAt) >= sevenDaysAgo)
|
|
121
|
+
.length,
|
|
122
|
+
hitRateLast30Days: hitRate(reviewsLast30Days),
|
|
123
|
+
children: buildDeckStats(node.children, cards, cardStateByHash, reviews, hasCardStates, now)
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type Grade = 'forgot' | 'hard' | 'good' | 'easy';
|
|
2
|
+
export type CardContent = {
|
|
3
|
+
type: 'basic';
|
|
4
|
+
question: string;
|
|
5
|
+
answer: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'cloze';
|
|
8
|
+
text: string;
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
};
|
|
12
|
+
export interface DeckSource {
|
|
13
|
+
deckName: string;
|
|
14
|
+
filePath: string;
|
|
15
|
+
text: string;
|
|
16
|
+
folderPath?: string;
|
|
17
|
+
nodePath?: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Card {
|
|
21
|
+
hash: string;
|
|
22
|
+
familyHash: string | null;
|
|
23
|
+
deckName: string;
|
|
24
|
+
filePath: string;
|
|
25
|
+
folderPath: string;
|
|
26
|
+
nodePath: string;
|
|
27
|
+
displayName: string;
|
|
28
|
+
range: [number, number];
|
|
29
|
+
content: CardContent;
|
|
30
|
+
frontMarkdown: string;
|
|
31
|
+
backMarkdown: string;
|
|
32
|
+
}
|
|
33
|
+
export interface DeckTreeNode {
|
|
34
|
+
path: string;
|
|
35
|
+
name: string;
|
|
36
|
+
cardCount: number;
|
|
37
|
+
totalCardCount: number;
|
|
38
|
+
children: DeckTreeNode[];
|
|
39
|
+
}
|
|
40
|
+
export interface CardPerformance {
|
|
41
|
+
lastReviewedAt: string | null;
|
|
42
|
+
stability: number | null;
|
|
43
|
+
difficulty: number | null;
|
|
44
|
+
intervalRaw: number | null;
|
|
45
|
+
intervalDays: number | null;
|
|
46
|
+
dueDate: string | null;
|
|
47
|
+
reviewCount: number;
|
|
48
|
+
}
|
|
49
|
+
export interface ReviewResult {
|
|
50
|
+
lastReviewedAt: string;
|
|
51
|
+
grade: Grade;
|
|
52
|
+
stability: number;
|
|
53
|
+
difficulty: number;
|
|
54
|
+
intervalRaw: number;
|
|
55
|
+
intervalDays: number;
|
|
56
|
+
dueDate: string;
|
|
57
|
+
reviewCount: number;
|
|
58
|
+
}
|
|
59
|
+
export interface ReviewQueueItem {
|
|
60
|
+
card: Card;
|
|
61
|
+
performance: CardPerformance | null;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAExD,MAAM,MAAM,WAAW,GACpB;IACA,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CACd,GACD;IACA,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACX,CAAC;AAEL,MAAM,WAAW,UAAU;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,IAAI;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxB,OAAO,EAAE,WAAW,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,YAAY,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC/B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,IAAI,CAAC;IACX,WAAW,EAAE,eAAe,GAAG,IAAI,CAAC;CACpC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mdsrs/core",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^24.0.0",
|
|
17
|
+
"typescript": "^6.0.3",
|
|
18
|
+
"vitest": "^4.0.0"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
24
|
+
}
|
|
25
|
+
}
|