mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +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