saterminal 0.3.2 → 0.4.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 CHANGED
@@ -3,3 +3,19 @@ why?
3
3
  - i don't like how the official SAT question bank forces u to download PDFs
4
4
  - official SAT question bank + other third party question readers display the question difficulty and other metadata (their UIs are also quite chopped)
5
5
  - used to use oneprep until i heard they started AI-generating questions, sooo...
6
+
7
+ usage
8
+
9
+ ```sh
10
+ sat
11
+ ```
12
+
13
+ The package ships `data/question-bank.json.zst` as the initial bank. On first use, Sat materializes it into a plain JSON cache at `~/.saterminal/userlocal/cache/question-bank.json` so regular practice reads directly from local JSON.
14
+
15
+ maintainers
16
+
17
+ ```sh
18
+ bun run update-bank
19
+ ```
20
+
21
+ This refreshes `data/question-bank.json.zst` from Practice SAT for the next package release.
Binary file
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "saterminal",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sat": "src/main.ts"
7
7
  },
8
8
  "files": [
9
+ "data",
9
10
  "src"
10
11
  ],
11
12
  "scripts": {
12
13
  "dev": "bun run src/main.ts",
13
14
  "start": "bun run src/main.ts",
15
+ "update-bank": "bun run scripts/update-question-bank.ts",
14
16
  "test": "bun test",
15
17
  "typecheck": "tsc --noEmit"
16
18
  },
@@ -24,6 +26,7 @@
24
26
  "@types/bun": "^1.2.22",
25
27
  "@types/he": "^1.2.3",
26
28
  "@types/word-wrap": "^1.2.1",
29
+ "p-limit": "^7.3.0",
27
30
  "typescript": "^5.9.3"
28
31
  }
29
32
  }
