@thesimonharms/basa 0.1.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/LICENSE +21 -0
- package/README.md +161 -0
- package/bin/basa.js +4 -0
- package/config/app.ts +8 -0
- package/package.json +54 -0
- package/src/commands/list.command.ts +65 -0
- package/src/commands/new.command.ts +25 -0
- package/src/commands/study.command.ts +84 -0
- package/src/flashcards/deck.ts +218 -0
- package/src/flashcards/halfblock.ts +55 -0
- package/src/flashcards/image.ts +97 -0
- package/src/flashcards/render.ts +123 -0
- package/src/flashcards/sound.ts +115 -0
- package/src/flashcards/srs.ts +88 -0
- package/src/flashcards/types.ts +85 -0
- package/src/providers/AppProvider.ts +14 -0
- package/src/tui/CardView.ts +281 -0
- package/src/tui/Footer.ts +65 -0
- package/src/tui/Header.ts +58 -0
- package/src/tui/StudyApp.ts +147 -0
- package/src/tui/effects.ts +81 -0
- package/test/CardView.test.ts +157 -0
- package/test/deck.test.ts +144 -0
- package/test/effects.test.ts +51 -0
- package/test/halfblock.test.ts +71 -0
- package/test/sound.test.ts +27 -0
- package/test/srs.test.ts +85 -0
- package/test/study.command.test.ts +26 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RgbaImage } from './image.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Encode an RGBA pixel buffer as ANSI truecolor half-block cells. Each
|
|
5
|
+
* terminal cell represents two vertical pixels using `▀` with the top pixel
|
|
6
|
+
* as the foreground and the bottom as the background. Works in any
|
|
7
|
+
* truecolor terminal — no Kitty/SIXEL needed.
|
|
8
|
+
*/
|
|
9
|
+
export function halfBlockLines(image: RgbaImage): string[] {
|
|
10
|
+
const { width, height, pixels } = image;
|
|
11
|
+
const out: string[] = [];
|
|
12
|
+
let row = 0;
|
|
13
|
+
while (row < height) {
|
|
14
|
+
let line = '';
|
|
15
|
+
for (let col = 0; col < width; col++) {
|
|
16
|
+
const top = pixelAt(pixels, width, height, col, row);
|
|
17
|
+
const bottom = row + 1 < height ? pixelAt(pixels, width, height, col, row + 1) : top;
|
|
18
|
+
line += `\x1b[38;2;${top.r};${top.g};${top.b};48;2;${bottom.r};${bottom.g};${bottom.b}m▀`;
|
|
19
|
+
}
|
|
20
|
+
line += '\x1b[0m';
|
|
21
|
+
out.push(line);
|
|
22
|
+
row += 2;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pixelAt(pixels: Uint8Array, width: number, height: number, x: number, y: number): { r: number; g: number; b: number } {
|
|
28
|
+
if (x < 0 || y < 0 || x >= width || y >= height) return { r: 0, g: 0, b: 0 };
|
|
29
|
+
const offset = (y * width + x) * 4;
|
|
30
|
+
return {
|
|
31
|
+
r: pixels[offset] ?? 0,
|
|
32
|
+
g: pixels[offset + 1] ?? 0,
|
|
33
|
+
b: pixels[offset + 2] ?? 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pick a width and height in pixels that fit a given cell budget. */
|
|
38
|
+
export function fitToCells(
|
|
39
|
+
sourceWidth: number,
|
|
40
|
+
sourceHeight: number,
|
|
41
|
+
maxCellsWidth: number,
|
|
42
|
+
maxCellsHeight: number,
|
|
43
|
+
): { width: number; height: number } {
|
|
44
|
+
if (sourceWidth === 0 || sourceHeight === 0) return { width: 1, height: 1 };
|
|
45
|
+
const aspect = sourceWidth / sourceHeight;
|
|
46
|
+
// Half-block makes 1 cell represent 2 vertical pixels → effective aspect per cell is 0.5.
|
|
47
|
+
const cellAspect = (maxCellsWidth / maxCellsHeight) * 0.5;
|
|
48
|
+
let cellsH = maxCellsHeight;
|
|
49
|
+
let cellsW = Math.round(cellsH * aspect / 0.5);
|
|
50
|
+
if (cellsW > maxCellsWidth) {
|
|
51
|
+
cellsW = maxCellsWidth;
|
|
52
|
+
cellsH = Math.round(cellsW * 0.5 / aspect);
|
|
53
|
+
}
|
|
54
|
+
return { width: cellsW, height: cellsH * 2 };
|
|
55
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolveMediaPath } from './deck.js';
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
6
|
+
|
|
7
|
+
/** True if a path looks like a relative/absolute reference to an image file. */
|
|
8
|
+
export function isImagePath(ref: string): boolean {
|
|
9
|
+
const lower = ref.toLowerCase();
|
|
10
|
+
for (const ext of IMAGE_EXTS) {
|
|
11
|
+
if (lower.endsWith(ext)) return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RgbaImage {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
/** Row-major RGBA bytes. */
|
|
20
|
+
pixels: Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decode an image to raw RGBA at a given pixel size, using ImageMagick if
|
|
25
|
+
* available. The function does NOT throw if the tool is missing or the file
|
|
26
|
+
* is broken — it returns `null` and the caller renders a placeholder.
|
|
27
|
+
*/
|
|
28
|
+
export async function loadRgba(file: string, width: number, height: number): Promise<RgbaImage | null> {
|
|
29
|
+
if (!existsSync(file)) return null;
|
|
30
|
+
const tool = findMagick();
|
|
31
|
+
if (tool === null) return null;
|
|
32
|
+
|
|
33
|
+
return new Promise<RgbaImage | null>((resolve) => {
|
|
34
|
+
const args = [
|
|
35
|
+
tool,
|
|
36
|
+
file,
|
|
37
|
+
'-resize',
|
|
38
|
+
`${Math.max(1, width)}x${Math.max(1, height)}!`,
|
|
39
|
+
'-depth',
|
|
40
|
+
'8',
|
|
41
|
+
'rgba:-',
|
|
42
|
+
];
|
|
43
|
+
const child = spawn(args[0]!, args.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
44
|
+
const chunks: Buffer[] = [];
|
|
45
|
+
let total = 0;
|
|
46
|
+
let settled = false;
|
|
47
|
+
|
|
48
|
+
const finish = (value: RgbaImage | null) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
child.kill();
|
|
52
|
+
resolve(value);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
child.stdout!.on('data', (chunk: Buffer) => {
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
total += chunk.length;
|
|
58
|
+
});
|
|
59
|
+
child.on('error', () => finish(null));
|
|
60
|
+
child.on('close', (code) => {
|
|
61
|
+
if (code !== 0) return finish(null);
|
|
62
|
+
if (total !== width * height * 4) return finish(null);
|
|
63
|
+
const buf = Buffer.concat(chunks, total);
|
|
64
|
+
resolve({ width, height, pixels: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findMagick(): string | null {
|
|
70
|
+
for (const candidate of ['magick', 'convert']) {
|
|
71
|
+
if (probeOnPath(candidate)) return candidate;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function probeOnPath(cmd: string): boolean {
|
|
77
|
+
const path = process.env.PATH ?? '';
|
|
78
|
+
for (const dir of path.split(':')) {
|
|
79
|
+
if (dir.length === 0) continue;
|
|
80
|
+
if (existsSync(`${dir}/${cmd}`)) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Auto-detect whether a `Side` is "complex enough" that image rendering helps. */
|
|
86
|
+
export function isLikelyComplexScript(text: string): boolean {
|
|
87
|
+
// CJK, Hangul, Hiragana/Katakana, Devanagari, Arabic, Hebrew, Thai, Tibetan.
|
|
88
|
+
// The user is learning one of these alphabets → render the card as an image
|
|
89
|
+
// so the terminal fonts don't fight us. (This requires the user to supply
|
|
90
|
+
// a font-rendered PNG of the text; the renderer falls back to plain text.)
|
|
91
|
+
return /[\u3000-\u9fff\uac00-\ud7af\u0900-\u097f\u0600-\u06ff\u0590-\u05ff\u0e00-\u0e7f\u0f00-\u0fff]/.test(text);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Resolve a media reference against the deck file's directory. */
|
|
95
|
+
export function resolveDeckMedia(deckPath: string, ref: string): string {
|
|
96
|
+
return resolveMediaPath(deckPath, ref);
|
|
97
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import type { Field, Side } from './types.js';
|
|
4
|
+
import { isImagePath, loadRgba } from './image.js';
|
|
5
|
+
import { halfBlockLines } from './halfblock.js';
|
|
6
|
+
|
|
7
|
+
export interface RenderContext {
|
|
8
|
+
deckPath: string;
|
|
9
|
+
/** Max width in cells available for the card face. */
|
|
10
|
+
cellsWidth: number;
|
|
11
|
+
/** Max height in cells. */
|
|
12
|
+
cellsHeight: number;
|
|
13
|
+
/** Reused so we don't reload + re-resize the same image every frame. */
|
|
14
|
+
imageCache: Map<string, Promise<string[] | null>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RenderedSide {
|
|
18
|
+
/** Plain text rows (no images). Always present, useful for animations. */
|
|
19
|
+
text: string[];
|
|
20
|
+
/** Pre-rendered image rows (already half-block ANSI strings). Empty if no images. */
|
|
21
|
+
imageRows: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a `Side` to text + image rows. Text rows are returned synchronously
|
|
26
|
+
* (so card reveals are instant). Image rows resolve asynchronously and
|
|
27
|
+
* populate the cache.
|
|
28
|
+
*/
|
|
29
|
+
export async function renderSide(side: Side, ctx: RenderContext): Promise<RenderedSide> {
|
|
30
|
+
const fields = normalizeSide(side);
|
|
31
|
+
const textRows: string[] = [];
|
|
32
|
+
const imageRows: string[] = [];
|
|
33
|
+
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
if (field.text !== undefined) {
|
|
36
|
+
for (const row of wrapText(field.text, ctx.cellsWidth)) {
|
|
37
|
+
textRows.push(row);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (field.image !== undefined) {
|
|
41
|
+
const rendered = await renderImageField(field.image, ctx);
|
|
42
|
+
if (rendered !== null) {
|
|
43
|
+
// Leave a blank line before an image if we already have text.
|
|
44
|
+
if (textRows.length > 0) textRows.push('');
|
|
45
|
+
imageRows.push(...rendered);
|
|
46
|
+
} else {
|
|
47
|
+
textRows.push(`[image missing: ${field.image}]`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { text: textRows, imageRows };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeSide(side: Side): Field[] {
|
|
56
|
+
if (typeof side === 'string') return [{ text: side }];
|
|
57
|
+
return side;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wrapText(text: string, width: number): string[] {
|
|
61
|
+
if (width <= 0) return [text];
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
for (const paragraph of text.split('\n')) {
|
|
64
|
+
if (paragraph.length === 0) {
|
|
65
|
+
out.push('');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Greedy wrap on whitespace; preserves CJK by breaking on any char.
|
|
69
|
+
const isWide = /[\u3000-\u9fff\uff00-\uffef]/.test(paragraph);
|
|
70
|
+
if (isWide) {
|
|
71
|
+
for (let i = 0; i < paragraph.length; i += width) {
|
|
72
|
+
out.push(paragraph.slice(i, i + width));
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
const words = paragraph.split(/(\s+)/);
|
|
76
|
+
let line = '';
|
|
77
|
+
for (const word of words) {
|
|
78
|
+
if (line.length + word.length > width && line.length > 0) {
|
|
79
|
+
out.push(line);
|
|
80
|
+
line = word.trimStart();
|
|
81
|
+
if (line.length > width) {
|
|
82
|
+
// Long word: hard-split.
|
|
83
|
+
for (let i = 0; i < line.length; i += width) {
|
|
84
|
+
out.push(line.slice(i, i + width));
|
|
85
|
+
}
|
|
86
|
+
line = '';
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
line += word;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (line.length > 0) out.push(line);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function renderImageField(ref: string, ctx: RenderContext): Promise<string[] | null> {
|
|
99
|
+
const cached = ctx.imageCache.get(ref);
|
|
100
|
+
if (cached !== undefined) return cached;
|
|
101
|
+
const promise = (async () => {
|
|
102
|
+
if (!isImagePath(ref)) return null;
|
|
103
|
+
const resolved = resolveRef(ctx.deckPath, ref);
|
|
104
|
+
if (!existsSync(resolved)) return null;
|
|
105
|
+
const size = pickImageSize(ctx.cellsWidth, ctx.cellsHeight);
|
|
106
|
+
const rgba = await loadRgba(resolved, size.width, size.height);
|
|
107
|
+
if (rgba === null) return null;
|
|
108
|
+
return halfBlockLines(rgba);
|
|
109
|
+
})();
|
|
110
|
+
ctx.imageCache.set(ref, promise);
|
|
111
|
+
return promise;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveRef(deckPath: string, ref: string): string {
|
|
115
|
+
if (ref.startsWith('~')) return ref.replace(/^~/, process.env.HOME ?? '');
|
|
116
|
+
if (isAbsolute(ref)) return ref;
|
|
117
|
+
return resolve(dirname(deckPath), ref);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pickImageSize(cellsWidth: number, cellsHeight: number): { width: number; height: number } {
|
|
121
|
+
// ImageMagick does the resize for us, so we just hand it the cell size.
|
|
122
|
+
return { width: cellsWidth, height: cellsHeight * 2 };
|
|
123
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { AudioOut, detectAudio, silentRequested, type AudioClip, type AudioDetection } from '@mudah-cli/audio';
|
|
2
|
+
import type { Grade } from './types.js';
|
|
3
|
+
|
|
4
|
+
const SAMPLE_RATE = 44100;
|
|
5
|
+
|
|
6
|
+
/** A BasaFx is a tiny sound effect player with two one-shots. */
|
|
7
|
+
export class BasaFx {
|
|
8
|
+
private audio: AudioOut | null = null;
|
|
9
|
+
readonly detection: AudioDetection | null;
|
|
10
|
+
|
|
11
|
+
private constructor(audio: AudioOut | null, detection: AudioDetection | null) {
|
|
12
|
+
this.audio = audio;
|
|
13
|
+
this.detection = detection;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Probe + open. Returns a silent instance if no backend is available. */
|
|
17
|
+
static async open(): Promise<BasaFx> {
|
|
18
|
+
if (silentRequested()) return new BasaFx(null, null);
|
|
19
|
+
const detection = detectAudio();
|
|
20
|
+
if (detection.backend === 'silent') return new BasaFx(null, detection);
|
|
21
|
+
try {
|
|
22
|
+
const audio = await AudioOut.open({ sampleRate: SAMPLE_RATE, channels: 1 });
|
|
23
|
+
return new BasaFx(audio, detection);
|
|
24
|
+
} catch {
|
|
25
|
+
return new BasaFx(null, detection);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get isLive(): boolean {
|
|
30
|
+
return this.audio !== null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Play the "you got it" sound. Grade 2 (Good) and 3 (Easy) → happy ding. */
|
|
34
|
+
async playCorrect(grade: Grade): Promise<void> {
|
|
35
|
+
if (this.audio === null) return;
|
|
36
|
+
const clip = grade === 3 ? happyChirp() : ding();
|
|
37
|
+
await this.audio.play(clip);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Play the "not quite" sound. Grade 0 (Again) → low buzz. */
|
|
41
|
+
async playIncorrect(): Promise<void> {
|
|
42
|
+
if (this.audio === null) return;
|
|
43
|
+
await this.audio.play(buzz());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async dispose(): Promise<void> {
|
|
47
|
+
this.audio?.dispose();
|
|
48
|
+
this.audio = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A two-note ascending arpeggio (C5 → E5). Quick, bright, low-volume.
|
|
54
|
+
* 0.18s total.
|
|
55
|
+
*/
|
|
56
|
+
function ding(): AudioClip {
|
|
57
|
+
const samples = renderSequence([
|
|
58
|
+
{ freq: 523.25, durationMs: 90, volume: 0.4 },
|
|
59
|
+
{ freq: 659.25, durationMs: 90, volume: 0.4 },
|
|
60
|
+
]);
|
|
61
|
+
return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Three ascending notes (C5 → E5 → G5). Triumphant. 0.30s total. */
|
|
65
|
+
function happyChirp(): AudioClip {
|
|
66
|
+
const samples = renderSequence([
|
|
67
|
+
{ freq: 523.25, durationMs: 80, volume: 0.45 },
|
|
68
|
+
{ freq: 659.25, durationMs: 80, volume: 0.45 },
|
|
69
|
+
{ freq: 783.99, durationMs: 140, volume: 0.5 },
|
|
70
|
+
]);
|
|
71
|
+
return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Two low square-ish pulses. 0.20s total. */
|
|
75
|
+
function buzz(): AudioClip {
|
|
76
|
+
const samples = renderSequence([
|
|
77
|
+
{ freq: 196.0, durationMs: 90, volume: 0.35, pulse: true },
|
|
78
|
+
{ freq: 164.81, durationMs: 90, volume: 0.35, pulse: true },
|
|
79
|
+
]);
|
|
80
|
+
return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Note {
|
|
84
|
+
freq: number;
|
|
85
|
+
durationMs: number;
|
|
86
|
+
volume: number;
|
|
87
|
+
pulse?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderSequence(notes: Note[]): Int16Array {
|
|
91
|
+
const total = notes.reduce((sum, n) => sum + Math.ceil((n.durationMs / 1000) * SAMPLE_RATE), 0);
|
|
92
|
+
const out = new Int16Array(total);
|
|
93
|
+
let cursor = 0;
|
|
94
|
+
for (const note of notes) {
|
|
95
|
+
const frames = Math.ceil((note.durationMs / 1000) * SAMPLE_RATE);
|
|
96
|
+
for (let i = 0; i < frames; i++) {
|
|
97
|
+
const t = i / SAMPLE_RATE;
|
|
98
|
+
const envelope = triangle(i, frames);
|
|
99
|
+
const value = Math.sin(2 * Math.PI * note.freq * t) * envelope * note.volume;
|
|
100
|
+
const square = note.pulse ? (Math.sin(2 * Math.PI * note.freq * t) >= 0 ? 1 : -1) : 0;
|
|
101
|
+
const mixed = note.pulse ? 0.6 * value + 0.4 * square * note.volume * envelope : value;
|
|
102
|
+
out[cursor + i] = Math.max(-1, Math.min(1, mixed)) * 0x7fff;
|
|
103
|
+
}
|
|
104
|
+
cursor += frames;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function triangle(i: number, total: number): number {
|
|
110
|
+
// Quick attack, slow release: 0..0.05 then linear decay to 0.
|
|
111
|
+
const attack = 0.05;
|
|
112
|
+
const t = i / total;
|
|
113
|
+
if (t < attack) return t / attack;
|
|
114
|
+
return Math.max(0, 1 - (t - attack) / (1 - attack));
|
|
115
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Grade, SrsState } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** A relaxed SM-2 implementation. Reasonable defaults, no anki-parity goal. */
|
|
4
|
+
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export function freshState(now: number = Date.now()): SrsState {
|
|
8
|
+
// `due: null` means "due immediately" — see `isDue`. Using a captured
|
|
9
|
+
// timestamp here is racey when the caller samples `Date.now()` again
|
|
10
|
+
// later (e.g. the list command compares state.due against a `now`
|
|
11
|
+
// sampled before the loop, while the loop's `loadReviewCards` calls
|
|
12
|
+
// freshState() for each card with progressively later timestamps).
|
|
13
|
+
void now;
|
|
14
|
+
return {
|
|
15
|
+
due: null,
|
|
16
|
+
streak: 0,
|
|
17
|
+
ease: 2.5,
|
|
18
|
+
intervalDays: 0,
|
|
19
|
+
reviews: 0,
|
|
20
|
+
lastGrade: null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apply a grade to a card's state and return the next state.
|
|
26
|
+
* Pure function — no I/O, no randomness — so it's trivially testable.
|
|
27
|
+
*/
|
|
28
|
+
export function grade(state: SrsState, grade: Grade, now: number = Date.now()): SrsState {
|
|
29
|
+
const next: SrsState = {
|
|
30
|
+
due: state.due,
|
|
31
|
+
streak: state.streak,
|
|
32
|
+
ease: state.ease,
|
|
33
|
+
intervalDays: state.intervalDays,
|
|
34
|
+
reviews: state.reviews + 1,
|
|
35
|
+
lastGrade: grade,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (grade === 0) {
|
|
39
|
+
// Again: reset streak, ease drops, see it again in 10 minutes.
|
|
40
|
+
next.streak = 0;
|
|
41
|
+
next.ease = Math.max(1.3, state.ease - 0.2);
|
|
42
|
+
next.intervalDays = 0;
|
|
43
|
+
next.due = now + 10 * 60 * 1000;
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
next.streak = state.streak + 1;
|
|
48
|
+
// SM-2 ease adjustment for non-failing grades. Hard (1) drops ease slightly;
|
|
49
|
+
// Easy (3) bumps it; Good (2) holds.
|
|
50
|
+
const easeDelta = grade === 1 ? -0.15 : grade === 3 ? 0.15 : 0;
|
|
51
|
+
next.ease = clamp(state.ease + easeDelta, 1.3, 2.8);
|
|
52
|
+
|
|
53
|
+
if (next.streak === 1) {
|
|
54
|
+
next.intervalDays = 1;
|
|
55
|
+
} else if (next.streak === 2) {
|
|
56
|
+
next.intervalDays = 6;
|
|
57
|
+
} else {
|
|
58
|
+
next.intervalDays = Math.max(1, Math.round(state.intervalDays * next.ease));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
next.due = now + next.intervalDays * DAY_MS;
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clamp(value: number, lo: number, hi: number): number {
|
|
66
|
+
return Math.max(lo, Math.min(hi, value));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Cards that are due now or earlier. `null` `due` is treated as "due immediately". */
|
|
70
|
+
export function isDue(state: SrsState, now: number = Date.now()): boolean {
|
|
71
|
+
if (state.due === null) return true;
|
|
72
|
+
return state.due <= now;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pick the next due card, ordered by oldest-due first, with new cards last. */
|
|
76
|
+
export function pickNext<T extends { state: SrsState }>(
|
|
77
|
+
cards: readonly T[],
|
|
78
|
+
now: number = Date.now(),
|
|
79
|
+
): T | undefined {
|
|
80
|
+
const due = cards.filter((c) => isDue(c.state, now));
|
|
81
|
+
if (due.length === 0) return undefined;
|
|
82
|
+
const sorted = [...due].sort((a, b) => {
|
|
83
|
+
const ad = a.state.due ?? 0;
|
|
84
|
+
const bd = b.state.due ?? 0;
|
|
85
|
+
return ad - bd;
|
|
86
|
+
});
|
|
87
|
+
return sorted[0];
|
|
88
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open flashcard format.
|
|
3
|
+
*
|
|
4
|
+
* A deck is a single file (YAML or JSON) with this shape:
|
|
5
|
+
*
|
|
6
|
+
* name: "Spanish 101"
|
|
7
|
+
* description: "First-year Spanish vocabulary"
|
|
8
|
+
* cards:
|
|
9
|
+
* - front: "hola"
|
|
10
|
+
* back: "hello"
|
|
11
|
+
* - front: "perro"
|
|
12
|
+
* back: "dog"
|
|
13
|
+
* - front:
|
|
14
|
+
* text: "ありがとう"
|
|
15
|
+
* image: "./assets/thanks.png" # optional
|
|
16
|
+
* back:
|
|
17
|
+
* text: "thank you"
|
|
18
|
+
*
|
|
19
|
+
* Each side is either a plain string (a single text field) or a list of
|
|
20
|
+
* fields, where each field is `{ text?, image?, audio? }`. The renderer walks
|
|
21
|
+
* the list top-to-bottom, leaving a blank line between fields. An `image`
|
|
22
|
+
* field is rendered with half-block cells (universal truecolor); a `text`
|
|
23
|
+
* field is rendered verbatim. An `audio` field is a relative path to a WAV
|
|
24
|
+
* that the user can play with `p` while reviewing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface Field {
|
|
28
|
+
text?: string;
|
|
29
|
+
image?: string;
|
|
30
|
+
audio?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Side = string | Field[];
|
|
34
|
+
|
|
35
|
+
export interface Card {
|
|
36
|
+
front: Side;
|
|
37
|
+
back: Side;
|
|
38
|
+
/**
|
|
39
|
+
* Optional per-card hints. The SRS engine reads `tags` for filtering; the
|
|
40
|
+
* UI reads `hint` to show a small prompt after a wrong answer.
|
|
41
|
+
*/
|
|
42
|
+
tags?: string[];
|
|
43
|
+
hint?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Deck {
|
|
47
|
+
name: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
cards: Card[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SrsState {
|
|
53
|
+
/** Next due time, ms since epoch. `null` means "not started". */
|
|
54
|
+
due: number | null;
|
|
55
|
+
/** Number of successful reviews in a row. Resets to 0 on `again`. */
|
|
56
|
+
streak: number;
|
|
57
|
+
/** SM-2 ease factor, 1.3 .. 2.8. */
|
|
58
|
+
ease: number;
|
|
59
|
+
/** Current interval in days. */
|
|
60
|
+
intervalDays: number;
|
|
61
|
+
/** Total times this card has been shown. */
|
|
62
|
+
reviews: number;
|
|
63
|
+
/** Last grade given (0..3). */
|
|
64
|
+
lastGrade: 0 | 1 | 2 | 3 | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A card together with its review state. */
|
|
68
|
+
export interface ReviewCard {
|
|
69
|
+
card: Card;
|
|
70
|
+
state: SrsState;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** SM-2-style grades. */
|
|
74
|
+
export type Grade = 0 | 1 | 2 | 3;
|
|
75
|
+
|
|
76
|
+
export const GRADE_LABELS: readonly string[] = ['Again', 'Hard', 'Good', 'Easy'];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a user actually answered. The text input is free-form; the rating bar
|
|
80
|
+
* is graded. We capture both so the UI can show "you typed X, the answer was Y".
|
|
81
|
+
*/
|
|
82
|
+
export interface AnswerRecord {
|
|
83
|
+
typed: string;
|
|
84
|
+
grade: Grade;
|
|
85
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ServiceProvider } from '@mudah-cli/mudah';
|
|
2
|
+
|
|
3
|
+
export default class BasaProvider extends ServiceProvider {
|
|
4
|
+
register(): void {
|
|
5
|
+
this.app.config().merge('app', {
|
|
6
|
+
name: 'basa',
|
|
7
|
+
env: 'local',
|
|
8
|
+
// Default location for user decks (~/.config/basa/decks).
|
|
9
|
+
decksDir: '~/basa/decks',
|
|
10
|
+
// Optional sound effects: 'on' | 'off' | 'auto'.
|
|
11
|
+
sound: 'auto',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|