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:
Till JS 2026-05-08 20:27:33 +02:00
parent bc158cb0bc
commit dd1bab09d5
10 changed files with 0 additions and 699 deletions

View file

@ -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"
}
}

View file

@ -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]);
});
});

View file

@ -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=frontback, 1=backfront)
* - 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];
}
}

View file

@ -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('&lt;script&gt;');
});
});

View file

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

View file

@ -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;
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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=frontback, 1=backfront).
* 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;

View file

@ -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"]
}