@@ -0,0 +1,219 @@
1
+ import { mkdir, rename, stat } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { stateDir } from "./state.ts";
5
+ import type { Difficulty, Focus, PracticeQuestion, QuestionMeta, Skill } from "./types.ts";
6
+
7
+ export const questionBankVersion = 1;
8
+ export const questionBankPath = join(stateDir, "cache", "question-bank.json");
9
+ export const bundledQuestionBankPath = fileURLToPath(new URL("../data/question-bank.json.zst", import.meta.url));
10
+
11
+ export type QuestionBank = {
12
+ version: typeof questionBankVersion;
13
+ source: string;
14
+ synced_at: string;
15
+ questions: PracticeQuestion[];
16
+ };
17
+
18
+ export type QuestionBankStatus = {
19
+ path: string;
20
+ exists: boolean;
21
+ size_bytes?: number;
22
+ source?: string;
23
+ synced_at?: string;
24
+ questions?: number;
25
+ };
26
+
27
+ let memoryBank: Promise<QuestionBank | undefined> | undefined;
28
+
29
+ export async function getPracticeQuestion(attemptedIds: Iterable<string>, focus: Focus): Promise<PracticeQuestion> {
30
+ const question = selectPracticeQuestion(await requireQuestionBank(), attemptedIds, focus);
31
+ if (!question) {
32
+ throw new Error("No unanswered questions matched the current filters.");
33
+ }
34
+
35
+ return question;
36
+ }
37
+
38
+ export async function getQuestionByShortId(questionId: string): Promise<PracticeQuestion | undefined> {
39
+ return findQuestionInBank(await requireQuestionBank(), questionId);
40
+ }
41
+
42
+ export function selectPracticeQuestion(
43
+ bank: QuestionBank,
44
+ attemptedIds: Iterable<string>,
45
+ focus: Focus,
46
+ random = Math.random,
47
+ ): PracticeQuestion | undefined {
48
+ const attempted = new Set(attemptedIds);
49
+ const candidates = bank.questions.filter((question) =>
50
+ !attempted.has(question.meta.questionId) && questionMatchesFocus(question.meta, focus)
51
+ );
52
+
53
+ if (candidates.length === 0) {
54
+ return undefined;
55
+ }
56
+
57
+ const index = Math.min(candidates.length - 1, Math.floor(random() * candidates.length));
58
+ return candidates[index];
59
+ }
60
+
61
+ export function findQuestionInBank(bank: QuestionBank, questionId: string): PracticeQuestion | undefined {
62
+ return bank.questions.find((question) => question.meta.questionId === questionId);
63
+ }
64
+
65
+ export async function loadQuestionBank(path = questionBankPath): Promise<QuestionBank | undefined> {
66
+ if (path === questionBankPath) {
67
+ memoryBank ??= materializeQuestionBankCache();
68
+ return memoryBank;
69
+ }
70
+
71
+ return readQuestionBank(path);
72
+ }
73
+
74
+ export async function materializeQuestionBankCache(
75
+ cachePath = questionBankPath,
76
+ bundledPath = bundledQuestionBankPath,
77
+ ): Promise<QuestionBank | undefined> {
78
+ const cached = await readQuestionBank(cachePath);
79
+ if (cached) {
80
+ return cached;
81
+ }
82
+
83
+ const bundled = await readQuestionBank(bundledPath);
84
+ if (!bundled) {
85
+ return undefined;
86
+ }
87
+
88
+ await saveQuestionBank(bundled, cachePath);
89
+ return bundled;
90
+ }
91
+
92
+ export async function questionBankStatus(path = questionBankPath): Promise<QuestionBankStatus> {
93
+ try {
94
+ const bank = await loadQuestionBank(path);
95
+ if (!bank) {
96
+ return { path, exists: false };
97
+ }
98
+ const file = await stat(path);
99
+
100
+ return {
101
+ path,
102
+ exists: true,
103
+ size_bytes: file.size,
104
+ source: bank.source,
105
+ synced_at: bank.synced_at,
106
+ questions: bank.questions.length,
107
+ };
108
+ } catch (error) {
109
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
110
+ return { path, exists: false };
111
+ }
112
+
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ export async function saveQuestionBank(bank: QuestionBank, path = questionBankPath): Promise<void> {
118
+ await mkdir(dirname(path), { recursive: true });
119
+ const tempPath = `${path}.${process.pid}.tmp`;
120
+ await Bun.write(tempPath, `${JSON.stringify(bank)}\n`);
121
+ await rename(tempPath, path);
122
+ }
123
+
124
+ export function resetQuestionBankMemoryCache(): void {
125
+ memoryBank = undefined;
126
+ }
127
+
128
+ async function requireQuestionBank(path = questionBankPath): Promise<QuestionBank> {
129
+ const bank = await loadQuestionBank(path);
130
+ if (!bank) {
131
+ throw new Error("Question bank is missing from the package.");
132
+ }
133
+
134
+ return bank;
135
+ }
136
+
137
+ async function readQuestionBank(path: string): Promise<QuestionBank | undefined> {
138
+ try {
139
+ if (path.endsWith(".zst")) {
140
+ const compressed = await Bun.file(path).arrayBuffer();
141
+ const payload = Bun.zstdDecompressSync(new Uint8Array(compressed));
142
+ return parseQuestionBank(JSON.parse(new TextDecoder().decode(payload)));
143
+ }
144
+
145
+ return parseQuestionBank(await Bun.file(path).json());
146
+ } catch (error) {
147
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
148
+ return undefined;
149
+ }
150
+
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ function parseQuestionBank(value: unknown): QuestionBank {
156
+ if (!value || typeof value !== "object") {
157
+ throw new Error("Question bank cache is not an object.");
158
+ }
159
+
160
+ const bank = value as Partial<QuestionBank>;
161
+ if (bank.version !== questionBankVersion) {
162
+ throw new Error(`Question bank cache version ${String(bank.version)} is not supported.`);
163
+ }
164
+
165
+ if (typeof bank.source !== "string" || !bank.source) {
166
+ throw new Error("Question bank cache is missing its source.");
167
+ }
168
+
169
+ if (typeof bank.synced_at !== "string" || Number.isNaN(new Date(bank.synced_at).getTime())) {
170
+ throw new Error("Question bank cache has an invalid sync timestamp.");
171
+ }
172
+
173
+ if (!Array.isArray(bank.questions)) {
174
+ throw new Error("Question bank cache is missing questions.");
175
+ }
176
+
177
+ const questions = bank.questions.filter(isPracticeQuestion);
178
+ if (questions.length !== bank.questions.length) {
179
+ throw new Error("Question bank cache contains invalid question entries.");
180
+ }
181
+
182
+ return {
183
+ version: questionBankVersion,
184
+ source: bank.source,
185
+ synced_at: bank.synced_at,
186
+ questions,
187
+ };
188
+ }
189
+
190
+ function isPracticeQuestion(value: unknown): value is PracticeQuestion {
191
+ if (!value || typeof value !== "object") {
192
+ return false;
193
+ }
194
+
195
+ const question = value as Partial<PracticeQuestion>;
196
+ return isQuestionMeta(question.meta) && isQuestionDetail(question.detail);
197
+ }
198
+
199
+ function isQuestionMeta(value: unknown): value is QuestionMeta {
200
+ if (!value || typeof value !== "object") {
201
+ return false;
202
+ }
203
+
204
+ const meta = value as Partial<QuestionMeta>;
205
+ return Boolean(meta.questionId && meta.external_id && meta.difficulty && meta.primary_class_cd && meta.skill_cd);
206
+ }
207
+
208
+ function isQuestionDetail(value: unknown): value is PracticeQuestion["detail"] {
209
+ if (!value || typeof value !== "object") {
210
+ return false;
211
+ }
212
+
213
+ const detail = value as Partial<PracticeQuestion["detail"]>;
214
+ return Boolean(detail.type && typeof detail.stem === "string" && detail.answerOptions && Array.isArray(detail.correct_answer));
215
+ }
216
+
217
+ function questionMatchesFocus(meta: QuestionMeta, focus: Focus): boolean {
218
+ return focus.difficulties.includes(meta.difficulty as Difficulty) && focus.skills.includes(meta.skill_cd as Skill);
219
+ }
package/src/tui/app.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defaultFocus } from "../focus.ts";
2
+ import { questionBankStatus } from "../question-bank.ts";
2
3
  import { ensureStateFiles, loadAttempts, loadFocus, stateDirExists } from "../state.ts";
3
4
  import { handleKey, loadNextQuestion } from "./input.ts";
4
5
  import { createFrameRenderer, render } from "./render.ts";
@@ -19,6 +20,11 @@ async function loadPersistedState(state: AppState, options: TuiOptions = {}): Pr
19
20
  state.focusRow = 1;
20
21
  state.notice = undefined;
21
22
 
23
+ const bank = await questionBankStatus();
24
+ if (bank.exists && bank.questions) {
25
+ state.notice = `${bank.questions} questions loaded.`;
26
+ }
27
+
22
28
  if (options.mode === "review") {
23
29
  state.reviewMode = true;
24
30
  state.reviewQuestionIds = reviewQuestionIds(state.attempts);
package/src/tui/input.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { fetchPracticeQuestion, findQuestionByShortId } from "../api.ts";
1
+ import { getPracticeQuestion, getQuestionByShortId } from "../question-bank.ts";
2
2
  import { appendAttemptEvent, recordAttempt, saveAttempts, saveFocus, saveSummary } from "../state.ts";
3
3
  import { focusGrid, moveFocusGridPosition, normalizeFocusGridPosition, toggleFocusGridRow } from "./focus-grid.ts";
4
4
  import {
@@ -147,7 +147,7 @@ export async function handleKey(state: AppState, name: string, data?: KeyData):
147
147
  } else if (name === "ENTER" && attempts[state.historyIndex]) {
148
148
  state.view = "loading";
149
149
  resetPaneScroll(state);
150
- state.detailQuestion = await findQuestionByShortId(attempts[state.historyIndex].question_id);
150
+ state.detailQuestion = await getQuestionByShortId(attempts[state.historyIndex].question_id);
151
151
  state.view = "detail";
152
152
  }
153
153
  return;
@@ -244,7 +244,7 @@ async function takeNextQuestion(state: AppState) {
244
244
  }
245
245
  }
246
246
 
247
- return fetchPracticeQuestion(questionExclusions(state), state.focus);
247
+ return getPracticeQuestion(questionExclusions(state), state.focus);
248
248
  }
