learn-loops 0.4.3 → 0.4.4

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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as p from "@clack/prompts";
3
- import { listTopics, loadProfile, configDir } from "./store.js";
3
+ import { listTopics, loadProfile, configDir, deleteTopic } from "./store.js";
4
4
  import { PROVIDER_HELP, allStatuses, noProviderMessage, setNotifier } from "./llm.js";
5
5
  import { isDue } from "./srs.js";
6
6
  import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
@@ -19,9 +19,12 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
19
19
  loops streak your streak
20
20
  loops map show a topic's map and prerequisite diagram
21
21
  loops topics list your topics and progress
22
+ loops delete delete a topic and all its notes and progress
22
23
  loops profile edit how you like to learn
23
24
  loops --help this help
24
25
 
26
+ To delete a course: run ${t.bold("loops delete")}, or choose "Delete a topic" from the menu.
27
+
25
28
  Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
26
29
  const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
27
30
  function progressOf(tp) {
@@ -102,12 +105,15 @@ async function startScreen(profile, topics, rec) {
102
105
  }),
103
106
  { value: "__new", label: t.accent("+ Start something new") },
104
107
  { value: "__menu", label: t.dim("Go to the main menu") },
108
+ { value: "__delete", label: t.dim("Delete a topic"), hint: "or run `loops delete`" },
105
109
  ],
106
110
  }));
107
111
  if (choice === "__menu")
108
112
  return;
109
113
  if (choice === "__new")
110
114
  return dispatch("new", profile);
115
+ if (choice === "__delete")
116
+ return dispatch("delete", profile);
111
117
  const tp = recent[Number(choice)];
112
118
  if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
113
119
  printMap(tp);
@@ -134,7 +140,7 @@ async function menu(profile) {
134
140
  ...(due ? [{ value: "review", label: "Review flashcards", hint: `${due} due${star("review")}` }] : []),
135
141
  ...(cards >= 4 ? [{ value: "quiz", label: "Mixed quiz", hint: `interleaved, weakest first${star("quiz")}` }] : []),
136
142
  ...(topics.some((tp) => tp.state.mistakes?.length) ? [{ value: "mistakes", label: "Mistake journal" }] : []),
137
- ...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
143
+ ...(topics.length ? [{ value: "topics", label: "My topics" }, { value: "delete", label: "Delete a topic" }] : []),
138
144
  ];
139
145
  // put the recommendation first
140
146
  opts.sort((a, b) => Number(b.value === rec) - Number(a.value === rec));
@@ -216,6 +222,17 @@ async function dispatch(action, profile, arg) {
216
222
  printMap(tp);
217
223
  break;
218
224
  }
225
+ case "delete": {
226
+ if (!topics.length)
227
+ return p.log.warn("No topics to delete.");
228
+ const tp = arg ? topics.find((x) => x.state.title.toLowerCase() === arg.toLowerCase()) ?? (await chooseTopic(topics, "Which topic?")) : await chooseTopic(topics, "Which topic do you want to delete?");
229
+ const ok = must(await p.confirm({ message: `Delete "${tp.state.title}" and all its notes, cards and progress? This can't be undone.`, initialValue: false }));
230
+ if (!ok)
231
+ return p.log.info("Kept it.");
232
+ await deleteTopic(tp);
233
+ p.log.success(t.ok(`Deleted "${tp.state.title}".`));
234
+ break;
235
+ }
219
236
  case "profile":
220
237
  await onboard(profile);
221
238
  break;
@@ -255,7 +272,7 @@ async function main() {
255
272
  }
256
273
  if (!cmd)
257
274
  return menu(profile);
258
- if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
275
+ if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map", "delete"].includes(cmd))
259
276
  return dispatch(cmd, profile, rest.join(" ") || undefined);
260
277
  console.log(HELP);
261
278
  process.exit(1);
package/dist/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile, readdir, rm } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, dirname } from "node:path";
@@ -54,6 +54,13 @@ export async function listTopics(profile) {
54
54
  return topics.sort((a, b) => lastTouched(b).localeCompare(lastTouched(a)));
55
55
  }
56
56
  const lastTouched = (t) => t.state.cards.map((c) => c.due).sort().pop() ?? t.state.createdAt;
