leetcode-capture-helper 0.0.0-stage → 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/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +346 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2193 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +169 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1344 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +277 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// extension/shared/local-server.js - best-effort facts about the local model server
|
|
2
|
+
// LocalBackend talks to: whether it is Ollama (so its native chat API, the
|
|
3
|
+
// only one that honors a requested context window, can be used), and how big
|
|
4
|
+
// the model's context window really is. Works with any OpenAI-compatible
|
|
5
|
+
// server: every probe is optional, has a short timeout, and is ignored on any
|
|
6
|
+
// error or unexpected shape, so an unknown server simply keeps the
|
|
7
|
+
// configured window (plus LocalBackend's silent-truncation guard). Uses only
|
|
8
|
+
// fetch and AbortController, no Node API, so it can also run inside the
|
|
9
|
+
// browser extension.
|
|
10
|
+
//
|
|
11
|
+
// Probes, all read-only:
|
|
12
|
+
// - Ollama: GET /api/version (identifies it), POST /api/show (the model's
|
|
13
|
+
// trained context_length) and GET /api/ps (the window a loaded model is
|
|
14
|
+
// running with, which the OpenAI-compatible endpoint is stuck with).
|
|
15
|
+
// - LM Studio: GET /api/v0/models (loaded_context_length, else
|
|
16
|
+
// max_context_length).
|
|
17
|
+
// - llama.cpp server: GET /props (default_generation_settings.n_ctx).
|
|
18
|
+
// - vLLM: GET <base>/models (max_model_len).
|
|
19
|
+
|
|
20
|
+
export const PROBE_TIMEOUT_MS = 1500;
|
|
21
|
+
|
|
22
|
+
// The server root, for the routes that do not live under the
|
|
23
|
+
// OpenAI-compatible /v1 prefix: a trailing /v1 (and slashes) is dropped.
|
|
24
|
+
export function serverRoot(baseUrl) {
|
|
25
|
+
return String(baseUrl || '')
|
|
26
|
+
.replace(/\/+$/, '')
|
|
27
|
+
.replace(/\/v1$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function positiveInt(value) {
|
|
31
|
+
const n = Number(value);
|
|
32
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ollama names a model "name:tag" and fills in ":latest" when none is given.
|
|
36
|
+
function sameOllamaModel(name, model) {
|
|
37
|
+
if (typeof name !== 'string' || !model) return false;
|
|
38
|
+
const withTag = (s) => (s.includes(':') ? s : `${s}:latest`);
|
|
39
|
+
return withTag(name) === withTag(model);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// GET /api/version answers {"version": "0.x.y"} on Ollama and nothing like
|
|
43
|
+
// it anywhere else.
|
|
44
|
+
export function looksLikeOllamaVersion(json) {
|
|
45
|
+
return Boolean(json && typeof json === 'object' && typeof json.version === 'string' && json.version);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// POST /api/show: model_info carries "<architecture>.context_length".
|
|
49
|
+
export function parseOllamaShow(json) {
|
|
50
|
+
const info = json && typeof json === 'object' ? json.model_info : null;
|
|
51
|
+
if (!info || typeof info !== 'object') return null;
|
|
52
|
+
for (const [key, value] of Object.entries(info)) {
|
|
53
|
+
if (key.endsWith('.context_length')) {
|
|
54
|
+
const n = positiveInt(value);
|
|
55
|
+
if (n) return n;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// GET /api/ps: the loaded models, each with the context_length it runs with.
|
|
62
|
+
export function parseOllamaPs(json, model) {
|
|
63
|
+
const models = json && Array.isArray(json.models) ? json.models : [];
|
|
64
|
+
const entry = models.find((m) => m && (sameOllamaModel(m.name, model) || sameOllamaModel(m.model, model)));
|
|
65
|
+
return entry ? positiveInt(entry.context_length) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// GET /api/v0/models (LM Studio): the loaded window wins over the maximum.
|
|
69
|
+
export function parseLmStudioModels(json, model) {
|
|
70
|
+
const models = json && Array.isArray(json.data) ? json.data : [];
|
|
71
|
+
const entry = models.find((m) => m && m.id === model);
|
|
72
|
+
if (!entry) return null;
|
|
73
|
+
return positiveInt(entry.loaded_context_length) || positiveInt(entry.max_context_length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// GET /props (llama.cpp server): the per-slot window.
|
|
77
|
+
export function parseLlamaCppProps(json) {
|
|
78
|
+
if (!json || typeof json !== 'object') return null;
|
|
79
|
+
return positiveInt(json.default_generation_settings?.n_ctx) || positiveInt(json.n_ctx);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// GET /v1/models (vLLM): max_model_len on the served model.
|
|
83
|
+
export function parseOpenAIModels(json, model) {
|
|
84
|
+
const models = json && Array.isArray(json.data) ? json.data : [];
|
|
85
|
+
const entry = models.find((m) => m && m.id === model);
|
|
86
|
+
return entry ? positiveInt(entry.max_model_len) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One probe: the parsed JSON body of a 2xx answer, or null on anything else
|
|
90
|
+
// (refused, timed out, not JSON, an error status).
|
|
91
|
+
export async function probeJson(url, { method = 'GET', body, headers = {}, timeoutMs = PROBE_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetchImpl(url, {
|
|
96
|
+
method,
|
|
97
|
+
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
|
98
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
response.body?.cancel?.().catch(() => {});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const text = await response.text();
|
|
106
|
+
return JSON.parse(text);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Runs every probe at once and reports what it learned:
|
|
115
|
+
// `{ ollama, window }`, where `window` is `{ tokens, source }` (the smallest
|
|
116
|
+
// window any probe reported) or null. `apiStyle` is the style that will be
|
|
117
|
+
// used ('native' or 'openai'); a loaded Ollama model's running window only
|
|
118
|
+
// matters for 'openai', since native requests ask for their own.
|
|
119
|
+
export async function probeServer({ baseUrl, model, apiKey = null, apiStyle = null, timeoutMs = PROBE_TIMEOUT_MS, fetchImpl = fetch }) {
|
|
120
|
+
const root = serverRoot(baseUrl);
|
|
121
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
122
|
+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
123
|
+
const get = (url) => probeJson(url, { headers, timeoutMs, fetchImpl });
|
|
124
|
+
const [version, show, ps, lmStudio, props, models] = await Promise.all([
|
|
125
|
+
get(`${root}/api/version`),
|
|
126
|
+
probeJson(`${root}/api/show`, { method: 'POST', body: { model }, headers, timeoutMs, fetchImpl }),
|
|
127
|
+
get(`${root}/api/ps`),
|
|
128
|
+
get(`${root}/api/v0/models`),
|
|
129
|
+
get(`${root}/props`),
|
|
130
|
+
get(`${base}/models`),
|
|
131
|
+
]);
|
|
132
|
+
const ollama = looksLikeOllamaVersion(version);
|
|
133
|
+
const style = apiStyle || (ollama ? 'native' : 'openai');
|
|
134
|
+
const found = [];
|
|
135
|
+
if (ollama) {
|
|
136
|
+
const trained = parseOllamaShow(show);
|
|
137
|
+
if (trained) found.push({ tokens: trained, source: 'Ollama /api/show' });
|
|
138
|
+
const running = style === 'openai' ? parseOllamaPs(ps, model) : null;
|
|
139
|
+
if (running) found.push({ tokens: running, source: 'Ollama /api/ps' });
|
|
140
|
+
}
|
|
141
|
+
const lm = parseLmStudioModels(lmStudio, model);
|
|
142
|
+
if (lm) found.push({ tokens: lm, source: 'LM Studio /api/v0/models' });
|
|
143
|
+
const llama = parseLlamaCppProps(props);
|
|
144
|
+
if (llama) found.push({ tokens: llama, source: 'llama.cpp /props' });
|
|
145
|
+
const vllm = parseOpenAIModels(models, model);
|
|
146
|
+
if (vllm) found.push({ tokens: vllm, source: '/v1/models max_model_len' });
|
|
147
|
+
const window = found.reduce((min, w) => (!min || w.tokens < min.tokens ? w : min), null);
|
|
148
|
+
return { ollama, apiStyle: style, window };
|
|
149
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// extension/shared/practice.js - "Practice a skill": problems from all of
|
|
2
|
+
// LeetCode (shared/problem-index.js, bundled public metadata) for one skill,
|
|
3
|
+
// ranked by popularity near a reference difficulty. Pure: no storage, no
|
|
4
|
+
// network, no Node or browser APIs (extension/shared.test.js checks that).
|
|
5
|
+
//
|
|
6
|
+
// Skills are a fixed friendly list mapped onto LeetCode's own topic tags,
|
|
7
|
+
// never the tags of the problem the student is on, so a suggestion never
|
|
8
|
+
// hints at how that problem is solved.
|
|
9
|
+
//
|
|
10
|
+
// Mapping decisions (SKILLS below):
|
|
11
|
+
// - A skill matches a problem when any one of its `tags` entries matches.
|
|
12
|
+
// An entry is a tag slug, or an array of slugs that must all be present
|
|
13
|
+
// (Graphs counts a grid search, BFS or DFS together with Matrix, since
|
|
14
|
+
// LeetCode tags Number of Islands and Rotting Oranges that way and never
|
|
15
|
+
// "graph").
|
|
16
|
+
// - Graphs ("Graph Theory" on LeetCode, slug `graph`) also takes Union
|
|
17
|
+
// Find and the specialised graph tags (topological sort, shortest path,
|
|
18
|
+
// Dijkstra, DAGs, bipartite graphs, minimum spanning trees, strongly
|
|
19
|
+
// connected components, Eulerian circuits, biconnected components).
|
|
20
|
+
// - Trees take Tree, Binary Tree and Binary Search Tree; Linked lists take
|
|
21
|
+
// Doubly-Linked List; Dynamic programming takes Memoization; Bit
|
|
22
|
+
// manipulation takes Bitmask; Math takes Number Theory, Combinatorics
|
|
23
|
+
// and Geometry; Strings take String Matching; Dynamic programming also
|
|
24
|
+
// takes Knapsack and DP on Trees; Heaps take the lone Heap tag.
|
|
25
|
+
// - LeetCode has no intervals tag, so Intervals is Sweep Line, a problem
|
|
26
|
+
// of a bundled list's Intervals topic (`listTopic`), or a title naming an
|
|
27
|
+
// interval together with Sorting, Greedy, a heap or an ordered set
|
|
28
|
+
// (`titles`; a title alone also caught "Count Odd Numbers in an Interval
|
|
29
|
+
// Range" and a JavaScript exercise).
|
|
30
|
+
// - Left out on purpose: Sorting, Simulation, Counting, Enumeration and
|
|
31
|
+
// similar tags (an aid in most problems, not a skill to practice on its
|
|
32
|
+
// own), Database (SQL, which the SQL 50 list covers), Concurrency and
|
|
33
|
+
// Shell.
|
|
34
|
+
//
|
|
35
|
+
// Ranking (practiceSuggestions):
|
|
36
|
+
// - Candidates: the skill matches, not paid-only, not solved, not the
|
|
37
|
+
// excluded problem (the one the student is on).
|
|
38
|
+
// - Tiers by difficulty: the reference level first, then one level up,
|
|
39
|
+
// then one level down (Easy: Easy, Medium; Medium: Medium, Hard, Easy;
|
|
40
|
+
// Hard: Hard, Medium). No reference (a problem the index does not have):
|
|
41
|
+
// one tier of every level.
|
|
42
|
+
// - Within a tier, by score: LeetCode likes, times CURATED_BOOST when the
|
|
43
|
+
// problem is on one of the bundled lists (shared/roadmap.js LISTS); then
|
|
44
|
+
// total submissions, then frontend id, then slug, so the order is stable.
|
|
45
|
+
|
|
46
|
+
import { PROBLEM_INDEX } from './problem-index.js';
|
|
47
|
+
import { LISTS } from './roadmap.js';
|
|
48
|
+
|
|
49
|
+
export const LEVELS = ['Easy', 'Medium', 'Hard'];
|
|
50
|
+
export const PAGE_SIZE = 3;
|
|
51
|
+
export const CURATED_BOOST = 1.25;
|
|
52
|
+
const PAID = 1;
|
|
53
|
+
|
|
54
|
+
const GRAPH_TAGS = ['graph', 'union-find', 'topological-sort', 'shortest-path', 'dijkstra', 'directed-acyclic-graph', 'bipartite-graph', 'minimum-spanning-tree', 'strongly-connected-component', 'eulerian-circuit', 'biconnected-component'];
|
|
55
|
+
|
|
56
|
+
// In the order the chips show: the basics first, then structures, then
|
|
57
|
+
// techniques.
|
|
58
|
+
export const SKILLS = [
|
|
59
|
+
{ id: 'arrays', label: 'Arrays', tags: ['array'] },
|
|
60
|
+
{ id: 'hash-maps', label: 'Hash maps', tags: ['hash-table'] },
|
|
61
|
+
{ id: 'strings', label: 'Strings', tags: ['string', 'string-matching'] },
|
|
62
|
+
{ id: 'two-pointers', label: 'Two pointers', tags: ['two-pointers'] },
|
|
63
|
+
{ id: 'sliding-window', label: 'Sliding window', tags: ['sliding-window'] },
|
|
64
|
+
{ id: 'stacks', label: 'Stacks', tags: ['stack'] },
|
|
65
|
+
{ id: 'monotonic-stack', label: 'Monotonic stack', tags: ['monotonic-stack'] },
|
|
66
|
+
{ id: 'binary-search', label: 'Binary search', tags: ['binary-search'] },
|
|
67
|
+
{ id: 'linked-lists', label: 'Linked lists', tags: ['linked-list', 'doubly-linked-list'] },
|
|
68
|
+
{ id: 'trees', label: 'Trees', tags: ['tree', 'binary-tree', 'binary-search-tree'] },
|
|
69
|
+
{ id: 'tries', label: 'Tries', tags: ['trie'] },
|
|
70
|
+
{ id: 'heaps', label: 'Heaps', tags: ['heap-priority-queue', 'heap'] },
|
|
71
|
+
{ id: 'graphs', label: 'Graphs', tags: [...GRAPH_TAGS, ['breadth-first-search', 'matrix'], ['depth-first-search', 'matrix']] },
|
|
72
|
+
{ id: 'backtracking', label: 'Backtracking', tags: ['backtracking'] },
|
|
73
|
+
{ id: 'dynamic-programming', label: 'Dynamic programming', tags: ['dynamic-programming', 'memoization', 'knapsack-problem', 'dp-on-trees'] },
|
|
74
|
+
{ id: 'greedy', label: 'Greedy', tags: ['greedy'] },
|
|
75
|
+
{ id: 'intervals', label: 'Intervals', tags: ['sweep-line'], listTopic: /^intervals?$/i, titles: { pattern: /\bintervals?\b/i, withAny: ['sorting', 'greedy', 'heap-priority-queue', 'ordered-set'] } },
|
|
76
|
+
{ id: 'bit-manipulation', label: 'Bit manipulation', tags: ['bit-manipulation', 'bitmask'] },
|
|
77
|
+
{ id: 'math', label: 'Math', tags: ['math', 'number-theory', 'combinatorics', 'geometry'] },
|
|
78
|
+
{ id: 'design', label: 'Design', tags: ['design'] },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const SKILL_BY_ID = new Map(SKILLS.map((s) => [s.id, s]));
|
|
82
|
+
|
|
83
|
+
export function skillById(id) {
|
|
84
|
+
return (typeof id === 'string' && SKILL_BY_ID.get(id)) || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// What the panel shows of the list: ids and labels only.
|
|
88
|
+
export function skillChoices() {
|
|
89
|
+
return SKILLS.map((s) => ({ id: s.id, label: s.label }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The index decoded once: { bySlug, problems: [{ id, slug, title, level,
|
|
93
|
+
// paid, likes, submissions, tags: Set<slug>, curated: list title or null }] }.
|
|
94
|
+
const cache = new WeakMap();
|
|
95
|
+
function decode(index) {
|
|
96
|
+
let decoded = cache.get(index);
|
|
97
|
+
if (decoded) return decoded;
|
|
98
|
+
const onList = new Map();
|
|
99
|
+
const topics = new Map(); // slug -> titles of the list topics that have it
|
|
100
|
+
for (const list of LISTS) for (const topic of list.topics) for (const p of topic.problems) {
|
|
101
|
+
if (!onList.has(p.slug)) onList.set(p.slug, list.title);
|
|
102
|
+
topics.set(p.slug, [...(topics.get(p.slug) || []), topic.title]);
|
|
103
|
+
}
|
|
104
|
+
const problems = index.problems.map(([id, slug, title, level, flags, likes, submissions, tags]) => ({
|
|
105
|
+
id,
|
|
106
|
+
slug,
|
|
107
|
+
title,
|
|
108
|
+
level,
|
|
109
|
+
paid: (flags & PAID) === PAID,
|
|
110
|
+
likes,
|
|
111
|
+
submissions,
|
|
112
|
+
tags: new Set(tags.map((t) => index.tags[t][0])),
|
|
113
|
+
curated: onList.get(slug) || null,
|
|
114
|
+
topics: topics.get(slug) || [],
|
|
115
|
+
}));
|
|
116
|
+
decoded = { problems, bySlug: new Map(problems.map((p) => [p.slug, p])), ranked: new Map() };
|
|
117
|
+
cache.set(index, decoded);
|
|
118
|
+
return decoded;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function problemBySlug(slug, index = PROBLEM_INDEX) {
|
|
122
|
+
const p = typeof slug === 'string' ? decode(index).bySlug.get(slug) : null;
|
|
123
|
+
return p ? { slug: p.slug, title: p.title, difficulty: LEVELS[p.level], paid: p.paid } : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The difficulty of a problem the index has, or null.
|
|
127
|
+
export function referenceLevel(slug, index = PROBLEM_INDEX) {
|
|
128
|
+
const p = problemBySlug(slug, index);
|
|
129
|
+
return p ? p.difficulty : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function matchesSkill(skill, problem) {
|
|
133
|
+
for (const entry of skill.tags) {
|
|
134
|
+
if (Array.isArray(entry) ? entry.every((t) => problem.tags.has(t)) : problem.tags.has(entry)) return true;
|
|
135
|
+
}
|
|
136
|
+
if (skill.listTopic && (problem.topics || []).some((t) => skill.listTopic.test(t))) return true;
|
|
137
|
+
return Boolean(skill.titles && skill.titles.pattern.test(problem.title) && skill.titles.withAny.some((t) => problem.tags.has(t)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The levels in the order they are offered for a reference level; null
|
|
141
|
+
// means one tier of all levels.
|
|
142
|
+
export function levelTiers(reference) {
|
|
143
|
+
const level = LEVELS.indexOf(reference);
|
|
144
|
+
if (level < 0) return [[0, 1, 2]];
|
|
145
|
+
return [level, level + 1, level - 1].filter((l) => l >= 0 && l < LEVELS.length).map((l) => [l]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function score(p) {
|
|
149
|
+
return p.likes * (p.curated ? CURATED_BOOST : 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compare(a, b) {
|
|
153
|
+
return score(b) - score(a) || b.submissions - a.submissions || compareIds(a.id, b.id) || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function compareIds(a, b) {
|
|
157
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
158
|
+
return String(a).localeCompare(String(b), 'en', { numeric: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Every free problem of the skill in offer order for a reference level,
|
|
162
|
+
// solved or not (filtered per call), cached per skill and level.
|
|
163
|
+
function rankedFor(skill, reference, index) {
|
|
164
|
+
const decoded = decode(index);
|
|
165
|
+
const key = `${skill.id}|${reference || ''}`;
|
|
166
|
+
let ranked = decoded.ranked.get(key);
|
|
167
|
+
if (ranked) return ranked;
|
|
168
|
+
const matching = decoded.problems.filter((p) => !p.paid && matchesSkill(skill, p));
|
|
169
|
+
ranked = [];
|
|
170
|
+
for (const tier of levelTiers(reference)) ranked.push(...matching.filter((p) => tier.includes(p.level)).sort(compare));
|
|
171
|
+
decoded.ranked.set(key, ranked);
|
|
172
|
+
return ranked;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatCount(n) {
|
|
176
|
+
if (n < 1000) return String(n);
|
|
177
|
+
if (n < 10000) return `${(Math.floor(n / 100) / 10).toFixed(1).replace(/\.0$/, '')}k`;
|
|
178
|
+
if (n < 1e6) return `${Math.floor(n / 1000)}k`;
|
|
179
|
+
return `${(Math.floor(n / 1e5) / 10).toFixed(1).replace(/\.0$/, '')}M`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const RELATION = { 0: 'Same level', 1: 'One level up', '-1': 'One level down' };
|
|
183
|
+
|
|
184
|
+
// The one short line under a suggestion: how its level compares, how liked
|
|
185
|
+
// it is, and the first bundled list that has it. Nothing about how to solve
|
|
186
|
+
// it, or the problem the student is on.
|
|
187
|
+
export function reasonFor(problem, reference) {
|
|
188
|
+
const parts = [];
|
|
189
|
+
const ref = LEVELS.indexOf(reference);
|
|
190
|
+
if (ref >= 0) parts.push(RELATION[problem.level - ref]);
|
|
191
|
+
parts.push(`${formatCount(problem.likes)} ${problem.likes === 1 ? 'like' : 'likes'}`);
|
|
192
|
+
if (problem.curated) parts.push(`on ${problem.curated}`);
|
|
193
|
+
return parts.join(' · ');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function solvedSet(solved) {
|
|
197
|
+
if (solved instanceof Set) return solved;
|
|
198
|
+
if (Array.isArray(solved)) return new Set(solved);
|
|
199
|
+
if (solved && typeof solved === 'object') return new Set(Object.keys(solved));
|
|
200
|
+
return new Set();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The suggestions for one skill:
|
|
204
|
+
// { skill: { id, label } | null, reference, suggestions: [{ slug, title,
|
|
205
|
+
// difficulty, reason }], offset, total, more }
|
|
206
|
+
// `reference` is 'Easy', 'Medium', 'Hard' or null; `solved` a Set, an array
|
|
207
|
+
// of slugs or the solved store's record; `exclude` a slug never offered
|
|
208
|
+
// (the problem the student is on); `offset` how many were shown already.
|
|
209
|
+
// An unknown skill answers no suggestions.
|
|
210
|
+
export function practiceSuggestions({ skill, reference = null, solved, exclude = null, offset = 0, count = PAGE_SIZE, index = PROBLEM_INDEX } = {}) {
|
|
211
|
+
const found = typeof skill === 'string' ? skillById(skill) : skill && SKILL_BY_ID.get(skill.id) === skill ? skill : null;
|
|
212
|
+
const level = LEVELS.includes(reference) ? reference : null;
|
|
213
|
+
const start = Number.isInteger(offset) && offset > 0 ? offset : 0;
|
|
214
|
+
const size = Number.isInteger(count) && count > 0 ? Math.min(count, 20) : PAGE_SIZE;
|
|
215
|
+
if (!found) return { skill: null, reference: level, suggestions: [], offset: 0, total: 0, more: false };
|
|
216
|
+
const done = solvedSet(solved);
|
|
217
|
+
const skip = new Set(Array.isArray(exclude) ? exclude : exclude ? [exclude] : []);
|
|
218
|
+
const open = rankedFor(found, level, index).filter((p) => !done.has(p.slug) && !skip.has(p.slug));
|
|
219
|
+
const page = open.slice(start, start + size);
|
|
220
|
+
return {
|
|
221
|
+
skill: { id: found.id, label: found.label },
|
|
222
|
+
reference: level,
|
|
223
|
+
suggestions: page.map((p) => ({ slug: p.slug, title: p.title, difficulty: LEVELS[p.level], reason: reasonFor(p, level) })),
|
|
224
|
+
offset: start,
|
|
225
|
+
total: open.length,
|
|
226
|
+
more: start + size < open.length,
|
|
227
|
+
};
|
|
228
|
+
}
|