@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Harms
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# basa
|
|
2
|
+
|
|
3
|
+
A TUI language-learning flashcard app built on [Mudah](https://github.com/thesimonharms/mudah). Decks are plain YAML or JSON files you can edit, commit, and share. Each side of a card can be a string, or a list of `text` / `image` / `audio` fields — handy for scripts your terminal can't render reliably.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
# Run without installing — uses npx and the scoped package.
|
|
9
|
+
npx @thesimonharms/basa --help
|
|
10
|
+
npx @thesimonharms/basa list
|
|
11
|
+
npx @thesimonharms/basa study spanish-101
|
|
12
|
+
|
|
13
|
+
# Or install globally and use the short `basa` command.
|
|
14
|
+
npm install -g @thesimonharms/basa
|
|
15
|
+
basa list
|
|
16
|
+
basa study spanish-101
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Node.js 26 or later.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install -g @thesimonharms/basa
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That puts a `basa` command on your `PATH`. To use a specific deck directory, set the `BASA_DECKS_DIR` env var.
|
|
28
|
+
|
|
29
|
+
For local development:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
git clone https://github.com/thesimonharms/basa
|
|
33
|
+
cd basa
|
|
34
|
+
npm install
|
|
35
|
+
npm run study spanish-101
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
# List bundled and user decks, with due-card counts.
|
|
42
|
+
basa list
|
|
43
|
+
|
|
44
|
+
# Open a study session. Without an argument, opens the only deck in
|
|
45
|
+
# ~/basa/decks.
|
|
46
|
+
basa study
|
|
47
|
+
basa study spanish-101
|
|
48
|
+
|
|
49
|
+
# Create an empty deck scaffold (YAML by default; --format=json for JSON).
|
|
50
|
+
basa new italian-101
|
|
51
|
+
basa new italian-101 --format=json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
While studying:
|
|
55
|
+
|
|
56
|
+
| Key | Action |
|
|
57
|
+
| ------------------- | ------------------------------------------------------- |
|
|
58
|
+
| `space` / `enter` | Reveal the back of the card (with a type-on animation) |
|
|
59
|
+
| `1` … `4` | Grade: Again / Hard / Good / Easy |
|
|
60
|
+
| type | Buffer your answer; submit by grading |
|
|
61
|
+
| `n` | Skip the card (shown again in 30 seconds, no progress) |
|
|
62
|
+
| `esc` / `ctrl+c` | Save and quit |
|
|
63
|
+
|
|
64
|
+
The first time you grade `Easy` you'll get a confetti burst; grading `Again`
|
|
65
|
+
makes the card shake. Right or wrong plays a short tone if your system has
|
|
66
|
+
`paplay`, `pw-play`, or `aplay` on `PATH`. No audio? Nothing happens — no
|
|
67
|
+
errors.
|
|
68
|
+
|
|
69
|
+
## Deck format
|
|
70
|
+
|
|
71
|
+
Decks live in `~/basa/decks/*.yml` (or `.yaml`/`.json`) by default. Override
|
|
72
|
+
with the `BASA_DECKS_DIR` env var or by editing `config/app.ts`.
|
|
73
|
+
|
|
74
|
+
Sample decks live in the [GitHub repo](https://github.com/thesimonharms/basa/tree/main/decks) — they're not shipped in the npm package.
|
|
75
|
+
|
|
76
|
+
### Minimal
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
name: Spanish 101
|
|
80
|
+
description: First-year Spanish vocab
|
|
81
|
+
cards:
|
|
82
|
+
- front: hola
|
|
83
|
+
back: hello
|
|
84
|
+
- front: adiós
|
|
85
|
+
back: goodbye
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With image and audio fields
|
|
89
|
+
|
|
90
|
+
Any field can be a list of `{text?, image?, audio?}` blocks. The renderer
|
|
91
|
+
walks the list top-to-bottom, leaving a blank line between fields. Images
|
|
92
|
+
are decoded with ImageMagick (`magick` on `PATH`) and rendered as ANSI
|
|
93
|
+
half-block cells in truecolor — works in any modern terminal, no
|
|
94
|
+
Kitty/SIXEL required.
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
name: Japanese greetings
|
|
98
|
+
cards:
|
|
99
|
+
- front:
|
|
100
|
+
- text: こんにちは
|
|
101
|
+
- image: ./assets/konnichiwa.png
|
|
102
|
+
back: hello / good afternoon
|
|
103
|
+
hint: spelled K-O-N-N-I-CHI-W-A
|
|
104
|
+
- front: ありがとう
|
|
105
|
+
back: thank you
|
|
106
|
+
audio: ./assets/aratou.wav
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`./assets/...` is resolved relative to the deck file's directory.
|
|
110
|
+
|
|
111
|
+
### Why images for scripts?
|
|
112
|
+
|
|
113
|
+
Terminal font coverage for CJK, Arabic, Devanagari, and other complex
|
|
114
|
+
scripts is uneven — boxes, missing glyphs, or wrong widths are common. The
|
|
115
|
+
`image:` field lets you point at a PNG/JPEG of the rendered text and
|
|
116
|
+
guarantee a beautiful card on every machine. Pre-render with `magick
|
|
117
|
+
-font /usr/share/fonts/... -pointsize 64 label:'こんにちは' out.png` or
|
|
118
|
+
whatever font pipeline you like.
|
|
119
|
+
|
|
120
|
+
## Storage
|
|
121
|
+
|
|
122
|
+
Progress is stored in a sibling file next to each deck: `spanish-101.yml.progress.json`.
|
|
123
|
+
It holds SRS state per card, keyed by index. Delete it to start over.
|
|
124
|
+
|
|
125
|
+
## Sound
|
|
126
|
+
|
|
127
|
+
| Setting | Effect |
|
|
128
|
+
| ------- | ------------------------------------------------------- |
|
|
129
|
+
| `on` | Always try to play (still degrades to silent if no tool) |
|
|
130
|
+
| `off` | Never play |
|
|
131
|
+
| `auto` | Default. Probe `pw-play` / `paplay` / `aplay` on `PATH` |
|
|
132
|
+
|
|
133
|
+
Override per-invocation with `--no-sound`, or globally in `config/app.ts` by
|
|
134
|
+
setting `sound: 'off'`.
|
|
135
|
+
|
|
136
|
+
## Structure
|
|
137
|
+
|
|
138
|
+
- `bin/basa.js` — executable entrypoint
|
|
139
|
+
- `src/flashcards/` — pure logic (types, SRS, deck I/O, sound, image, render)
|
|
140
|
+
- `src/tui/` — the TUI widgets (CardView, Header, Footer, StudyApp, effects)
|
|
141
|
+
- `src/commands/` — one `*.command.ts` per CLI command
|
|
142
|
+
- `decks/` — sample decks (GitHub only; not in the npm tarball)
|
|
143
|
+
- `test/` — vitest unit tests
|
|
144
|
+
- `config/app.ts` — schema-validated defaults
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
npm test # vitest run
|
|
150
|
+
npm run typecheck # tsc --noEmit
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Adding a command
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
node bin/basa.js make command quiz
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
[MIT](./LICENSE) © Simon Harms.
|
package/bin/basa.js
ADDED
package/config/app.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thesimonharms/basa",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Basa — language-learning flashcards in your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"basa": "./bin/basa.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"config",
|
|
14
|
+
"test",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=26"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/thesimonharms/basa.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/thesimonharms/basa/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/thesimonharms/basa#readme",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node bin/basa.js",
|
|
35
|
+
"study": "node bin/basa.js study",
|
|
36
|
+
"list": "node bin/basa.js list",
|
|
37
|
+
"dev": "node bin/basa.js dev welcome",
|
|
38
|
+
"doctor": "node bin/basa.js doctor",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"publish": "./scripts/publish.sh"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@mudah-cli/audio": "^0.5.0",
|
|
45
|
+
"@mudah-cli/mudah": "^0.5.0",
|
|
46
|
+
"js-yaml": "^4.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/js-yaml": "^4.0.9",
|
|
50
|
+
"@types/node": "^26.0.0",
|
|
51
|
+
"typescript": "^7.0.2",
|
|
52
|
+
"vitest": "^4.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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, type TableColumn } from '@mudah-cli/ui';
|
|
5
|
+
|
|
6
|
+
export default class ListCommand extends Command {
|
|
7
|
+
signature = 'list [--all] [--dir=]';
|
|
8
|
+
description = 'List decks (and per-deck card counts)';
|
|
9
|
+
|
|
10
|
+
async handle(): Promise<number> {
|
|
11
|
+
const flag = this.option('dir');
|
|
12
|
+
const dir = typeof flag === 'string' && flag.length > 0
|
|
13
|
+
? flag
|
|
14
|
+
: (this.app.config().get<string>('app.decksDir') ?? defaultDecksDir());
|
|
15
|
+
const paths = await listDecks(dir);
|
|
16
|
+
if (paths.length === 0) {
|
|
17
|
+
this.output.muted(`No decks found in ${dir}.`);
|
|
18
|
+
this.output.hint(`Create one with \`basa new <name>\`, or pass --dir=<path>.`);
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const showAll = this.option('all') === true;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const rows: string[][] = [];
|
|
25
|
+
let totalCards = 0;
|
|
26
|
+
let totalDue = 0;
|
|
27
|
+
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
const deck = await loadDeck(path);
|
|
30
|
+
const cards = await loadReviewCards(deck, path);
|
|
31
|
+
const due = cards.filter((c) => isDue(c.state, now)).length;
|
|
32
|
+
totalCards += cards.length;
|
|
33
|
+
totalDue += due;
|
|
34
|
+
const description = (deck.description ?? '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
rows.push([
|
|
36
|
+
deck.name,
|
|
37
|
+
String(cards.length),
|
|
38
|
+
String(due),
|
|
39
|
+
truncate(description, 50),
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (showAll) {
|
|
44
|
+
for (const path of paths) this.output.muted(`• ${path}`);
|
|
45
|
+
this.output.raw('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const columns: TableColumn[] = [
|
|
49
|
+
{ header: 'Deck', align: 'left' },
|
|
50
|
+
{ header: 'Cards', align: 'right' },
|
|
51
|
+
{ header: 'Due', align: 'right' },
|
|
52
|
+
{ header: 'Description', align: 'left' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
this.output.raw(renderTable(columns, rows, { level: 0, unicode: true }));
|
|
56
|
+
this.output.raw('');
|
|
57
|
+
this.output.muted(`${totalDue} of ${totalCards} cards due across ${paths.length} decks.`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function truncate(text: string, max: number): string {
|
|
63
|
+
if (text.length <= max) return text;
|
|
64
|
+
return `${text.slice(0, max - 1)}…`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { Command } from '@mudah-cli/mudah';
|
|
3
|
+
import { createDeck, defaultDecksDir, expandHome } from '../flashcards/deck.js';
|
|
4
|
+
|
|
5
|
+
export default class NewCommand extends Command {
|
|
6
|
+
signature = 'new {name} [--format=yaml] [--dir=]';
|
|
7
|
+
description = 'Create a new empty deck';
|
|
8
|
+
|
|
9
|
+
async handle(): Promise<number> {
|
|
10
|
+
const name = this.arg('name');
|
|
11
|
+
if (name === undefined) throw new Error('Deck name is required.');
|
|
12
|
+
const format = (this.option('format') as string | undefined) ?? 'yaml';
|
|
13
|
+
const flag = this.option('dir');
|
|
14
|
+
const baseDir = typeof flag === 'string' && flag.length > 0
|
|
15
|
+
? flag
|
|
16
|
+
: (this.app.config().get<string>('app.decksDir') ?? defaultDecksDir());
|
|
17
|
+
const dir = expandHome(baseDir);
|
|
18
|
+
const ext = format === 'json' ? '.json' : '.yml';
|
|
19
|
+
const file = join(dir, `${name}${ext}`);
|
|
20
|
+
const path = await createDeck(file, name);
|
|
21
|
+
this.output.success(`Created ${path}`);
|
|
22
|
+
this.output.hint(`Edit it, then run \`basa study ${name}\`.`);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
|
|
7
|
+
export default class StudyCommand extends Command {
|
|
8
|
+
signature = 'study {name?} [--no-sound] [--dir=]';
|
|
9
|
+
description = 'Open a flashcard deck in the terminal';
|
|
10
|
+
|
|
11
|
+
async handle(): Promise<number> {
|
|
12
|
+
const decksDir = this.configDecksDir();
|
|
13
|
+
const deckPath = await resolveDeckPath(decksDir, this.arg('name'));
|
|
14
|
+
const deck = await loadDeck(deckPath);
|
|
15
|
+
const cards = await loadReviewCards(deck, deckPath);
|
|
16
|
+
|
|
17
|
+
const fx = await openFx(this.configSound());
|
|
18
|
+
let study: StudyApp | undefined;
|
|
19
|
+
let resizeListener: (() => void) | undefined;
|
|
20
|
+
|
|
21
|
+
const program = new Program({ mouse: true, keyboard: true, stdin: process.stdin });
|
|
22
|
+
|
|
23
|
+
const width = (process.stdout as { columns?: number }).columns ?? 80;
|
|
24
|
+
const height = (process.stdout as { rows?: number }).rows ?? 24;
|
|
25
|
+
study = new StudyApp({
|
|
26
|
+
deck,
|
|
27
|
+
deckPath,
|
|
28
|
+
cards,
|
|
29
|
+
width,
|
|
30
|
+
height,
|
|
31
|
+
fx,
|
|
32
|
+
});
|
|
33
|
+
program.mount(study.root);
|
|
34
|
+
|
|
35
|
+
// Repaint loop: Program repaints every 16ms via setInterval, but we
|
|
36
|
+
// want the StudyApp to also tick (advance animations, swap card).
|
|
37
|
+
const ticker = setInterval(() => {
|
|
38
|
+
study?.tick();
|
|
39
|
+
program.requestFrame();
|
|
40
|
+
}, 16);
|
|
41
|
+
|
|
42
|
+
// Handle terminal resize. Program's diff renderer adapts automatically
|
|
43
|
+
// to the new column count, but we need to re-render images and layout.
|
|
44
|
+
resizeListener = (): void => {
|
|
45
|
+
const w = (process.stdout as { columns?: number }).columns ?? width;
|
|
46
|
+
const h = (process.stdout as { rows?: number }).rows ?? height;
|
|
47
|
+
study?.resize(w, h);
|
|
48
|
+
program.requestFrame();
|
|
49
|
+
};
|
|
50
|
+
process.stdout.on('resize', resizeListener);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
return await program.run();
|
|
54
|
+
} finally {
|
|
55
|
+
clearInterval(ticker);
|
|
56
|
+
if (resizeListener !== undefined) process.stdout.off('resize', resizeListener);
|
|
57
|
+
await study?.persist();
|
|
58
|
+
await fx.dispose();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private configDecksDir(): string {
|
|
63
|
+
const flag = this.option('dir');
|
|
64
|
+
if (typeof flag === 'string' && flag.length > 0) return flag;
|
|
65
|
+
return this.app.config().get<string>('app.decksDir') ?? defaultDecksDir();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private configSound(): 'on' | 'off' | 'auto' {
|
|
69
|
+
const noSound = this.option('no-sound') === true;
|
|
70
|
+
if (noSound) return 'off';
|
|
71
|
+
const value = this.app.config().get<string>('app.sound');
|
|
72
|
+
if (value === 'on' || value === 'off' || value === 'auto') return value;
|
|
73
|
+
return 'auto';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function openFx(setting: 'on' | 'off' | 'auto'): Promise<BasaFx> {
|
|
78
|
+
// BasaFx already handles all three settings via the same open() call:
|
|
79
|
+
// it probes for an audio backend and falls back to silent if none is
|
|
80
|
+
// available. The `setting` is reserved for future per-setting behavior
|
|
81
|
+
// (e.g. a sound-test command, a "play even in CI" override).
|
|
82
|
+
void setting;
|
|
83
|
+
return BasaFx.open();
|
|
84
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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 type { Deck, SrsState, ReviewCard } from './types.js';
|
|
7
|
+
import { freshState } from './srs.js';
|
|
8
|
+
|
|
9
|
+
export class DeckLoadError extends Error {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
constructor(message: string, path: string) {
|
|
12
|
+
super(`${path}: ${message}`);
|
|
13
|
+
this.path = path;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve a leading `~` to the user's home directory. */
|
|
18
|
+
export function expandHome(p: string): string {
|
|
19
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
20
|
+
if (p === '~') return homedir();
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The default location for user decks: `~/basa/decks`. */
|
|
25
|
+
export function defaultDecksDir(): string {
|
|
26
|
+
return join(homedir(), 'basa', 'decks');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Find every supported deck file in a directory (non-recursive). */
|
|
30
|
+
export async function listDecks(dir: string): Promise<string[]> {
|
|
31
|
+
const path = expandHome(dir);
|
|
32
|
+
if (!existsSync(path)) return [];
|
|
33
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
34
|
+
return entries
|
|
35
|
+
.filter((e) => e.isFile() && /\.(ya?ml|json)$/i.test(e.name))
|
|
36
|
+
.map((e) => join(path, e.name))
|
|
37
|
+
.sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadDeck(file: string): Promise<Deck> {
|
|
41
|
+
const path = isAbsolute(file) ? file : expandHome(file);
|
|
42
|
+
let raw: string;
|
|
43
|
+
try {
|
|
44
|
+
raw = await readFile(path, 'utf8');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new DeckLoadError(
|
|
47
|
+
`cannot read file: ${(err as NodeJS.ErrnoException).message}`,
|
|
48
|
+
path,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
const ext = extname(path).toLowerCase();
|
|
53
|
+
try {
|
|
54
|
+
if (ext === '.json') {
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
56
|
+
} else {
|
|
57
|
+
parsed = yaml.load(raw);
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new DeckLoadError(
|
|
61
|
+
`parse error: ${(err as Error).message}`,
|
|
62
|
+
path,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return validateDeck(parsed, path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateDeck(value: unknown, path: string): Deck {
|
|
69
|
+
if (value === null || typeof value !== 'object') {
|
|
70
|
+
throw new DeckLoadError('deck must be a mapping (name + cards)', path);
|
|
71
|
+
}
|
|
72
|
+
const obj = value as Record<string, unknown>;
|
|
73
|
+
if (typeof obj.name !== 'string' || obj.name.length === 0) {
|
|
74
|
+
throw new DeckLoadError('deck.name must be a non-empty string', path);
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(obj.cards)) {
|
|
77
|
+
throw new DeckLoadError('deck.cards must be an array', path);
|
|
78
|
+
}
|
|
79
|
+
const cards = obj.cards.map((c, i) => validateCard(c, path, i));
|
|
80
|
+
return {
|
|
81
|
+
name: obj.name,
|
|
82
|
+
description: typeof obj.description === 'string' ? obj.description : undefined,
|
|
83
|
+
cards,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateCard(value: unknown, path: string, index: number): Deck['cards'][number] {
|
|
88
|
+
if (value === null || typeof value !== 'object') {
|
|
89
|
+
throw new DeckLoadError(`card #${index + 1} must be a mapping`, path);
|
|
90
|
+
}
|
|
91
|
+
const obj = value as Record<string, unknown>;
|
|
92
|
+
if (obj.front === undefined || obj.back === undefined) {
|
|
93
|
+
throw new DeckLoadError(`card #${index + 1} is missing front or back`, path);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
front: validateSide(obj.front, `card #${index + 1}.front`, path),
|
|
97
|
+
back: validateSide(obj.back, `card #${index + 1}.back`, path),
|
|
98
|
+
tags: Array.isArray(obj.tags) ? obj.tags.filter((t): t is string => typeof t === 'string') : undefined,
|
|
99
|
+
hint: typeof obj.hint === 'string' ? obj.hint : undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateSide(value: unknown, where: string, path: string): Deck['cards'][number]['front'] {
|
|
104
|
+
if (typeof value === 'string') return value;
|
|
105
|
+
if (Array.isArray(value)) return value.map((f, i) => validateField(f, `${where}[${i}]`, path));
|
|
106
|
+
if (value !== null && typeof value === 'object') {
|
|
107
|
+
return [validateField(value, where, path)];
|
|
108
|
+
}
|
|
109
|
+
throw new DeckLoadError(`${where} must be a string, mapping, or list of fields`, path);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateField(value: unknown, where: string, path: string): { text?: string; image?: string; audio?: string } {
|
|
113
|
+
if (value === null || typeof value !== 'object') {
|
|
114
|
+
throw new DeckLoadError(`${where} must be a mapping`, path);
|
|
115
|
+
}
|
|
116
|
+
const field = value as Record<string, unknown>;
|
|
117
|
+
const out: { text?: string; image?: string; audio?: string } = {};
|
|
118
|
+
if (typeof field.text === 'string') out.text = field.text;
|
|
119
|
+
if (typeof field.image === 'string') out.image = field.image;
|
|
120
|
+
if (typeof field.audio === 'string') out.audio = field.audio;
|
|
121
|
+
if (out.text === undefined && out.image === undefined && out.audio === undefined) {
|
|
122
|
+
throw new DeckLoadError(`${where} has no text, image, or audio`, path);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Sibling file where SRS state is persisted, keyed by absolute deck path. */
|
|
128
|
+
function progressPathFor(deckPath: string): string {
|
|
129
|
+
return `${deckPath}.progress.json`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Load SRS state for every card, defaulting to a fresh state. */
|
|
133
|
+
export async function loadReviewCards(deck: Deck, deckPath: string): Promise<ReviewCard[]> {
|
|
134
|
+
const path = progressPathFor(deckPath);
|
|
135
|
+
let saved: Record<string, SrsState> = {};
|
|
136
|
+
if (existsSync(path)) {
|
|
137
|
+
try {
|
|
138
|
+
const raw = await readFile(path, 'utf8');
|
|
139
|
+
const parsed = JSON.parse(raw) as { states?: Record<string, SrsState> };
|
|
140
|
+
if (parsed && typeof parsed === 'object' && parsed.states) saved = parsed.states;
|
|
141
|
+
} catch {
|
|
142
|
+
// Corrupt progress file: ignore and start fresh.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return deck.cards.map((card, i) => {
|
|
146
|
+
const key = String(i);
|
|
147
|
+
const state = saved[key] ?? freshState();
|
|
148
|
+
return { card, state };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Persist SRS state for every card. Stable across deck reorders by index. */
|
|
153
|
+
export async function saveReviewCards(
|
|
154
|
+
deckPath: string,
|
|
155
|
+
cards: readonly ReviewCard[],
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const path = progressPathFor(deckPath);
|
|
158
|
+
const states: Record<string, SrsState> = {};
|
|
159
|
+
cards.forEach((c, i) => {
|
|
160
|
+
states[String(i)] = c.state;
|
|
161
|
+
});
|
|
162
|
+
await writeFile(path, JSON.stringify({ version: 1, states }, null, 2) + '\n', 'utf8');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Create an empty deck file at the given path. Auto-creates parent directories. */
|
|
166
|
+
export async function createDeck(file: string, name: string): Promise<string> {
|
|
167
|
+
const path = isAbsolute(file) ? file : expandHome(file);
|
|
168
|
+
if (existsSync(path)) {
|
|
169
|
+
throw new DeckLoadError('file already exists', path);
|
|
170
|
+
}
|
|
171
|
+
await mkdir(dirname(path), { recursive: true });
|
|
172
|
+
const stub: Deck = {
|
|
173
|
+
name,
|
|
174
|
+
description: 'A new Basa deck.',
|
|
175
|
+
cards: [
|
|
176
|
+
{ front: 'hello', back: 'a greeting' },
|
|
177
|
+
{ front: 'thanks', back: 'an expression of gratitude' },
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
if (path.endsWith('.json')) {
|
|
181
|
+
await writeFile(path, JSON.stringify(stub, null, 2) + '\n', 'utf8');
|
|
182
|
+
} else {
|
|
183
|
+
await writeFile(path, yaml.dump(stub, { lineWidth: 120 }), 'utf8');
|
|
184
|
+
}
|
|
185
|
+
return path;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Resolve a deck name or path. With a `dir`, looks for `dir/<name>.{yml,yaml,json}`. */
|
|
189
|
+
export async function resolveDeckPath(dir: string, nameOrPath: string | undefined): Promise<string> {
|
|
190
|
+
if (nameOrPath === undefined) {
|
|
191
|
+
const decks = await listDecks(dir);
|
|
192
|
+
if (decks.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`No decks found in ${expandHome(dir)}. Create one with \`basa new <name>\`.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (decks.length === 1) return decks[0]!;
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Multiple decks in ${expandHome(dir)} — pass a deck name (e.g. \`basa study ${basename(decks[0]!, extname(decks[0]!))}\`).`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
// If it's a path that exists, use it directly.
|
|
203
|
+
const direct = isAbsolute(nameOrPath) ? nameOrPath : expandHome(nameOrPath);
|
|
204
|
+
if (existsSync(direct)) return direct;
|
|
205
|
+
// Otherwise try `dir/<name>.{yml,yaml,json}`.
|
|
206
|
+
const base = join(expandHome(dir), nameOrPath);
|
|
207
|
+
for (const ext of ['.yml', '.yaml', '.json']) {
|
|
208
|
+
const candidate = base + ext;
|
|
209
|
+
if (existsSync(candidate)) return candidate;
|
|
210
|
+
}
|
|
211
|
+
throw new Error(`Deck not found: ${nameOrPath} (looked in ${expandHome(dir)})`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A relative `image` or `audio` path is resolved against the deck file's directory. */
|
|
215
|
+
export function resolveMediaPath(deckPath: string, ref: string): string {
|
|
216
|
+
if (isAbsolute(ref) || ref.startsWith('~')) return expandHome(ref);
|
|
217
|
+
return resolve(dirname(deckPath), ref);
|
|
218
|
+
}
|