57
+ /** Removes a topic's folder (notes, cards, progress) and its category folder if that leaves it empty. */
58
+ export async function deleteTopic(topic) {
59
+ await rm(topic.dir, { recursive: true, force: true });
60
+ const cat = dirname(topic.dir);
61
+ if (!(await readdir(cat).catch(() => ["x"])).length)
62
+ await rm(cat, { recursive: true, force: true });
63
+ }
57
64
  export async function listCategories(profile) {
58
65
  const root = expandHome(profile.rootDir);
59
66
  if (!existsSync(root))
package/dist/table.js ADDED
@@ -0,0 +1,50 @@
1
+ import Table from "cli-table3";
2
+ export const isTableLine = (s) => /^\s*\|.*\|\s*$/.test(s);
3
+ const ANSI = /\x1b\[[0-9;]*m/g;
4
+ const visible = (s) => s.replace(ANSI, "").length;
5
+ function splitRow(line) {
6
+ const body = line.trim().replace(/^\|/, "").replace(/\|$/, "");
7
+ return body.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|").trim());
8
+ }
9
+ const alignOf = (c) => (/^:-+:$/.test(c) ? "center" : /^-+:$/.test(c) ? "right" : "left");
10
+ /**
11
+ * Draw a markdown table (consecutive `| a | b |` lines) as a bordered, aligned box that wraps to maxWidth.
12
+ * `cell` styles a cell's text (e.g. inline bold/code/math); `head` styles header cells.
13
+ */
14
+ export function renderTable(lines, cell, head, border, maxWidth) {
15
+ const rows = lines.map(splitRow);
16
+ const hasHeader = rows.length > 1 && rows[1].every((c) => /^:?-+:?$/.test(c));
17
+ const aligns = hasHeader ? rows[1].map(alignOf) : [];
18
+ const data = hasHeader ? [rows[0], ...rows.slice(2)] : rows;
19
+ const n = Math.max(...data.map((r) => r.length));
20
+ const grid = data.map((r, i) => Array.from({ length: n }, (_, c) => (hasHeader && i === 0 ? head : cell)(r[c] ?? "")));
21
+ const widths = Array.from({ length: n }, (_, c) => Math.max(1, ...grid.map((r) => Math.max(...r[c].split("\n").map(visible)))));
22
+ const budget = maxWidth - (3 * n + 1);
23
+ if (budget < 5 * n)
24
+ return stacked(data, hasHeader, cell, head);
25
+ while (widths.reduce((a, b) => a + b, 0) > budget)
26
+ widths[widths.indexOf(Math.max(...widths))]--;
27
+ const table = new Table({
28
+ chars: { top: "─", "top-mid": "┬", "top-left": "╭", "top-right": "╮", bottom: "─", "bottom-mid": "┴", "bottom-left": "╰", "bottom-right": "╯", left: "│", "left-mid": "├", mid: "─", "mid-mid": "┼", right: "│", "right-mid": "┤", middle: "│" },
29
+ style: { head: [], border: [], "padding-left": 1, "padding-right": 1 },
30
+ colWidths: widths.map((w) => w + 2),
31
+ colAligns: Array.from({ length: n }, (_, c) => aligns[c] ?? "left"),
32
+ wordWrap: true,
33
+ });
34
+ grid.forEach((r) => table.push(r));
35
+ let drawn = table.toString().split("\n");
36
+ // keep the rule under the header; drop the ones between single-line rows (they only add noise)
37
+ const wrapped = drawn.length - (grid.length * 2 + 1) > 0;
38
+ if (!wrapped)
39
+ drawn = drawn.filter((l, i) => !l.startsWith("├") || i === drawn.findIndex((x) => x.startsWith("├")));
40
+ return drawn.map((l) => l.replace(/[─│╭╮╰╯├┤┬┴┼]+/g, border));
41
+ }
42
+ /** Fallback for terminals too narrow for a grid: one "header: value" block per row. */
43
+ function stacked(data, hasHeader, cell, head) {
44
+ const out = [];
45
+ const keys = hasHeader ? data[0] : [];
46
+ for (const r of hasHeader ? data.slice(1) : data) {
47
+ out.push(...r.map((v, c) => (keys[c] ? `${head(keys[c])}: ${cell(v)}` : cell(v))), "");
48
+ }
49
+ return out.slice(0, -1);
50
+ }
package/dist/ui.js CHANGED
@@ -3,6 +3,7 @@ import pc from "picocolors";
3
3
  import { renderDiagram } from "./diagram.js";
4
4
  import { parsePlot, plotToBraille, renderPlot, sanitizeSvg, svgCaption } from "./figures.js";
5
5
  import { mathToText, svgToPng, texToLines, texToPng } from "./math.js";
6
+ import { isTableLine, renderTable } from "./table.js";
6
7
  import { drawImage, imageProtocol } from "./termimg.js";
7
8
  const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
8
9
  const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
@@ -112,7 +113,15 @@ export function renderMd(md) {
112
113
  .replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
113
114
  .replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
114
115
  .replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
116
+ let tableBuf = [];
117
+ const flushTable = () => {
118
+ const cols = process.stdout.columns ?? 100;
119
+ out.push(...renderTable(tableBuf, inline, (x) => t.bold(t.accent(inline(x))), t.dim, cols - 4).map((l) => " " + l));
120
+ tableBuf = [];
121
+ };
115
122
  for (const raw of md.split("\n")) {
123
+ if (tableBuf.length && !isTableLine(raw))
124
+ flushTable();
116
125
  // display math: $$ … $$ (one or many lines) and \[ … \]
117
126
  if (math) {
118
127
  const close = raw.match(/^(.*?)(\$\$|\\\])\s*$/);
@@ -162,6 +171,10 @@ export function renderMd(md) {
162
171
  out.push(" " + t.dim("│ ") + t.cyan(raw));
163
172
  continue;
164
173
  }
174
+ if (isTableLine(raw)) {
175
+ tableBuf.push(raw);
176
+ continue;
177
+ }
165
178
  const h = raw.match(/^(#{1,4})\s+(.*)/);
166
179
  if (h) {
167
180
  out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(inline(h[2]))) : t.bold(inline(h[2]))));
@@ -179,6 +192,8 @@ export function renderMd(md) {
179
192
  }
180
193
  out.push(raw.trim() ? " " + inline(raw) : "");
181
194
  }
195
+ if (tableBuf.length)
196
+ flushTable();
182
197
  return out.join("\n");
183
198
  }
184
199
  /** Plain-text math for prompts, options and flashcards. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "learn-loops",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "A learning CLI that uses Claude Code / Codex as your tutor",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "@clack/prompts": "^1.8.1",
22
22
  "@resvg/resvg-js": "^2.6.2",
23
23
  "beautiful-mermaid": "^1.1.3",
24
+ "cli-table3": "^0.6.5",
24
25
  "mathjax-full": "^3.2.1",
25
26
  "mathjs": "^15.2.0",
26
27
  "picocolors": "^1.1.1",