mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
chore(decommission): remove packages/cards-core/
@mana/cards-core wurde nur von apps/cards + services/cards-server + apps/mana/.../modules/cards genutzt — die ersten zwei sind weg, das mana-Modul kommt im nächsten Commit. Rollback: git checkout cards-decommission-base -- packages/cards-core/ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc158cb0bc
commit
dd1bab09d5
10 changed files with 0 additions and 699 deletions
|
|
@ -1,30 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@mana/cards-core",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"sideEffects": false,
|
|
||||||
"description": "Pure utilities for the Cardecky product: types, FSRS wrapper, Cloze parser, Markdown render. Consumed by both the mana cards module and the cardecky.mana.how standalone app.",
|
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"type-check": "tsc --noEmit",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"clean": "rm -rf dist"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mana/local-store": "workspace:*",
|
|
||||||
"@mana/shared-privacy": "workspace:*",
|
|
||||||
"isomorphic-dompurify": "^3.7.1",
|
|
||||||
"marked": "^17.0.5",
|
|
||||||
"ts-fsrs": "^5.3.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.10.1",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vitest": "^4.1.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { subIndexesFor } from './card-reviews';
|
|
||||||
|
|
||||||
describe('subIndexesFor', () => {
|
|
||||||
it('basic → [0]', () => {
|
|
||||||
expect(subIndexesFor({ type: 'basic', fields: { front: 'a', back: 'b' } })).toEqual([0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('type-in → [0]', () => {
|
|
||||||
expect(subIndexesFor({ type: 'type-in', fields: { front: 'a', back: 'b' } })).toEqual([0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('basic-reverse → [0, 1]', () => {
|
|
||||||
expect(subIndexesFor({ type: 'basic-reverse', fields: { front: 'a', back: 'b' } })).toEqual([
|
|
||||||
0, 1,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cloze → cluster indexes', () => {
|
|
||||||
expect(
|
|
||||||
subIndexesFor({
|
|
||||||
type: 'cloze',
|
|
||||||
fields: { text: '{{c1::Berlin}} ist Hauptstadt von {{c2::Deutschland}}.' },
|
|
||||||
})
|
|
||||||
).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cloze with no clusters falls back to [1]', () => {
|
|
||||||
expect(subIndexesFor({ type: 'cloze', fields: { text: '' } })).toEqual([1]);
|
|
||||||
expect(subIndexesFor({ type: 'cloze', fields: { text: 'no clozes here' } })).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cloze deduplicates repeated clusters', () => {
|
|
||||||
expect(
|
|
||||||
subIndexesFor({
|
|
||||||
type: 'cloze',
|
|
||||||
fields: { text: '{{c1::a}} und {{c1::b}} und {{c2::c}}' },
|
|
||||||
})
|
|
||||||
).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('phase-2 types stub to [0] (no crash)', () => {
|
|
||||||
expect(subIndexesFor({ type: 'image-occlusion', fields: {} })).toEqual([0]);
|
|
||||||
expect(subIndexesFor({ type: 'audio', fields: {} })).toEqual([0]);
|
|
||||||
expect(subIndexesFor({ type: 'multiple-choice', fields: {} })).toEqual([0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* Per-card-type review fan-out.
|
|
||||||
*
|
|
||||||
* Different card types produce different numbers of learnable units:
|
|
||||||
* - basic / type-in: one (subIndex 0)
|
|
||||||
* - basic-reverse: two (0=front→back, 1=back→front)
|
|
||||||
* - cloze: one per distinct cluster (subIndex = cluster idx)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { clusterIndexes } from './cloze';
|
|
||||||
import type { CardFields, CardType } from './types';
|
|
||||||
|
|
||||||
export function subIndexesFor(input: { type: CardType; fields: CardFields }): number[] {
|
|
||||||
switch (input.type) {
|
|
||||||
case 'basic':
|
|
||||||
case 'type-in':
|
|
||||||
return [0];
|
|
||||||
case 'basic-reverse':
|
|
||||||
return [0, 1];
|
|
||||||
case 'cloze': {
|
|
||||||
const text = input.fields.text ?? '';
|
|
||||||
const idx = clusterIndexes(text);
|
|
||||||
return idx.length > 0 ? idx : [1];
|
|
||||||
}
|
|
||||||
case 'image-occlusion':
|
|
||||||
case 'audio':
|
|
||||||
case 'multiple-choice':
|
|
||||||
return [0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { tokenize, clusterIndexes, clusters, renderCloze } from './cloze';
|
|
||||||
|
|
||||||
describe('tokenize', () => {
|
|
||||||
it('splits plain text and clusters', () => {
|
|
||||||
const tokens = tokenize('A {{c1::B}} C');
|
|
||||||
expect(tokens).toEqual([
|
|
||||||
{ kind: 'text', value: 'A ' },
|
|
||||||
{ kind: 'cluster', index: 1, answer: 'B', hint: undefined },
|
|
||||||
{ kind: 'text', value: ' C' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures hints', () => {
|
|
||||||
const [t] = tokenize('{{c1::Berlin::Hauptstadt}}');
|
|
||||||
expect(t).toEqual({ kind: 'cluster', index: 1, answer: 'Berlin', hint: 'Hauptstadt' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles multiple clusters in one source', () => {
|
|
||||||
const tokens = tokenize('{{c1::Berlin}} ist die Hauptstadt von {{c2::Deutschland}}.');
|
|
||||||
const indexes = tokens.filter((t) => t.kind === 'cluster').map((t) => (t as any).index);
|
|
||||||
expect(indexes).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes through unmatched braces', () => {
|
|
||||||
const tokens = tokenize('foo {bar} baz');
|
|
||||||
expect(tokens).toEqual([{ kind: 'text', value: 'foo {bar} baz' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('survives multi-line input', () => {
|
|
||||||
const tokens = tokenize('Line 1\n{{c1::x}}\nLine 3');
|
|
||||||
expect(tokens.length).toBe(3);
|
|
||||||
expect((tokens[1] as any).answer).toBe('x');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clusterIndexes', () => {
|
|
||||||
it('returns ascending unique indexes', () => {
|
|
||||||
expect(clusterIndexes('{{c2::a}} {{c1::b}} {{c2::c}} {{c3::d}}')).toEqual([1, 2, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty for plain text', () => {
|
|
||||||
expect(clusterIndexes('no clozes here')).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clusters', () => {
|
|
||||||
it('groups answers under their cluster', () => {
|
|
||||||
const result = clusters('{{c1::a}} {{c1::b}} {{c2::c}}');
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ index: 1, answers: ['a', 'b'] },
|
|
||||||
{ index: 2, answers: ['c'] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('renderCloze', () => {
|
|
||||||
it('blanks the hidden cluster on front, reveals on back', () => {
|
|
||||||
const r = renderCloze('{{c1::Berlin}} ist die Hauptstadt von {{c2::Deutschland}}.', 1);
|
|
||||||
expect(r.front).toContain('[…]');
|
|
||||||
expect(r.front).toContain('Deutschland');
|
|
||||||
expect(r.back).toContain('Berlin');
|
|
||||||
expect(r.back).toContain('cloze-active');
|
|
||||||
expect(r.answer).toBe('Berlin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses hint when present', () => {
|
|
||||||
const r = renderCloze('{{c1::Berlin::Hauptstadt}} ist eine Stadt.', 1);
|
|
||||||
expect(r.front).toContain('[Hauptstadt]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blanks every occurrence of the hidden cluster', () => {
|
|
||||||
const r = renderCloze('{{c1::x}} und {{c1::x}}', 1);
|
|
||||||
const blanks = r.front.match(/cloze-blank/g) ?? [];
|
|
||||||
expect(blanks.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes HTML in user content', () => {
|
|
||||||
const r = renderCloze('{{c1::<script>}}', 1);
|
|
||||||
expect(r.back).not.toContain('<script>');
|
|
||||||
expect(r.back).toContain('<script>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
/**
|
|
||||||
* Cloze parser — Anki-compatible {{cN::answer}} / {{cN::answer::hint}} syntax.
|
|
||||||
*
|
|
||||||
* A cloze card produces one *review* per distinct cluster index. The
|
|
||||||
* `subIndex` of a review row is the cluster number (1-based), so basic
|
|
||||||
* cards use subIndex 0 and cloze cards use subIndex 1, 2, ….
|
|
||||||
*
|
|
||||||
* Why parse to a tree of `Token`s instead of regex-replacing during
|
|
||||||
* render? Because the same cluster can appear multiple times in one
|
|
||||||
* text (e.g. `{{c1::Berlin}} … {{c1::Berlin}}`) and the renderer must
|
|
||||||
* blank both occurrences. A token list keeps the renderer trivial and
|
|
||||||
* lets us reuse the parse for stats / extraction.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ClozeCluster {
|
|
||||||
/** 1-based cluster index, e.g. 1 for {{c1::…}}. */
|
|
||||||
index: number;
|
|
||||||
/** The answer text(s) belonging to this cluster, in source order. */
|
|
||||||
answers: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type Token =
|
|
||||||
| { kind: 'text'; value: string }
|
|
||||||
| { kind: 'cluster'; index: number; answer: string; hint?: string };
|
|
||||||
|
|
||||||
const CLOZE_RE = /\{\{c(\d+)::((?:(?!\}\}).)+?)\}\}/gs;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lex a cloze source string. Anything not matching {{cN::…}} is a
|
|
||||||
* `text` token; matches become `cluster` tokens with their parsed
|
|
||||||
* answer/hint.
|
|
||||||
*/
|
|
||||||
export function tokenize(source: string): Token[] {
|
|
||||||
const tokens: Token[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
for (const match of source.matchAll(CLOZE_RE)) {
|
|
||||||
const start = match.index ?? 0;
|
|
||||||
if (start > lastIndex) {
|
|
||||||
tokens.push({ kind: 'text', value: source.slice(lastIndex, start) });
|
|
||||||
}
|
|
||||||
const idx = Number.parseInt(match[1], 10);
|
|
||||||
const inner = match[2];
|
|
||||||
const hintSplit = inner.indexOf('::');
|
|
||||||
const answer = hintSplit >= 0 ? inner.slice(0, hintSplit) : inner;
|
|
||||||
const hint = hintSplit >= 0 ? inner.slice(hintSplit + 2) : undefined;
|
|
||||||
tokens.push({ kind: 'cluster', index: idx, answer, hint });
|
|
||||||
lastIndex = start + match[0].length;
|
|
||||||
}
|
|
||||||
if (lastIndex < source.length) {
|
|
||||||
tokens.push({ kind: 'text', value: source.slice(lastIndex) });
|
|
||||||
}
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distinct cluster indexes in ascending order. Each one becomes one
|
|
||||||
* `cardReviews` row (subIndex = cluster index).
|
|
||||||
*/
|
|
||||||
export function clusterIndexes(source: string): number[] {
|
|
||||||
const set = new Set<number>();
|
|
||||||
for (const t of tokenize(source)) {
|
|
||||||
if (t.kind === 'cluster') set.add(t.index);
|
|
||||||
}
|
|
||||||
return [...set].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group answers by cluster. Useful for the editor preview and for
|
|
||||||
* generating the prompt that lists hints.
|
|
||||||
*/
|
|
||||||
export function clusters(source: string): ClozeCluster[] {
|
|
||||||
const grouped = new Map<number, string[]>();
|
|
||||||
for (const t of tokenize(source)) {
|
|
||||||
if (t.kind !== 'cluster') continue;
|
|
||||||
const arr = grouped.get(t.index) ?? [];
|
|
||||||
arr.push(t.answer);
|
|
||||||
grouped.set(t.index, arr);
|
|
||||||
}
|
|
||||||
return [...grouped.entries()]
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([index, answers]) => ({ index, answers }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RenderedCloze {
|
|
||||||
front: string;
|
|
||||||
back: string;
|
|
||||||
answer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a cloze prompt for a specific cluster index. Plain HTML —
|
|
||||||
* Markdown coupling happens one level up.
|
|
||||||
*
|
|
||||||
* Hidden cluster: `[…]` placeholder, with hint in parens if set
|
|
||||||
* Other clusters: rendered as plain answer text
|
|
||||||
*
|
|
||||||
* Back side reveals every cluster; the active one wears
|
|
||||||
* `<mark class="cloze-active">…</mark>` so the UI can highlight it.
|
|
||||||
*/
|
|
||||||
export function renderCloze(source: string, hideIndex: number): RenderedCloze {
|
|
||||||
const tokens = tokenize(source);
|
|
||||||
const front: string[] = [];
|
|
||||||
const back: string[] = [];
|
|
||||||
const hiddenAnswers: string[] = [];
|
|
||||||
|
|
||||||
for (const t of tokens) {
|
|
||||||
if (t.kind === 'text') {
|
|
||||||
front.push(escapeHtml(t.value));
|
|
||||||
back.push(escapeHtml(t.value));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const ans = escapeHtml(t.answer);
|
|
||||||
if (t.index === hideIndex) {
|
|
||||||
hiddenAnswers.push(t.answer);
|
|
||||||
const placeholder = t.hint
|
|
||||||
? `<span class="cloze-blank">[${escapeHtml(t.hint)}]</span>`
|
|
||||||
: `<span class="cloze-blank">[…]</span>`;
|
|
||||||
front.push(placeholder);
|
|
||||||
back.push(`<mark class="cloze-active">${ans}</mark>`);
|
|
||||||
} else {
|
|
||||||
front.push(ans);
|
|
||||||
back.push(ans);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
front: front.join(''),
|
|
||||||
back: back.join(''),
|
|
||||||
answer: hiddenAnswers.join(', '),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
/**
|
|
||||||
* FSRS wrapper — Free Spaced Repetition Scheduler v6 via `ts-fsrs`.
|
|
||||||
*
|
|
||||||
* Translates between ts-fsrs's `Card` (Date objects, snake_case) and
|
|
||||||
* our `LocalCardReview` (ISO strings, camelCase). Stores never see
|
|
||||||
* ts-fsrs types directly. One place to swap params for per-user-tuning
|
|
||||||
* later.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { fsrs, createEmptyCard, State, type Card, type Grade } from 'ts-fsrs';
|
|
||||||
import type { LocalCardReview, ReviewGrade } from './types';
|
|
||||||
|
|
||||||
const STATE_TO_STRING: Record<State, LocalCardReview['state']> = {
|
|
||||||
[State.New]: 'new',
|
|
||||||
[State.Learning]: 'learning',
|
|
||||||
[State.Review]: 'review',
|
|
||||||
[State.Relearning]: 'relearning',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STRING_TO_STATE: Record<LocalCardReview['state'], State> = {
|
|
||||||
new: State.New,
|
|
||||||
learning: State.Learning,
|
|
||||||
review: State.Review,
|
|
||||||
relearning: State.Relearning,
|
|
||||||
};
|
|
||||||
|
|
||||||
function toLocalReview(id: string, cardId: string, subIndex: number, card: Card): LocalCardReview {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
cardId,
|
|
||||||
subIndex,
|
|
||||||
state: STATE_TO_STRING[card.state],
|
|
||||||
stability: card.stability,
|
|
||||||
difficulty: card.difficulty,
|
|
||||||
due: card.due.toISOString(),
|
|
||||||
reps: card.reps,
|
|
||||||
lapses: card.lapses,
|
|
||||||
lastReview: card.last_review ? card.last_review.toISOString() : undefined,
|
|
||||||
elapsedDays: card.elapsed_days,
|
|
||||||
scheduledDays: card.scheduled_days,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFsrsCard(review: LocalCardReview): Card {
|
|
||||||
return {
|
|
||||||
due: new Date(review.due),
|
|
||||||
stability: review.stability,
|
|
||||||
difficulty: review.difficulty,
|
|
||||||
elapsed_days: review.elapsedDays,
|
|
||||||
scheduled_days: review.scheduledDays,
|
|
||||||
learning_steps: 0,
|
|
||||||
reps: review.reps,
|
|
||||||
lapses: review.lapses,
|
|
||||||
state: STRING_TO_STATE[review.state],
|
|
||||||
last_review: review.lastReview ? new Date(review.lastReview) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a fresh review row for a new learnable unit (basic card,
|
|
||||||
* one cloze cluster, one direction of basic-reverse).
|
|
||||||
*/
|
|
||||||
export function newReview(opts: { cardId: string; subIndex: number; now?: Date }): LocalCardReview {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
const empty = createEmptyCard(opts.now ?? new Date());
|
|
||||||
return toLocalReview(id, opts.cardId, opts.subIndex, empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a grade to a review and return the next-state row.
|
|
||||||
*/
|
|
||||||
export function gradeReview(
|
|
||||||
review: LocalCardReview,
|
|
||||||
grade: ReviewGrade,
|
|
||||||
now: Date = new Date()
|
|
||||||
): LocalCardReview {
|
|
||||||
const scheduler = getScheduler();
|
|
||||||
const fsrsCard = toFsrsCard(review);
|
|
||||||
const result = scheduler.next(fsrsCard, now, gradeToRating(grade));
|
|
||||||
return toLocalReview(review.id, review.cardId, review.subIndex, result.card);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gradeToRating(grade: ReviewGrade): Grade {
|
|
||||||
return grade as unknown as Grade;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached: ReturnType<typeof fsrs> | null = null;
|
|
||||||
function getScheduler() {
|
|
||||||
if (!cached) cached = fsrs();
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Cardecky / Cards-Core — pure utilities used by both the mana cards module
|
|
||||||
* (apps/mana/.../modules/cards/) and the cardecky.mana.how standalone app.
|
|
||||||
*
|
|
||||||
* Only DB-free code lives here. Anything that touches Dexie, mana-sync,
|
|
||||||
* or app-specific encryption stays in the consumer apps.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './types';
|
|
||||||
export * from './cloze';
|
|
||||||
export * from './card-reviews';
|
|
||||||
export * from './fsrs';
|
|
||||||
export * from './render';
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
/**
|
|
||||||
* Markdown render helper for cards.
|
|
||||||
*
|
|
||||||
* Pipeline: marked (GFM) → DOMPurify. Used by the card face for basic /
|
|
||||||
* type-in / basic-reverse, and by the cloze post-processor.
|
|
||||||
*
|
|
||||||
* Cloze callers should pass `{ skipParagraph: true }` so a single-line
|
|
||||||
* fragment doesn't get wrapped in <p>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { marked } from 'marked';
|
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
|
||||||
|
|
||||||
marked.setOptions({ gfm: true, breaks: true });
|
|
||||||
|
|
||||||
export interface RenderOptions {
|
|
||||||
skipParagraph?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderMarkdown(source: string, opts: RenderOptions = {}): string {
|
|
||||||
if (!source) return '';
|
|
||||||
const raw = marked.parse(source, { async: false }) as string;
|
|
||||||
let html = DOMPurify.sanitize(raw, {
|
|
||||||
// `mark` for cloze highlights; `audio`/`source`/`video` for media
|
|
||||||
// attachments inserted via the editor (the Markdown renderer
|
|
||||||
// passes inline HTML through, sanitizer is the gate).
|
|
||||||
ADD_TAGS: ['mark', 'audio', 'source', 'video'],
|
|
||||||
ADD_ATTR: ['class', 'controls', 'preload', 'src', 'type'],
|
|
||||||
});
|
|
||||||
if (opts.skipParagraph) {
|
|
||||||
html = html.replace(/^\s*<p>/, '').replace(/<\/p>\s*$/, '');
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
/**
|
|
||||||
* Cardecky — shared types.
|
|
||||||
*
|
|
||||||
* Used by both the mana cards module (apps/mana/.../modules/cards/) and
|
|
||||||
* the cardecky.mana.how standalone app. Pure type definitions, no runtime
|
|
||||||
* imports beyond `BaseRecord` and `VisibilityLevel` from the shared
|
|
||||||
* Mana packages.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseRecord } from '@mana/local-store';
|
|
||||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
|
||||||
|
|
||||||
// ─── Card Types ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discriminator for the card's render/learn behaviour. Phase 1 ships
|
|
||||||
* basic, basic-reverse, cloze, type-in. Future types are reserved here
|
|
||||||
* so storage already understands them — UI can light up later without
|
|
||||||
* a schema change.
|
|
||||||
*/
|
|
||||||
export type CardType =
|
|
||||||
| 'basic'
|
|
||||||
| 'basic-reverse'
|
|
||||||
| 'cloze'
|
|
||||||
| 'type-in'
|
|
||||||
| 'image-occlusion'
|
|
||||||
| 'audio'
|
|
||||||
| 'multiple-choice';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Free-form key/value bag with the user-typed content. Schema by type:
|
|
||||||
* basic / basic-reverse / type-in: { front, back }
|
|
||||||
* cloze: { text, extra? }
|
|
||||||
*/
|
|
||||||
export type CardFields = Record<string, string>;
|
|
||||||
|
|
||||||
// ─── Local (IndexedDB) Records ─────────────────────────────
|
|
||||||
|
|
||||||
export interface LocalDeck extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
color: string;
|
|
||||||
cardCount: number;
|
|
||||||
lastStudied?: string | null;
|
|
||||||
visibility?: VisibilityLevel;
|
|
||||||
visibilityChangedAt?: string;
|
|
||||||
visibilityChangedBy?: string;
|
|
||||||
activeStudyBlockId?: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marketplace-subscription markers. Set on decks that the user
|
|
||||||
* pulled from cardecky.mana.how/d/<slug> rather than created
|
|
||||||
* themselves. The pair (slug + version) lets the client compute
|
|
||||||
* a smart-merge diff against the server's latest version.
|
|
||||||
*
|
|
||||||
* Subscribed decks are read-only locally — the editor hides its
|
|
||||||
* mutate buttons. Forking instead makes a separate own-deck row.
|
|
||||||
*/
|
|
||||||
subscribedFromSlug?: string;
|
|
||||||
subscribedAtVersion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalCard extends BaseRecord {
|
|
||||||
deckId: string;
|
|
||||||
type?: CardType;
|
|
||||||
fields?: CardFields;
|
|
||||||
order: number;
|
|
||||||
|
|
||||||
// Legacy columns (pre-Phase-0). Still written for basic cards so
|
|
||||||
// older mana-app builds keep rendering.
|
|
||||||
front?: string;
|
|
||||||
back?: string;
|
|
||||||
difficulty?: number;
|
|
||||||
nextReview?: string | null;
|
|
||||||
reviewCount?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For cards pulled from a marketplace subscription: the server-
|
|
||||||
* computed SHA-256 of (type, fields). Powers smart-merge — when
|
|
||||||
* an updated version arrives, cards whose hash matches keep their
|
|
||||||
* FSRS state; cards whose hash changes get refreshed content but
|
|
||||||
* also keep their FSRS state (better for the learner than a reset).
|
|
||||||
*/
|
|
||||||
serverContentHash?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FSRS state for one *learnable unit*. A basic card has one review
|
|
||||||
* (subIndex=0). A basic-reverse card has two (0=front→back, 1=back→front).
|
|
||||||
* A cloze card has one per cluster (1=c1, 2=c2, …).
|
|
||||||
*
|
|
||||||
* Plaintext on purpose: the scheduler must query by `due` to find what's
|
|
||||||
* fällig today.
|
|
||||||
*/
|
|
||||||
export interface LocalCardReview extends BaseRecord {
|
|
||||||
cardId: string;
|
|
||||||
subIndex: number;
|
|
||||||
state: 'new' | 'learning' | 'review' | 'relearning';
|
|
||||||
stability: number;
|
|
||||||
difficulty: number;
|
|
||||||
due: string;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
lastReview?: string;
|
|
||||||
elapsedDays: number;
|
|
||||||
scheduledDays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Daily aggregate of learning activity for streak + stats. One row per
|
|
||||||
* user per local date. All plaintext (numbers + dates).
|
|
||||||
*/
|
|
||||||
export interface LocalCardStudyBlock extends BaseRecord {
|
|
||||||
date: string;
|
|
||||||
cardsReviewed: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── View Types (DTOs returned to UI) ──────────────────────
|
|
||||||
|
|
||||||
export interface Deck {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
color: string;
|
|
||||||
visibility: VisibilityLevel;
|
|
||||||
tags: string[];
|
|
||||||
cardCount: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
/** Marketplace slug if this deck was pulled from a subscription. */
|
|
||||||
subscribedFromSlug?: string;
|
|
||||||
/** Semver of the subscribed-from version that's currently local. */
|
|
||||||
subscribedAtVersion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Card {
|
|
||||||
id: string;
|
|
||||||
deckId: string;
|
|
||||||
type: CardType;
|
|
||||||
fields: CardFields;
|
|
||||||
order: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
|
|
||||||
front: string;
|
|
||||||
back: string;
|
|
||||||
|
|
||||||
/** @deprecated legacy DTO field — read from cardReviews going forward. */
|
|
||||||
difficulty?: number;
|
|
||||||
/** @deprecated legacy DTO field — read from cardReviews going forward. */
|
|
||||||
nextReview?: string;
|
|
||||||
/** @deprecated legacy DTO field — read from cardReviews going forward. */
|
|
||||||
reviewCount?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For cards from a subscribed deck: the server's content-hash for
|
|
||||||
* the card as it was published. The PR-creation flow uses this as
|
|
||||||
* `previousContentHash` when proposing a "modify" diff.
|
|
||||||
*/
|
|
||||||
serverContentHash?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CardReview {
|
|
||||||
id: string;
|
|
||||||
cardId: string;
|
|
||||||
subIndex: number;
|
|
||||||
state: LocalCardReview['state'];
|
|
||||||
stability: number;
|
|
||||||
difficulty: number;
|
|
||||||
due: string;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
lastReview?: string;
|
|
||||||
elapsedDays: number;
|
|
||||||
scheduledDays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Inputs ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface CreateDeckInput {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDeckInput {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCardInput {
|
|
||||||
deckId: string;
|
|
||||||
type?: CardType;
|
|
||||||
fields?: CardFields;
|
|
||||||
front?: string;
|
|
||||||
back?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCardInput {
|
|
||||||
type?: CardType;
|
|
||||||
fields?: CardFields;
|
|
||||||
front?: string;
|
|
||||||
back?: string;
|
|
||||||
order?: number;
|
|
||||||
/** @deprecated legacy field — use cardReviews going forward. */
|
|
||||||
difficulty?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-grading scale used by the learner during a session. Values match
|
|
||||||
* FSRS's Rating enum (1=Again, 2=Hard, 3=Good, 4=Easy).
|
|
||||||
*/
|
|
||||||
export type ReviewGrade = 1 | 2 | 3 | 4;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022", "DOM"],
|
|
||||||
"types": ["node"],
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue