saterminal 0.3.1 → 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 +16 -0
- package/data/question-bank.json.zst +0 -0
- package/package.json +4 -1
- package/src/cli.ts +18 -0
- package/src/question-bank.ts +219 -0
- package/src/tui/app.ts +6 -0
- package/src/tui/input.ts +5 -5
- package/src/tui/question.ts +3 -2
- package/src/tui/render.ts +4 -1
- package/src/urls.ts +2 -0
- package/src/api.ts +0 -78
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
|
+
"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
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
1
2
|
import { difficultyLabels, domainLabels, focusSummary, skillLabels } from "./focus.ts";
|
|
2
3
|
import { progressBarText } from "./progress.ts";
|
|
3
4
|
import { buildSummaryRows, loadAttemptEvents, loadAttempts, loadFocus } from "./state.ts";
|
|
@@ -16,6 +17,7 @@ export type HistoryFilters = {
|
|
|
16
17
|
export type ParsedCli =
|
|
17
18
|
| { kind: "tui" }
|
|
18
19
|
| { kind: "review" }
|
|
20
|
+
| { kind: "version" }
|
|
19
21
|
| { kind: "help" }
|
|
20
22
|
| { kind: "error"; message: string }
|
|
21
23
|
| {
|
|
@@ -107,6 +109,10 @@ export function parseArgs(args: string[]): ParsedCli {
|
|
|
107
109
|
return { kind: "help" };
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
if (arg === "-V" || arg === "--version") {
|
|
113
|
+
return { kind: "version" };
|
|
114
|
+
}
|
|
115
|
+
|
|
110
116
|
if (arg === "-p" || arg === "--pretty") {
|
|
111
117
|
pretty = true;
|
|
112
118
|
continue;
|
|
@@ -222,6 +228,11 @@ export async function runCliCommand(
|
|
|
222
228
|
return 0;
|
|
223
229
|
}
|
|
224
230
|
|
|
231
|
+
if (parsed.kind === "version") {
|
|
232
|
+
stdout.write(`sat ${await packageVersion()}\n`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
225
236
|
if (parsed.kind === "error") {
|
|
226
237
|
stderr.write(`${parsed.message}\n\n${helpText()}\n`);
|
|
227
238
|
return 1;
|
|
@@ -392,12 +403,19 @@ export function helpText(): string {
|
|
|
392
403
|
" -p, --pretty Use colorized human-readable output",
|
|
393
404
|
" --json Output JSON",
|
|
394
405
|
" --no-color Disable ANSI color in pretty output",
|
|
406
|
+
" -V, --version Show version",
|
|
395
407
|
" -h, --help Show this help",
|
|
396
408
|
"",
|
|
397
409
|
"Run `sat` with no command to open the interactive TUI.",
|
|
398
410
|
].join("\n");
|
|
399
411
|
}
|
|
400
412
|
|
|
413
|
+
async function packageVersion(): Promise<string> {
|
|
414
|
+
const packageJson = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
415
|
+
const parsed = JSON.parse(packageJson) as { version?: unknown };
|
|
416
|
+
return typeof parsed.version === "string" && parsed.version ? parsed.version : "unknown";
|
|
417
|
+
}
|
|
418
|
+
|
|
401
419
|
export function filterHistory(attempts: Attempt[], filters: HistoryFilters = {}, now = new Date()): Attempt[] {
|
|
402
420
|
let rows = [...attempts].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
403
421
|
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
271
|
+
state.nextQuestion = getPracticeQuestion(questionExclusions(state), state.focus).catch(() => undefined);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
function questionExclusions(state: AppState): string[] {
|
package/src/tui/question.ts
CHANGED
|
@@ -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
|
|
34
|
+
return `${siteUrl}/question/${encodeURIComponent(question.meta.questionId)}`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function practiceQuestionApiUrl(question: PracticeQuestion): string {
|
|
37
|
-
return
|
|
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,
|
|
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
package/src/api.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { defaultFocus, domainsForSkills } from "./focus.ts";
|
|
2
|
-
import type { Focus, PracticeQuestion, QuestionDetail, QuestionMeta } from "./types.ts";
|
|
3
|
-
|
|
4
|
-
const baseUrl = "https://practicesat.vercel.app/api";
|
|
5
|
-
|
|
6
|
-
type ApiEnvelope<T> = {
|
|
7
|
-
success: boolean;
|
|
8
|
-
data: T;
|
|
9
|
-
message?: string;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export async function fetchQuestionBank(excludeIds: Iterable<string> = [], focus: Focus = defaultFocus): Promise<QuestionMeta[]> {
|
|
13
|
-
const params = focusParams(focus);
|
|
14
|
-
const excluded = [...excludeIds].filter(Boolean);
|
|
15
|
-
if (excluded.length > 0) {
|
|
16
|
-
params.set("excludeIds", excluded.join(","));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const response = await fetchJson<ApiEnvelope<QuestionMeta[]>>(`${baseUrl}/get-questions?${params}`);
|
|
20
|
-
if (!response.success || !Array.isArray(response.data)) {
|
|
21
|
-
throw new Error(response.message || "Question bank fetch failed.");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return response.data.filter((item) => item.questionId && item.external_id);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function fetchQuestion(id: string): Promise<QuestionDetail> {
|
|
28
|
-
const response = await fetchJson<ApiEnvelope<QuestionDetail>>(`${baseUrl}/question/${id}`);
|
|
29
|
-
if (!response.success || !response.data) {
|
|
30
|
-
throw new Error(response.message || `Question ${id} fetch failed.`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return response.data;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function fetchPracticeQuestion(attemptedIds: Iterable<string>, focus: Focus = defaultFocus): Promise<PracticeQuestion> {
|
|
37
|
-
const bank = await fetchQuestionBank(attemptedIds, focus);
|
|
38
|
-
if (bank.length === 0) {
|
|
39
|
-
throw new Error("No unanswered questions matched the current filters.");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const meta = bank[Math.floor(Math.random() * bank.length)];
|
|
43
|
-
const detail = await fetchQuestion(meta.external_id);
|
|
44
|
-
return { meta, detail };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function findQuestionByShortId(questionId: string): Promise<PracticeQuestion | undefined> {
|
|
48
|
-
const bank = await fetchQuestionBank();
|
|
49
|
-
const meta = bank.find((item) => item.questionId === questionId);
|
|
50
|
-
if (!meta) {
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return { meta, detail: await fetchQuestion(meta.external_id) };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function focusParams(focus: Focus): URLSearchParams {
|
|
58
|
-
return new URLSearchParams({
|
|
59
|
-
assessment: "SAT",
|
|
60
|
-
domains: domainsForSkills(focus.skills).join(","),
|
|
61
|
-
difficulties: focus.difficulties.join(","),
|
|
62
|
-
skills: focus.skills.join(","),
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async function fetchJson<T>(url: string): Promise<T> {
|
|
67
|
-
const response = await fetch(url, {
|
|
68
|
-
headers: {
|
|
69
|
-
accept: "application/json",
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
if (!response.ok) {
|
|
74
|
-
throw new Error(`${response.status} ${response.statusText}`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return (await response.json()) as T;
|
|
78
|
-
}
|