@derekhut/alvy 1.0.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/README.md +96 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.js +17 -0
- package/dist/components/celebration.d.ts +7 -0
- package/dist/components/celebration.js +8 -0
- package/dist/components/cjk-test.d.ts +1 -0
- package/dist/components/cjk-test.js +57 -0
- package/dist/components/daily-session.d.ts +1 -0
- package/dist/components/daily-session.js +128 -0
- package/dist/components/dashboard.d.ts +7 -0
- package/dist/components/dashboard.js +13 -0
- package/dist/components/doctor.d.ts +1 -0
- package/dist/components/doctor.js +120 -0
- package/dist/components/explore.d.ts +1 -0
- package/dist/components/explore.js +5 -0
- package/dist/components/result.d.ts +7 -0
- package/dist/components/result.js +5 -0
- package/dist/components/review-session.d.ts +1 -0
- package/dist/components/review-session.js +108 -0
- package/dist/components/root-lesson.d.ts +9 -0
- package/dist/components/root-lesson.js +8 -0
- package/dist/components/session-summary.d.ts +8 -0
- package/dist/components/session-summary.js +5 -0
- package/dist/components/stats.d.ts +1 -0
- package/dist/components/stats.js +17 -0
- package/dist/components/streak-header.d.ts +7 -0
- package/dist/components/streak-header.js +7 -0
- package/dist/components/word-detail.d.ts +9 -0
- package/dist/components/word-detail.js +17 -0
- package/dist/components/word-drill.d.ts +20 -0
- package/dist/components/word-drill.js +54 -0
- package/dist/data/roots.json +1802 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +29 -0
- package/dist/lib/ai.d.ts +1 -0
- package/dist/lib/ai.js +5 -0
- package/dist/lib/progress.d.ts +17 -0
- package/dist/lib/progress.js +103 -0
- package/dist/lib/roots-db.d.ts +9 -0
- package/dist/lib/roots-db.js +25 -0
- package/dist/lib/store.d.ts +6 -0
- package/dist/lib/store.js +75 -0
- package/dist/lib/types.d.ts +39 -0
- package/dist/lib/types.js +1 -0
- package/package.json +33 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import meow from "meow";
|
|
5
|
+
import App from "./app.js";
|
|
6
|
+
const cli = meow(`
|
|
7
|
+
用法
|
|
8
|
+
$ alvy 每日学习(3 个词根,每个 5 个单词)
|
|
9
|
+
$ alvy review 复习已学词根
|
|
10
|
+
$ alvy stats 导出学习进度
|
|
11
|
+
$ alvy doctor 检查运行环境
|
|
12
|
+
|
|
13
|
+
选项
|
|
14
|
+
--help 显示帮助信息
|
|
15
|
+
--version 显示版本号
|
|
16
|
+
`, {
|
|
17
|
+
importMeta: import.meta,
|
|
18
|
+
});
|
|
19
|
+
const validCommands = new Set(["daily", "review", "stats", "doctor"]);
|
|
20
|
+
const input = cli.input[0];
|
|
21
|
+
const command = input && validCommands.has(input)
|
|
22
|
+
? input
|
|
23
|
+
: input
|
|
24
|
+
? (() => {
|
|
25
|
+
console.error(`Unknown command: ${input}. Run alvy --help for usage.`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
})()
|
|
28
|
+
: "daily";
|
|
29
|
+
render(_jsx(App, { command: command }));
|
package/dist/lib/ai.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateMnemonic(_word: string, _root: string): Promise<string>;
|
package/dist/lib/ai.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { UserData, RootEntry } from "./types.js";
|
|
2
|
+
/** Count fully studied roots (all 5 words seen) */
|
|
3
|
+
export declare function masteredCount(data: UserData): number;
|
|
4
|
+
/** Count roots that have been started */
|
|
5
|
+
export declare function seenCount(data: UserData): number;
|
|
6
|
+
/** Update streak based on today's date */
|
|
7
|
+
export declare function updateStreak(data: UserData): void;
|
|
8
|
+
/** Add XP */
|
|
9
|
+
export declare function addXP(data: UserData, points?: number): void;
|
|
10
|
+
/** Mark a root as seen and track words studied */
|
|
11
|
+
export declare function markRootSeen(data: UserData, rootKey: string): void;
|
|
12
|
+
/** Mark a word as studied */
|
|
13
|
+
export declare function markWordStudied(data: UserData, rootKey: string, word: string): void;
|
|
14
|
+
/** Select next morphemes for a daily session */
|
|
15
|
+
export declare function selectNextMorphemes(data: UserData, allRoots: RootEntry[], count?: number): RootEntry[];
|
|
16
|
+
/** Generate stats summary as markdown */
|
|
17
|
+
export declare function generateStatsSummary(data: UserData, totalRoots: number): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Count fully studied roots (all 5 words seen) */
|
|
2
|
+
export function masteredCount(data) {
|
|
3
|
+
return Object.values(data.rootProgress).filter((p) => p.seen && p.wordsStudied >= 5).length;
|
|
4
|
+
}
|
|
5
|
+
/** Count roots that have been started */
|
|
6
|
+
export function seenCount(data) {
|
|
7
|
+
return Object.values(data.rootProgress).filter((p) => p.seen).length;
|
|
8
|
+
}
|
|
9
|
+
/** Update streak based on today's date */
|
|
10
|
+
export function updateStreak(data) {
|
|
11
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
12
|
+
const lastDate = data.streak.lastDate;
|
|
13
|
+
if (lastDate === today)
|
|
14
|
+
return;
|
|
15
|
+
if (lastDate) {
|
|
16
|
+
const last = new Date(lastDate);
|
|
17
|
+
const now = new Date(today);
|
|
18
|
+
const diffDays = (now.getTime() - last.getTime()) / (1000 * 60 * 60 * 24);
|
|
19
|
+
data.streak.current = diffDays === 1 ? data.streak.current + 1 : 1;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
data.streak.current = 1;
|
|
23
|
+
}
|
|
24
|
+
data.streak.lastDate = today;
|
|
25
|
+
if (data.streak.current > data.streak.longest) {
|
|
26
|
+
data.streak.longest = data.streak.current;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Add XP */
|
|
30
|
+
export function addXP(data, points = 10) {
|
|
31
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
32
|
+
if (data.streak.lastDate !== today) {
|
|
33
|
+
data.xp.today = 0;
|
|
34
|
+
}
|
|
35
|
+
data.xp.total += points;
|
|
36
|
+
data.xp.today += points;
|
|
37
|
+
}
|
|
38
|
+
/** Mark a root as seen and track words studied */
|
|
39
|
+
export function markRootSeen(data, rootKey) {
|
|
40
|
+
if (!data.rootProgress[rootKey]) {
|
|
41
|
+
data.rootProgress[rootKey] = {
|
|
42
|
+
seen: true,
|
|
43
|
+
wordsStudied: 0,
|
|
44
|
+
lastStudied: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
data.rootProgress[rootKey].seen = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Mark a word as studied */
|
|
52
|
+
export function markWordStudied(data, rootKey, word) {
|
|
53
|
+
const wordsArr = Array.isArray(data.wordsStudied)
|
|
54
|
+
? data.wordsStudied
|
|
55
|
+
: [...data.wordsStudied];
|
|
56
|
+
if (!wordsArr.includes(word)) {
|
|
57
|
+
wordsArr.push(word);
|
|
58
|
+
data.wordsStudied = wordsArr;
|
|
59
|
+
}
|
|
60
|
+
const progress = data.rootProgress[rootKey];
|
|
61
|
+
if (progress) {
|
|
62
|
+
progress.wordsStudied = (progress.wordsStudied || 0) + 1;
|
|
63
|
+
progress.lastStudied = new Date().toISOString().slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Select next morphemes for a daily session */
|
|
67
|
+
export function selectNextMorphemes(data, allRoots, count = 3) {
|
|
68
|
+
// Unseen morphemes first (sequential order)
|
|
69
|
+
const unseen = allRoots.filter((r) => !data.rootProgress[r.root]?.seen);
|
|
70
|
+
if (unseen.length > 0) {
|
|
71
|
+
return unseen.slice(0, count);
|
|
72
|
+
}
|
|
73
|
+
// All seen — pick least studied, tie-break by oldest lastStudied
|
|
74
|
+
const sorted = [...allRoots].sort((a, b) => {
|
|
75
|
+
const pA = data.rootProgress[a.root];
|
|
76
|
+
const pB = data.rootProgress[b.root];
|
|
77
|
+
const studiedA = pA?.wordsStudied ?? 0;
|
|
78
|
+
const studiedB = pB?.wordsStudied ?? 0;
|
|
79
|
+
if (studiedA !== studiedB)
|
|
80
|
+
return studiedA - studiedB;
|
|
81
|
+
const dateA = pA?.lastStudied ?? "";
|
|
82
|
+
const dateB = pB?.lastStudied ?? "";
|
|
83
|
+
return dateA.localeCompare(dateB);
|
|
84
|
+
});
|
|
85
|
+
return sorted.slice(0, count);
|
|
86
|
+
}
|
|
87
|
+
/** Generate stats summary as markdown */
|
|
88
|
+
export function generateStatsSummary(data, totalRoots) {
|
|
89
|
+
const mastered = masteredCount(data);
|
|
90
|
+
const seen = seenCount(data);
|
|
91
|
+
const totalWords = Array.isArray(data.wordsStudied)
|
|
92
|
+
? data.wordsStudied.length
|
|
93
|
+
: data.wordsStudied.size;
|
|
94
|
+
return `# 词根学习进度
|
|
95
|
+
|
|
96
|
+
- **已掌握:** ${mastered}/${totalRoots} 个词根
|
|
97
|
+
- **已学习:** ${seen}/${totalRoots} 个词根
|
|
98
|
+
- **总经验值:** ${data.xp.total} XP
|
|
99
|
+
- **当前连续天数:** ${data.streak.current} 天
|
|
100
|
+
- **最长连续天数:** ${data.streak.longest} 天
|
|
101
|
+
- **已学单词:** ${totalWords} 个
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RootEntry } from "./types.js";
|
|
2
|
+
/** Get all root entries */
|
|
3
|
+
export declare function getAllRoots(): RootEntry[];
|
|
4
|
+
/** Get total number of root entries */
|
|
5
|
+
export declare function getRootCount(): number;
|
|
6
|
+
/** Get a root entry by its key (e.g., "bene-") */
|
|
7
|
+
export declare function getRootByKey(key: string): RootEntry | undefined;
|
|
8
|
+
/** Get meanings map for related roots */
|
|
9
|
+
export declare function getRelatedMeanings(entry: RootEntry): Record<string, string>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import rootsData from "../data/roots.json" with { type: "json" };
|
|
2
|
+
const roots = rootsData;
|
|
3
|
+
/** Get all root entries */
|
|
4
|
+
export function getAllRoots() {
|
|
5
|
+
return roots;
|
|
6
|
+
}
|
|
7
|
+
/** Get total number of root entries */
|
|
8
|
+
export function getRootCount() {
|
|
9
|
+
return roots.length;
|
|
10
|
+
}
|
|
11
|
+
/** Get a root entry by its key (e.g., "bene-") */
|
|
12
|
+
export function getRootByKey(key) {
|
|
13
|
+
return roots.find((r) => r.root === key);
|
|
14
|
+
}
|
|
15
|
+
/** Get meanings map for related roots */
|
|
16
|
+
export function getRelatedMeanings(entry) {
|
|
17
|
+
const result = {};
|
|
18
|
+
for (const relatedKey of entry.related) {
|
|
19
|
+
const related = getRootByKey(relatedKey);
|
|
20
|
+
if (related) {
|
|
21
|
+
result[relatedKey] = related.meaning_zh;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { UserData } from "./types.js";
|
|
2
|
+
export declare const DATA_DIR: string;
|
|
3
|
+
export declare const DATA_FILE: string;
|
|
4
|
+
export declare function loadData(): UserData;
|
|
5
|
+
export declare function saveData(data: UserData): void;
|
|
6
|
+
export declare function getDataPath(): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
export const DATA_DIR = path.join(os.homedir(), ".alvy");
|
|
5
|
+
export const DATA_FILE = path.join(DATA_DIR, "data.json");
|
|
6
|
+
const BACKUP_FILE = path.join(DATA_DIR, "data.backup.json");
|
|
7
|
+
const OLD_DATA_DIR = path.join(os.homedir(), ".toefl-roots");
|
|
8
|
+
const OLD_DATA_FILE = path.join(OLD_DATA_DIR, "data.json");
|
|
9
|
+
function defaultData() {
|
|
10
|
+
return {
|
|
11
|
+
streak: { current: 0, longest: 0, lastDate: null },
|
|
12
|
+
xp: { total: 0, today: 0 },
|
|
13
|
+
dailyGoal: 3,
|
|
14
|
+
rootProgress: {},
|
|
15
|
+
wordsStudied: [],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
20
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function migrateFromOldPath() {
|
|
24
|
+
if (!fs.existsSync(DATA_FILE) && fs.existsSync(OLD_DATA_FILE)) {
|
|
25
|
+
fs.copyFileSync(OLD_DATA_FILE, DATA_FILE);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function loadData() {
|
|
29
|
+
ensureDir();
|
|
30
|
+
migrateFromOldPath();
|
|
31
|
+
if (!fs.existsSync(DATA_FILE)) {
|
|
32
|
+
const data = defaultData();
|
|
33
|
+
saveData(data);
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(DATA_FILE, "utf-8");
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
if (!parsed.streak || !parsed.xp || !parsed.rootProgress) {
|
|
40
|
+
throw new Error("Invalid data structure");
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
...parsed,
|
|
44
|
+
wordsStudied: parsed.wordsStudied ?? [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
if (fs.existsSync(DATA_FILE)) {
|
|
49
|
+
fs.copyFileSync(DATA_FILE, BACKUP_FILE);
|
|
50
|
+
}
|
|
51
|
+
console.error(`警告: data.json 已损坏,已备份至 ${BACKUP_FILE}。重新开始。`);
|
|
52
|
+
const data = defaultData();
|
|
53
|
+
saveData(data);
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function saveData(data) {
|
|
58
|
+
ensureDir();
|
|
59
|
+
const persisted = {
|
|
60
|
+
streak: data.streak,
|
|
61
|
+
xp: data.xp,
|
|
62
|
+
dailyGoal: data.dailyGoal,
|
|
63
|
+
rootProgress: data.rootProgress,
|
|
64
|
+
wordsStudied: Array.isArray(data.wordsStudied)
|
|
65
|
+
? data.wordsStudied
|
|
66
|
+
: [...data.wordsStudied],
|
|
67
|
+
};
|
|
68
|
+
const json = JSON.stringify(persisted, null, 2);
|
|
69
|
+
const tmpFile = DATA_FILE + ".tmp";
|
|
70
|
+
fs.writeFileSync(tmpFile, json, "utf-8");
|
|
71
|
+
fs.renameSync(tmpFile, DATA_FILE);
|
|
72
|
+
}
|
|
73
|
+
export function getDataPath() {
|
|
74
|
+
return DATA_FILE;
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface RootWord {
|
|
2
|
+
word: string;
|
|
3
|
+
breakdown: string;
|
|
4
|
+
derivation: string;
|
|
5
|
+
meaning_en: string;
|
|
6
|
+
meaning_zh: string;
|
|
7
|
+
example: string;
|
|
8
|
+
example_zh: string;
|
|
9
|
+
toefl_frequency: "high" | "medium" | "low";
|
|
10
|
+
}
|
|
11
|
+
export interface RootEntry {
|
|
12
|
+
root: string;
|
|
13
|
+
type: "root" | "prefix" | "suffix";
|
|
14
|
+
meaning_en: string;
|
|
15
|
+
meaning_zh: string;
|
|
16
|
+
origin: string;
|
|
17
|
+
related: string[];
|
|
18
|
+
words: RootWord[];
|
|
19
|
+
}
|
|
20
|
+
export interface RootProgress {
|
|
21
|
+
seen: boolean;
|
|
22
|
+
wordsStudied: number;
|
|
23
|
+
lastStudied: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface UserData {
|
|
26
|
+
streak: {
|
|
27
|
+
current: number;
|
|
28
|
+
longest: number;
|
|
29
|
+
lastDate: string | null;
|
|
30
|
+
};
|
|
31
|
+
xp: {
|
|
32
|
+
total: number;
|
|
33
|
+
today: number;
|
|
34
|
+
};
|
|
35
|
+
dailyGoal: number;
|
|
36
|
+
rootProgress: Record<string, RootProgress>;
|
|
37
|
+
wordsStudied: Set<string> | string[];
|
|
38
|
+
}
|
|
39
|
+
export type Command = "daily" | "review" | "stats" | "doctor";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@derekhut/alvy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Terminal-based vocabulary tool — learn English word roots with Chinese translations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"alvy": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"dev": "tsc --watch",
|
|
18
|
+
"start": "node dist/index.js",
|
|
19
|
+
"doctor": "node dist/index.js doctor",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"ink": "^5.1.0",
|
|
25
|
+
"meow": "^13.2.0",
|
|
26
|
+
"react": "^18.3.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/react": "^18.3.12",
|
|
30
|
+
"typescript": "^5.7.0",
|
|
31
|
+
"vitest": "^4.1.2"
|
|
32
|
+
}
|
|
33
|
+
}
|