249
249
 
250
250
  async function takeReviewQuestion(state: AppState): Promise<PracticeQuestion | undefined> {
@@ -254,7 +254,7 @@ async function takeReviewQuestion(state: AppState): Promise<PracticeQuestion | u
254
254
  continue;
255
255
  }
256
256
 
257
- const question = await findQuestionByShortId(id);
257
+ const question = await getQuestionByShortId(id);
258
258
  if (question) {
259
259
  return question;
260
260
  }
@@ -268,7 +268,7 @@ function cacheNextQuestion(state: AppState): void {
268
268
  return;
269
269
  }
270
270
 
271
- state.nextQuestion = fetchPracticeQuestion(questionExclusions(state), state.focus).catch(() => undefined);
271
+ state.nextQuestion = getPracticeQuestion(questionExclusions(state), state.focus).catch(() => undefined);
272
272
  }
273
273
 
274
274
  function questionExclusions(state: AppState): string[] {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { hasHtmlTable, parseHtmlSegments, wrapSegments } from "../text.ts";
3
3
  import type { PracticeQuestion, QuestionDetail } from "../types.ts";
4
+ import { apiBaseUrl, siteUrl } from "../urls.ts";
4
5
  import type { DisplayRow } from "./types.ts";
5
6
 
6
7
  export function answerKeys(question?: PracticeQuestion): string[] {
@@ -30,11 +31,11 @@ export function questionRows(detail: QuestionDetail, width: number): DisplayRow[
30
31
  }
31
32
 
32
33
  export function practiceQuestionUrl(question: PracticeQuestion): string {
33
- return `https://mysatprep.fun/question/${encodeURIComponent(question.meta.questionId)}`;
34
+ return `${siteUrl}/question/${encodeURIComponent(question.meta.questionId)}`;
34
35
  }
35
36
 
36
37
  export function practiceQuestionApiUrl(question: PracticeQuestion): string {
37
- return `https://mysatprep.fun/api/question/${encodeURIComponent(question.meta.external_id)}`;
38
+ return `${apiBaseUrl}/question/${encodeURIComponent(question.meta.external_id)}`;
38
39
  }
39
40
 
40
41
  export function openExternalQuestion(question: PracticeQuestion): void {
package/src/tui/render.ts CHANGED
@@ -70,7 +70,7 @@ function renderSetup(doc: Frame): void {
70
70
  const location = displayStateDir(stateDir);
71
71
 
72
72
  text(doc, 0, 3, "storage location", { bold: true });
73
- text(doc, 0, 5, "Sat saves progress, focus settings, and summary stats locally.", { color: "gray" }, width);
73
+ text(doc, 0, 5, "Sat saves progress, focus settings, summary stats, and the local question cache.", { color: "gray" }, width);
74
74
  text(doc, 0, 7, "Allow creating this directory?", { bold: true }, width);
75
75
  text(doc, 0, 9, location, { color: "cyan" }, width);
76
76
  }
@@ -124,6 +124,9 @@ function renderFocus(doc: Frame, state: AppState): void {
124
124
  text(doc, 0, 3, "study focus", { bold: true });
125
125
  text(doc, Math.max(0, width - summary.length - 1), 3, summary, { color: "cyan" });
126
126
  text(doc, 0, 4, "Space toggles the selected row. Enter starts practice.", { color: "gray" }, width);
127
+ if (state.notice) {
128
+ text(doc, 0, 5, state.notice, { color: "yellow" }, width);
129
+ }
127
130
 
128
131
  renderDifficultyColumn(doc, columns[0], position.column === 0 ? position.row : -1);
129
132
  renderDomainColumns(doc, columns.slice(1), position.column - 1, position.row);
package/src/urls.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const siteUrl = "https://practicesat.vercel.app";
2
+ export const apiBaseUrl = `${siteUrl}/api`;
package/src/api.ts DELETED
@@ -1,113 +0,0 @@
1
- import { defaultFocus, domainsForSkills } from "./focus.ts";
2
- import type { Focus, PracticeQuestion, QuestionDetail, QuestionMeta } from "./types.ts";
3
-
4
- const baseUrl = "https://mysatprep.fun/api";
5
-
6
- type ApiEnvelope<T> = {
7
- success: boolean;
8
- data: T;
9
- error?: string;
10
- message?: string;
11
- };
12
-
13
- export async function fetchQuestionBank(excludeIds: Iterable<string> = [], focus: Focus = defaultFocus): Promise<QuestionMeta[]> {
14
- const params = focusParams(focus);
15
- const excluded = [...excludeIds].filter(Boolean);
16
- if (excluded.length > 0) {
17
- params.set("excludeIds", excluded.join(","));
18
- }
19
-
20
- const response = await fetchJson<ApiEnvelope<QuestionMeta[]>>(`${baseUrl}/get-questions?${params}`);
21
- if (!response.success || !Array.isArray(response.data)) {
22
- throw new Error(response.error || response.message || "Question bank fetch failed.");
23
- }
24
-
25
- return response.data.filter((item) => item.questionId && item.external_id);
26
- }
27
-
28
- export async function fetchQuestion(id: string): Promise<QuestionDetail> {
29
- const response = await fetchJson<ApiEnvelope<QuestionDetail>>(`${baseUrl}/question/${id}`);
30
- if (!response.success || !response.data) {
31
- throw new Error(response.error || response.message || `Question ${id} fetch failed.`);
32
- }
33
-
34
- return response.data;
35
- }
36
-
37
- export async function fetchPracticeQuestion(attemptedIds: Iterable<string>, focus: Focus = defaultFocus): Promise<PracticeQuestion> {
38
- const bank = await fetchQuestionBank(attemptedIds, focus);
39
- if (bank.length === 0) {
40
- throw new Error("No unanswered questions matched the current filters.");
41
- }
42
-
43
- const meta = bank[Math.floor(Math.random() * bank.length)];
44
- const detail = await fetchQuestion(meta.external_id);
45
- return { meta, detail };
46
- }
47
-
48
- export async function findQuestionByShortId(questionId: string): Promise<PracticeQuestion | undefined> {
49
- const bank = await fetchQuestionBank();
50
- const meta = bank.find((item) => item.questionId === questionId);
51
- if (!meta) {
52
- return undefined;
53
- }
54
-
55
- return { meta, detail: await fetchQuestion(meta.external_id) };
56
- }
57
-
58
- function focusParams(focus: Focus): URLSearchParams {
59
- return new URLSearchParams({
60
- assessment: "SAT",
61
- domains: domainsForSkills(focus.skills).join(","),
62
- difficulties: focus.difficulties.join(","),
63
- skills: focus.skills.join(","),
64
- });
65
- }
66
-
67
- async function fetchJson<T>(url: string): Promise<T> {
68
- const response = await fetch(url, {
69
- headers: {
70
- accept: "application/json",
71
- },
72
- });
73
- const body = await response.text();
74
- const contentType = response.headers.get("content-type") ?? "unknown content type";
75
-
76
- if (!response.ok) {
77
- throw new Error(`${response.status} ${response.statusText}: ${responseError(body)}`);
78
- }
79
-
80
- if (!contentType.toLowerCase().includes("application/json")) {
81
- throw new Error(`Expected JSON from ${url}, got ${contentType}: ${snippet(body)}`);
82
- }
83
-
84
- try {
85
- return JSON.parse(body) as T;
86
- } catch (error) {
87
- const message = error instanceof Error ? error.message : String(error);
88
- throw new Error(`Invalid JSON from ${url}: ${message}. Response: ${snippet(body)}`);
89
- }
90
- }
91
-
92
- function responseError(body: string): string {
93
- try {
94
- const parsed = JSON.parse(body) as { error?: unknown; message?: unknown; details?: unknown };
95
- const message = parsed.error || parsed.message || parsed.details;
96
- if (typeof message === "string" && message.trim()) {
97
- return message;
98
- }
99
- } catch {
100
- // Fall back to the raw body below.
101
- }
102
-
103
- return snippet(body);
104
- }
105
-
106
- function snippet(value: string): string {
107
- const normalized = value.replace(/\s+/g, " ").trim();
108
- if (!normalized) {
109
- return "empty response";
110
- }
111
-
112
- return normalized.length > 160 ? `${normalized.slice(0, 157)}...` : normalized;
113
- }