@thesimonharms/basa 0.1.0 → 0.1.2
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/bin/basa.js +62 -1
- package/dist/commands/list.command.js +59 -0
- package/dist/commands/new.command.js +24 -0
- package/dist/commands/study.command.js +78 -0
- package/dist/flashcards/deck.js +207 -0
- package/dist/flashcards/halfblock.js +48 -0
- package/dist/flashcards/image.js +89 -0
- package/dist/flashcards/render.js +110 -0
- package/dist/flashcards/sound.js +100 -0
- package/dist/flashcards/srs.js +77 -0
- package/dist/flashcards/types.js +26 -0
- package/dist/providers/AppProvider.js +13 -0
- package/dist/tui/CardView.js +228 -0
- package/dist/tui/Footer.js +57 -0
- package/dist/tui/Header.js +48 -0
- package/dist/tui/StudyApp.js +120 -0
- package/dist/tui/effects.js +55 -0
- package/package.json +5 -5
- package/config/app.ts +0 -8
- package/src/commands/list.command.ts +0 -65
- package/src/commands/new.command.ts +0 -25
- package/src/commands/study.command.ts +0 -84
- package/src/flashcards/deck.ts +0 -218
- package/src/flashcards/halfblock.ts +0 -55
- package/src/flashcards/image.ts +0 -97
- package/src/flashcards/render.ts +0 -123
- package/src/flashcards/sound.ts +0 -115
- package/src/flashcards/srs.ts +0 -88
- package/src/flashcards/types.ts +0 -85
- package/src/providers/AppProvider.ts +0 -14
- package/src/tui/CardView.ts +0 -281
- package/src/tui/Footer.ts +0 -65
- package/src/tui/Header.ts +0 -58
- package/src/tui/StudyApp.ts +0 -147
- package/src/tui/effects.ts +0 -81
- package/test/CardView.test.ts +0 -157
- package/test/deck.test.ts +0 -144
- package/test/effects.test.ts +0 -51
- package/test/halfblock.test.ts +0 -71
- package/test/sound.test.ts +0 -27
- package/test/srs.test.ts +0 -85
- package/test/study.command.test.ts +0 -26
package/bin/basa.js
CHANGED
|
@@ -1,4 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Basa CLI shim. Boots the Mudah app kernel and points it at this
|
|
3
|
+
// package's own `dist/` for providers and commands, so a published
|
|
4
|
+
// install works from any working directory (not just the project root).
|
|
5
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { Application } from '@mudah-cli/core';
|
|
2
9
|
import { run } from '@mudah-cli/mudah';
|
|
3
10
|
|
|
4
|
-
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkgDir = dirname(here);
|
|
13
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
14
|
+
|
|
15
|
+
// Application discovery defaults to <basePath>/src/{commands,providers}.
|
|
16
|
+
// The published package ships compiled output under dist/, so we pre-load
|
|
17
|
+
// the built providers and command modules from there and inject them via
|
|
18
|
+
// run({providers, commands}). This is the supported escape hatch for
|
|
19
|
+
// packages that don't follow the scaffolded-app layout.
|
|
20
|
+
const distDir = join(pkgDir, 'dist');
|
|
21
|
+
const commandModules = await loadModules(join(distDir, 'commands'));
|
|
22
|
+
const providerModules = await loadModules(join(distDir, 'providers'));
|
|
23
|
+
|
|
24
|
+
const manifest = {
|
|
25
|
+
name: pkg.name,
|
|
26
|
+
version: pkg.version,
|
|
27
|
+
bin: 'basa',
|
|
28
|
+
ui: { theme: 'auto' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const app = new Application(pkgDir, manifest);
|
|
32
|
+
for (const mod of providerModules) {
|
|
33
|
+
const value = mod.default ?? mod;
|
|
34
|
+
if (typeof value === 'function') app.register(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const code = await run({
|
|
38
|
+
app,
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
manifest,
|
|
41
|
+
argv: process.argv.slice(2),
|
|
42
|
+
stdout: process.stdout,
|
|
43
|
+
stderr: process.stderr,
|
|
44
|
+
stdin: process.stdin,
|
|
45
|
+
commands: commandModules,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
process.exitCode = code;
|
|
49
|
+
|
|
50
|
+
async function loadModules(dir) {
|
|
51
|
+
const modules = [];
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return modules;
|
|
57
|
+
}
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isFile() || !/\.(c?js|mjs)$/.test(entry.name)) continue;
|
|
60
|
+
const file = join(dir, entry.name);
|
|
61
|
+
const mod = await import(pathToFileURL(file).href);
|
|
62
|
+
if (mod.default) modules.push(mod);
|
|
63
|
+
}
|
|
64
|
+
return modules;
|
|
65
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Command } from '@mudah-cli/mudah';
|
|
2
|
+
import { defaultDecksDir, listDecks, loadDeck, loadReviewCards } from '../flashcards/deck.js';
|
|
3
|
+
import { isDue } from '../flashcards/srs.js';
|
|
4
|
+
import { renderTable } from '@mudah-cli/ui';
|
|
5
|
+
export default class ListCommand extends Command {
|
|
6
|
+
signature = 'list [--all] [--dir=]';
|
|
7
|
+
description = 'List decks (and per-deck card counts)';
|
|
8
|
+
async handle() {
|
|
9
|
+
const flag = this.option('dir');
|
|
10
|
+
const dir = typeof flag === 'string' && flag.length > 0
|
|
11
|
+
? flag
|
|
12
|
+
: (this.app.config().get('app.decksDir') ?? defaultDecksDir());
|
|
13
|
+
const paths = await listDecks(dir);
|
|
14
|
+
if (paths.length === 0) {
|
|
15
|
+
this.output.muted(`No decks found in ${dir}.`);
|
|
16
|
+
this.output.hint(`Create one with \`basa new <name>\`, or pass --dir=<path>.`);
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
const showAll = this.option('all') === true;
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const rows = [];
|
|
22
|
+
let totalCards = 0;
|
|
23
|
+
let totalDue = 0;
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
const deck = await loadDeck(path);
|
|
26
|
+
const cards = await loadReviewCards(deck, path);
|
|
27
|
+
const due = cards.filter((c) => isDue(c.state, now)).length;
|
|
28
|
+
totalCards += cards.length;
|
|
29
|
+
totalDue += due;
|
|
30
|
+
const description = (deck.description ?? '').replace(/\s+/g, ' ').trim();
|
|
31
|
+
rows.push([
|
|
32
|
+
deck.name,
|
|
33
|
+
String(cards.length),
|
|
34
|
+
String(due),
|
|
35
|
+
truncate(description, 50),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
if (showAll) {
|
|
39
|
+
for (const path of paths)
|
|
40
|
+
this.output.muted(`• ${path}`);
|
|
41
|
+
this.output.raw('');
|
|
42
|
+
}
|
|
43
|
+
const columns = [
|
|
44
|
+
{ header: 'Deck', align: 'left' },
|
|
45
|
+
{ header: 'Cards', align: 'right' },
|
|
46
|
+
{ header: 'Due', align: 'right' },
|
|
47
|
+
{ header: 'Description', align: 'left' },
|
|
48
|
+
];
|
|
49
|
+
this.output.raw(renderTable(columns, rows, { level: 0, unicode: true }));
|
|
50
|
+
this.output.raw('');
|
|
51
|
+
this.output.muted(`${totalDue} of ${totalCards} cards due across ${paths.length} decks.`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function truncate(text, max) {
|
|
56
|
+
if (text.length <= max)
|
|
57
|
+
return text;
|
|
58
|
+
return `${text.slice(0, max - 1)}…`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { Command } from '@mudah-cli/mudah';
|
|
3
|
+
import { createDeck, defaultDecksDir, expandHome } from '../flashcards/deck.js';
|
|
4
|
+
export default class NewCommand extends Command {
|
|
5
|
+
signature = 'new {name} [--format=yaml] [--dir=]';
|
|
6
|
+
description = 'Create a new empty deck';
|
|
7
|
+
async handle() {
|
|
8
|
+
const name = this.arg('name');
|
|
9
|
+
if (name === undefined)
|
|
10
|
+
throw new Error('Deck name is required.');
|
|
11
|
+
const format = this.option('format') ?? 'yaml';
|
|
12
|
+
const flag = this.option('dir');
|
|
13
|
+
const baseDir = typeof flag === 'string' && flag.length > 0
|
|
14
|
+
? flag
|
|
15
|
+
: (this.app.config().get('app.decksDir') ?? defaultDecksDir());
|
|
16
|
+
const dir = expandHome(baseDir);
|
|
17
|
+
const ext = format === 'json' ? '.json' : '.yml';
|
|
18
|
+
const file = join(dir, `${name}${ext}`);
|
|
19
|
+
const path = await createDeck(file, name);
|
|
20
|
+
this.output.success(`Created ${path}`);
|
|
21
|
+
this.output.hint(`Edit it, then run \`basa study ${name}\`.`);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Command } from '@mudah-cli/mudah';
|
|
2
|
+
import { Program } from '@mudah-cli/tui';
|
|
3
|
+
import { loadDeck, loadReviewCards, resolveDeckPath, defaultDecksDir } from '../flashcards/deck.js';
|
|
4
|
+
import { BasaFx } from '../flashcards/sound.js';
|
|
5
|
+
import { StudyApp } from '../tui/StudyApp.js';
|
|
6
|
+
export default class StudyCommand extends Command {
|
|
7
|
+
signature = 'study {name?} [--no-sound] [--dir=]';
|
|
8
|
+
description = 'Open a flashcard deck in the terminal';
|
|
9
|
+
async handle() {
|
|
10
|
+
const decksDir = this.configDecksDir();
|
|
11
|
+
const deckPath = await resolveDeckPath(decksDir, this.arg('name'));
|
|
12
|
+
const deck = await loadDeck(deckPath);
|
|
13
|
+
const cards = await loadReviewCards(deck, deckPath);
|
|
14
|
+
const fx = await openFx(this.configSound());
|
|
15
|
+
let study;
|
|
16
|
+
let resizeListener;
|
|
17
|
+
const program = new Program({ mouse: true, keyboard: true, stdin: process.stdin });
|
|
18
|
+
const width = process.stdout.columns ?? 80;
|
|
19
|
+
const height = process.stdout.rows ?? 24;
|
|
20
|
+
study = new StudyApp({
|
|
21
|
+
deck,
|
|
22
|
+
deckPath,
|
|
23
|
+
cards,
|
|
24
|
+
width,
|
|
25
|
+
height,
|
|
26
|
+
fx,
|
|
27
|
+
});
|
|
28
|
+
program.mount(study.root);
|
|
29
|
+
// Repaint loop: Program repaints every 16ms via setInterval, but we
|
|
30
|
+
// want the StudyApp to also tick (advance animations, swap card).
|
|
31
|
+
const ticker = setInterval(() => {
|
|
32
|
+
study?.tick();
|
|
33
|
+
program.requestFrame();
|
|
34
|
+
}, 16);
|
|
35
|
+
// Handle terminal resize. Program's diff renderer adapts automatically
|
|
36
|
+
// to the new column count, but we need to re-render images and layout.
|
|
37
|
+
resizeListener = () => {
|
|
38
|
+
const w = process.stdout.columns ?? width;
|
|
39
|
+
const h = process.stdout.rows ?? height;
|
|
40
|
+
study?.resize(w, h);
|
|
41
|
+
program.requestFrame();
|
|
42
|
+
};
|
|
43
|
+
process.stdout.on('resize', resizeListener);
|
|
44
|
+
try {
|
|
45
|
+
return await program.run();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearInterval(ticker);
|
|
49
|
+
if (resizeListener !== undefined)
|
|
50
|
+
process.stdout.off('resize', resizeListener);
|
|
51
|
+
await study?.persist();
|
|
52
|
+
await fx.dispose();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
configDecksDir() {
|
|
56
|
+
const flag = this.option('dir');
|
|
57
|
+
if (typeof flag === 'string' && flag.length > 0)
|
|
58
|
+
return flag;
|
|
59
|
+
return this.app.config().get('app.decksDir') ?? defaultDecksDir();
|
|
60
|
+
}
|
|
61
|
+
configSound() {
|
|
62
|
+
const noSound = this.option('no-sound') === true;
|
|
63
|
+
if (noSound)
|
|
64
|
+
return 'off';
|
|
65
|
+
const value = this.app.config().get('app.sound');
|
|
66
|
+
if (value === 'on' || value === 'off' || value === 'auto')
|
|
67
|
+
return value;
|
|
68
|
+
return 'auto';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function openFx(setting) {
|
|
72
|
+
// BasaFx already handles all three settings via the same open() call:
|
|
73
|
+
// it probes for an audio backend and falls back to silent if none is
|
|
74
|
+
// available. The `setting` is reserved for future per-setting behavior
|
|
75
|
+
// (e.g. a sound-test command, a "play even in CI" override).
|
|
76
|
+
void setting;
|
|
77
|
+
return BasaFx.open();
|
|
78
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { freshState } from './srs.js';
|
|
7
|
+
export class DeckLoadError extends Error {
|
|
8
|
+
path;
|
|
9
|
+
constructor(message, path) {
|
|
10
|
+
super(`${path}: ${message}`);
|
|
11
|
+
this.path = path;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Resolve a leading `~` to the user's home directory. */
|
|
15
|
+
export function expandHome(p) {
|
|
16
|
+
if (p.startsWith('~/'))
|
|
17
|
+
return join(homedir(), p.slice(2));
|
|
18
|
+
if (p === '~')
|
|
19
|
+
return homedir();
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
/** The default location for user decks: `~/basa/decks`. */
|
|
23
|
+
export function defaultDecksDir() {
|
|
24
|
+
return join(homedir(), 'basa', 'decks');
|
|
25
|
+
}
|
|
26
|
+
/** Find every supported deck file in a directory (non-recursive). */
|
|
27
|
+
export async function listDecks(dir) {
|
|
28
|
+
const path = expandHome(dir);
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return [];
|
|
31
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
32
|
+
return entries
|
|
33
|
+
.filter((e) => e.isFile() && /\.(ya?ml|json)$/i.test(e.name))
|
|
34
|
+
.map((e) => join(path, e.name))
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
export async function loadDeck(file) {
|
|
38
|
+
const path = isAbsolute(file) ? file : expandHome(file);
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(path, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
throw new DeckLoadError(`cannot read file: ${err.message}`, path);
|
|
45
|
+
}
|
|
46
|
+
let parsed;
|
|
47
|
+
const ext = extname(path).toLowerCase();
|
|
48
|
+
try {
|
|
49
|
+
if (ext === '.json') {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
parsed = yaml.load(raw);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new DeckLoadError(`parse error: ${err.message}`, path);
|
|
58
|
+
}
|
|
59
|
+
return validateDeck(parsed, path);
|
|
60
|
+
}
|
|
61
|
+
function validateDeck(value, path) {
|
|
62
|
+
if (value === null || typeof value !== 'object') {
|
|
63
|
+
throw new DeckLoadError('deck must be a mapping (name + cards)', path);
|
|
64
|
+
}
|
|
65
|
+
const obj = value;
|
|
66
|
+
if (typeof obj.name !== 'string' || obj.name.length === 0) {
|
|
67
|
+
throw new DeckLoadError('deck.name must be a non-empty string', path);
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(obj.cards)) {
|
|
70
|
+
throw new DeckLoadError('deck.cards must be an array', path);
|
|
71
|
+
}
|
|
72
|
+
const cards = obj.cards.map((c, i) => validateCard(c, path, i));
|
|
73
|
+
return {
|
|
74
|
+
name: obj.name,
|
|
75
|
+
description: typeof obj.description === 'string' ? obj.description : undefined,
|
|
76
|
+
cards,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function validateCard(value, path, index) {
|
|
80
|
+
if (value === null || typeof value !== 'object') {
|
|
81
|
+
throw new DeckLoadError(`card #${index + 1} must be a mapping`, path);
|
|
82
|
+
}
|
|
83
|
+
const obj = value;
|
|
84
|
+
if (obj.front === undefined || obj.back === undefined) {
|
|
85
|
+
throw new DeckLoadError(`card #${index + 1} is missing front or back`, path);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
front: validateSide(obj.front, `card #${index + 1}.front`, path),
|
|
89
|
+
back: validateSide(obj.back, `card #${index + 1}.back`, path),
|
|
90
|
+
tags: Array.isArray(obj.tags) ? obj.tags.filter((t) => typeof t === 'string') : undefined,
|
|
91
|
+
hint: typeof obj.hint === 'string' ? obj.hint : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function validateSide(value, where, path) {
|
|
95
|
+
if (typeof value === 'string')
|
|
96
|
+
return value;
|
|
97
|
+
if (Array.isArray(value))
|
|
98
|
+
return value.map((f, i) => validateField(f, `${where}[${i}]`, path));
|
|
99
|
+
if (value !== null && typeof value === 'object') {
|
|
100
|
+
return [validateField(value, where, path)];
|
|
101
|
+
}
|
|
102
|
+
throw new DeckLoadError(`${where} must be a string, mapping, or list of fields`, path);
|
|
103
|
+
}
|
|
104
|
+
function validateField(value, where, path) {
|
|
105
|
+
if (value === null || typeof value !== 'object') {
|
|
106
|
+
throw new DeckLoadError(`${where} must be a mapping`, path);
|
|
107
|
+
}
|
|
108
|
+
const field = value;
|
|
109
|
+
const out = {};
|
|
110
|
+
if (typeof field.text === 'string')
|
|
111
|
+
out.text = field.text;
|
|
112
|
+
if (typeof field.image === 'string')
|
|
113
|
+
out.image = field.image;
|
|
114
|
+
if (typeof field.audio === 'string')
|
|
115
|
+
out.audio = field.audio;
|
|
116
|
+
if (out.text === undefined && out.image === undefined && out.audio === undefined) {
|
|
117
|
+
throw new DeckLoadError(`${where} has no text, image, or audio`, path);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Sibling file where SRS state is persisted, keyed by absolute deck path. */
|
|
122
|
+
function progressPathFor(deckPath) {
|
|
123
|
+
return `${deckPath}.progress.json`;
|
|
124
|
+
}
|
|
125
|
+
/** Load SRS state for every card, defaulting to a fresh state. */
|
|
126
|
+
export async function loadReviewCards(deck, deckPath) {
|
|
127
|
+
const path = progressPathFor(deckPath);
|
|
128
|
+
let saved = {};
|
|
129
|
+
if (existsSync(path)) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = await readFile(path, 'utf8');
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
if (parsed && typeof parsed === 'object' && parsed.states)
|
|
134
|
+
saved = parsed.states;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Corrupt progress file: ignore and start fresh.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return deck.cards.map((card, i) => {
|
|
141
|
+
const key = String(i);
|
|
142
|
+
const state = saved[key] ?? freshState();
|
|
143
|
+
return { card, state };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Persist SRS state for every card. Stable across deck reorders by index. */
|
|
147
|
+
export async function saveReviewCards(deckPath, cards) {
|
|
148
|
+
const path = progressPathFor(deckPath);
|
|
149
|
+
const states = {};
|
|
150
|
+
cards.forEach((c, i) => {
|
|
151
|
+
states[String(i)] = c.state;
|
|
152
|
+
});
|
|
153
|
+
await writeFile(path, JSON.stringify({ version: 1, states }, null, 2) + '\n', 'utf8');
|
|
154
|
+
}
|
|
155
|
+
/** Create an empty deck file at the given path. Auto-creates parent directories. */
|
|
156
|
+
export async function createDeck(file, name) {
|
|
157
|
+
const path = isAbsolute(file) ? file : expandHome(file);
|
|
158
|
+
if (existsSync(path)) {
|
|
159
|
+
throw new DeckLoadError('file already exists', path);
|
|
160
|
+
}
|
|
161
|
+
await mkdir(dirname(path), { recursive: true });
|
|
162
|
+
const stub = {
|
|
163
|
+
name,
|
|
164
|
+
description: 'A new Basa deck.',
|
|
165
|
+
cards: [
|
|
166
|
+
{ front: 'hello', back: 'a greeting' },
|
|
167
|
+
{ front: 'thanks', back: 'an expression of gratitude' },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
if (path.endsWith('.json')) {
|
|
171
|
+
await writeFile(path, JSON.stringify(stub, null, 2) + '\n', 'utf8');
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
await writeFile(path, yaml.dump(stub, { lineWidth: 120 }), 'utf8');
|
|
175
|
+
}
|
|
176
|
+
return path;
|
|
177
|
+
}
|
|
178
|
+
/** Resolve a deck name or path. With a `dir`, looks for `dir/<name>.{yml,yaml,json}`. */
|
|
179
|
+
export async function resolveDeckPath(dir, nameOrPath) {
|
|
180
|
+
if (nameOrPath === undefined) {
|
|
181
|
+
const decks = await listDecks(dir);
|
|
182
|
+
if (decks.length === 0) {
|
|
183
|
+
throw new Error(`No decks found in ${expandHome(dir)}. Create one with \`basa new <name>\`.`);
|
|
184
|
+
}
|
|
185
|
+
if (decks.length === 1)
|
|
186
|
+
return decks[0];
|
|
187
|
+
throw new Error(`Multiple decks in ${expandHome(dir)} — pass a deck name (e.g. \`basa study ${basename(decks[0], extname(decks[0]))}\`).`);
|
|
188
|
+
}
|
|
189
|
+
// If it's a path that exists, use it directly.
|
|
190
|
+
const direct = isAbsolute(nameOrPath) ? nameOrPath : expandHome(nameOrPath);
|
|
191
|
+
if (existsSync(direct))
|
|
192
|
+
return direct;
|
|
193
|
+
// Otherwise try `dir/<name>.{yml,yaml,json}`.
|
|
194
|
+
const base = join(expandHome(dir), nameOrPath);
|
|
195
|
+
for (const ext of ['.yml', '.yaml', '.json']) {
|
|
196
|
+
const candidate = base + ext;
|
|
197
|
+
if (existsSync(candidate))
|
|
198
|
+
return candidate;
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`Deck not found: ${nameOrPath} (looked in ${expandHome(dir)})`);
|
|
201
|
+
}
|
|
202
|
+
/** A relative `image` or `audio` path is resolved against the deck file's directory. */
|
|
203
|
+
export function resolveMediaPath(deckPath, ref) {
|
|
204
|
+
if (isAbsolute(ref) || ref.startsWith('~'))
|
|
205
|
+
return expandHome(ref);
|
|
206
|
+
return resolve(dirname(deckPath), ref);
|
|
207
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encode an RGBA pixel buffer as ANSI truecolor half-block cells. Each
|
|
3
|
+
* terminal cell represents two vertical pixels using `▀` with the top pixel
|
|
4
|
+
* as the foreground and the bottom as the background. Works in any
|
|
5
|
+
* truecolor terminal — no Kitty/SIXEL needed.
|
|
6
|
+
*/
|
|
7
|
+
export function halfBlockLines(image) {
|
|
8
|
+
const { width, height, pixels } = image;
|
|
9
|
+
const out = [];
|
|
10
|
+
let row = 0;
|
|
11
|
+
while (row < height) {
|
|
12
|
+
let line = '';
|
|
13
|
+
for (let col = 0; col < width; col++) {
|
|
14
|
+
const top = pixelAt(pixels, width, height, col, row);
|
|
15
|
+
const bottom = row + 1 < height ? pixelAt(pixels, width, height, col, row + 1) : top;
|
|
16
|
+
line += `\x1b[38;2;${top.r};${top.g};${top.b};48;2;${bottom.r};${bottom.g};${bottom.b}m▀`;
|
|
17
|
+
}
|
|
18
|
+
line += '\x1b[0m';
|
|
19
|
+
out.push(line);
|
|
20
|
+
row += 2;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function pixelAt(pixels, width, height, x, y) {
|
|
25
|
+
if (x < 0 || y < 0 || x >= width || y >= height)
|
|
26
|
+
return { r: 0, g: 0, b: 0 };
|
|
27
|
+
const offset = (y * width + x) * 4;
|
|
28
|
+
return {
|
|
29
|
+
r: pixels[offset] ?? 0,
|
|
30
|
+
g: pixels[offset + 1] ?? 0,
|
|
31
|
+
b: pixels[offset + 2] ?? 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Pick a width and height in pixels that fit a given cell budget. */
|
|
35
|
+
export function fitToCells(sourceWidth, sourceHeight, maxCellsWidth, maxCellsHeight) {
|
|
36
|
+
if (sourceWidth === 0 || sourceHeight === 0)
|
|
37
|
+
return { width: 1, height: 1 };
|
|
38
|
+
const aspect = sourceWidth / sourceHeight;
|
|
39
|
+
// Half-block makes 1 cell represent 2 vertical pixels → effective aspect per cell is 0.5.
|
|
40
|
+
const cellAspect = (maxCellsWidth / maxCellsHeight) * 0.5;
|
|
41
|
+
let cellsH = maxCellsHeight;
|
|
42
|
+
let cellsW = Math.round(cellsH * aspect / 0.5);
|
|
43
|
+
if (cellsW > maxCellsWidth) {
|
|
44
|
+
cellsW = maxCellsWidth;
|
|
45
|
+
cellsH = Math.round(cellsW * 0.5 / aspect);
|
|
46
|
+
}
|
|
47
|
+
return { width: cellsW, height: cellsH * 2 };
|
|
48
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolveMediaPath } from './deck.js';
|
|
4
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
5
|
+
/** True if a path looks like a relative/absolute reference to an image file. */
|
|
6
|
+
export function isImagePath(ref) {
|
|
7
|
+
const lower = ref.toLowerCase();
|
|
8
|
+
for (const ext of IMAGE_EXTS) {
|
|
9
|
+
if (lower.endsWith(ext))
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Decode an image to raw RGBA at a given pixel size, using ImageMagick if
|
|
16
|
+
* available. The function does NOT throw if the tool is missing or the file
|
|
17
|
+
* is broken — it returns `null` and the caller renders a placeholder.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadRgba(file, width, height) {
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
return null;
|
|
22
|
+
const tool = findMagick();
|
|
23
|
+
if (tool === null)
|
|
24
|
+
return null;
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const args = [
|
|
27
|
+
tool,
|
|
28
|
+
file,
|
|
29
|
+
'-resize',
|
|
30
|
+
`${Math.max(1, width)}x${Math.max(1, height)}!`,
|
|
31
|
+
'-depth',
|
|
32
|
+
'8',
|
|
33
|
+
'rgba:-',
|
|
34
|
+
];
|
|
35
|
+
const child = spawn(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let total = 0;
|
|
38
|
+
let settled = false;
|
|
39
|
+
const finish = (value) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
child.kill();
|
|
44
|
+
resolve(value);
|
|
45
|
+
};
|
|
46
|
+
child.stdout.on('data', (chunk) => {
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
total += chunk.length;
|
|
49
|
+
});
|
|
50
|
+
child.on('error', () => finish(null));
|
|
51
|
+
child.on('close', (code) => {
|
|
52
|
+
if (code !== 0)
|
|
53
|
+
return finish(null);
|
|
54
|
+
if (total !== width * height * 4)
|
|
55
|
+
return finish(null);
|
|
56
|
+
const buf = Buffer.concat(chunks, total);
|
|
57
|
+
resolve({ width, height, pixels: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function findMagick() {
|
|
62
|
+
for (const candidate of ['magick', 'convert']) {
|
|
63
|
+
if (probeOnPath(candidate))
|
|
64
|
+
return candidate;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function probeOnPath(cmd) {
|
|
69
|
+
const path = process.env.PATH ?? '';
|
|
70
|
+
for (const dir of path.split(':')) {
|
|
71
|
+
if (dir.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
if (existsSync(`${dir}/${cmd}`))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/** Auto-detect whether a `Side` is "complex enough" that image rendering helps. */
|
|
79
|
+
export function isLikelyComplexScript(text) {
|
|
80
|
+
// CJK, Hangul, Hiragana/Katakana, Devanagari, Arabic, Hebrew, Thai, Tibetan.
|
|
81
|
+
// The user is learning one of these alphabets → render the card as an image
|
|
82
|
+
// so the terminal fonts don't fight us. (This requires the user to supply
|
|
83
|
+
// a font-rendered PNG of the text; the renderer falls back to plain text.)
|
|
84
|
+
return /[\u3000-\u9fff\uac00-\ud7af\u0900-\u097f\u0600-\u06ff\u0590-\u05ff\u0e00-\u0e7f\u0f00-\u0fff]/.test(text);
|
|
85
|
+
}
|
|
86
|
+
/** Resolve a media reference against the deck file's directory. */
|
|
87
|
+
export function resolveDeckMedia(deckPath, ref) {
|
|
88
|
+
return resolveMediaPath(deckPath, ref);
|
|
89
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { isImagePath, loadRgba } from './image.js';
|
|
4
|
+
import { halfBlockLines } from './halfblock.js';
|
|
5
|
+
/**
|
|
6
|
+
* Render a `Side` to text + image rows. Text rows are returned synchronously
|
|
7
|
+
* (so card reveals are instant). Image rows resolve asynchronously and
|
|
8
|
+
* populate the cache.
|
|
9
|
+
*/
|
|
10
|
+
export async function renderSide(side, ctx) {
|
|
11
|
+
const fields = normalizeSide(side);
|
|
12
|
+
const textRows = [];
|
|
13
|
+
const imageRows = [];
|
|
14
|
+
for (const field of fields) {
|
|
15
|
+
if (field.text !== undefined) {
|
|
16
|
+
for (const row of wrapText(field.text, ctx.cellsWidth)) {
|
|
17
|
+
textRows.push(row);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (field.image !== undefined) {
|
|
21
|
+
const rendered = await renderImageField(field.image, ctx);
|
|
22
|
+
if (rendered !== null) {
|
|
23
|
+
// Leave a blank line before an image if we already have text.
|
|
24
|
+
if (textRows.length > 0)
|
|
25
|
+
textRows.push('');
|
|
26
|
+
imageRows.push(...rendered);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
textRows.push(`[image missing: ${field.image}]`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { text: textRows, imageRows };
|
|
34
|
+
}
|
|
35
|
+
function normalizeSide(side) {
|
|
36
|
+
if (typeof side === 'string')
|
|
37
|
+
return [{ text: side }];
|
|
38
|
+
return side;
|
|
39
|
+
}
|
|
40
|
+
function wrapText(text, width) {
|
|
41
|
+
if (width <= 0)
|
|
42
|
+
return [text];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const paragraph of text.split('\n')) {
|
|
45
|
+
if (paragraph.length === 0) {
|
|
46
|
+
out.push('');
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// Greedy wrap on whitespace; preserves CJK by breaking on any char.
|
|
50
|
+
const isWide = /[\u3000-\u9fff\uff00-\uffef]/.test(paragraph);
|
|
51
|
+
if (isWide) {
|
|
52
|
+
for (let i = 0; i < paragraph.length; i += width) {
|
|
53
|
+
out.push(paragraph.slice(i, i + width));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const words = paragraph.split(/(\s+)/);
|
|
58
|
+
let line = '';
|
|
59
|
+
for (const word of words) {
|
|
60
|
+
if (line.length + word.length > width && line.length > 0) {
|
|
61
|
+
out.push(line);
|
|
62
|
+
line = word.trimStart();
|
|
63
|
+
if (line.length > width) {
|
|
64
|
+
// Long word: hard-split.
|
|
65
|
+
for (let i = 0; i < line.length; i += width) {
|
|
66
|
+
out.push(line.slice(i, i + width));
|
|
67
|
+
}
|
|
68
|
+
line = '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
line += word;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (line.length > 0)
|
|
76
|
+
out.push(line);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
async function renderImageField(ref, ctx) {
|
|
82
|
+
const cached = ctx.imageCache.get(ref);
|
|
83
|
+
if (cached !== undefined)
|
|
84
|
+
return cached;
|
|
85
|
+
const promise = (async () => {
|
|
86
|
+
if (!isImagePath(ref))
|
|
87
|
+
return null;
|
|
88
|
+
const resolved = resolveRef(ctx.deckPath, ref);
|
|
89
|
+
if (!existsSync(resolved))
|
|
90
|
+
return null;
|
|
91
|
+
const size = pickImageSize(ctx.cellsWidth, ctx.cellsHeight);
|
|
92
|
+
const rgba = await loadRgba(resolved, size.width, size.height);
|
|
93
|
+
if (rgba === null)
|
|
94
|
+
return null;
|
|
95
|
+
return halfBlockLines(rgba);
|
|
96
|
+
})();
|
|
97
|
+
ctx.imageCache.set(ref, promise);
|
|
98
|
+
return promise;
|
|
99
|
+
}
|
|
100
|
+
function resolveRef(deckPath, ref) {
|
|
101
|
+
if (ref.startsWith('~'))
|
|
102
|
+
return ref.replace(/^~/, process.env.HOME ?? '');
|
|
103
|
+
if (isAbsolute(ref))
|
|
104
|
+
return ref;
|
|
105
|
+
return resolve(dirname(deckPath), ref);
|
|
106
|
+
}
|
|
107
|
+
function pickImageSize(cellsWidth, cellsHeight) {
|
|
108
|
+
// ImageMagick does the resize for us, so we just hand it the cell size.
|
|
109
|
+
return { width: cellsWidth, height: cellsHeight * 2 };
|
|
110
|
+
}
|