SLOPSHOPPER

Daily Learning

Daily learning in Claude Code: Chitas (Chumash with Rashi, Tehillim, Tanya), Rambam, Hayom Yom and Daf Yomi (with Rashi) on one line above the prompt, and the…

newpanebandcommandtoastprompt
v0.12.2MITupdated 2026-10-08sharshi/daily-learning-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dl
│ ┃ Daily Learning ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Could not load: JSON Parse error: Unexpected ⏺ Update(src/auth.ts) │ ┃ identifier "dev" ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ✡ Daily Learning │ ┃ [ 📅 Today ][ 📚 Library ][ ⚙ Settings ] › /dl-toggle │ ┃ ──────────────────────────────────────────── ⎿ dl: Hidden for today (/dl-toggle shows it again). │ │ ✡ dl · loading… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✡ dl · loading…
Pane · Daily Learning
Could not load: JSON Parse error: Unexpected identifier "dev" ✡ Daily Learning [ 📅 Today ][ 📚 Library ][ ⚙ Settings ] ───────────────────────────────────────────────────────
README

Daily Learning Plugin

Your daily learning inside Claude Code: today's Chitas, Rambam, Hayom Yom and Daf Yomi, and a Library of Mishnayos, Shas and Mishneh Torah to learn through at your own pace, in Hebrew that reads right. · Website

Install

In a Claude Code session:

/plugin marketplace add sharshi/daily-learning-plugin
/plugin install dl@daily-learning

Then /dl opens the sidebar. Needs Claude Code 2.1.286+ and python3.

Daily Learning in the Claude Desktop app: the sidebar showing today's Tanya, Hebrew and English side by side, with the line above the prompt

Today

✡ 25 Tishrei 5787 · Parashat Bereshit   📅 Today's learning   📚 Library   ▶ Ketubot 5   ×

One line above the prompt: today's date, 📅 Today's learning, 📚 Library, and ▶ where you left each Library collection. Each opens the sidebar there; × hides the line until tomorrow.

While Claude works

On a longer-running task, the same line offers to fill the wait:

⏳ Claude's working. Learn while you wait:   ▶ Ketubot 5   📅 Chumash + Rashi   later

▶ picks up where you left the Library; 📅 opens the next part of today you haven't read yet (or Today's learning, once you've opened them all). Either one opens the sidebar right there. later puts the launcher back.

  • It appears once a turn has run 45 seconds, and never takes the keyboard, so you can keep typing.
  • When Claude finishes, it's gone, and a quiet "Claude is done" toast lets you know.
  • It shows at most once every 30 minutes, and never on a day you've hidden the line with ×.
  • While Claude works in ⚙ Settings: after 45 seconds, after 2 minutes, or off.

In the sidebar, 📅 Today is a menu of the day's sections; each opens into its full text, and next › walks through the day in order:

  • Chumash, the day's aliyah, with Rashi under each verse
  • Tehillim on the Chabad monthly cycle
  • Tanya, the whole day's portion
  • Rambam ×1 and ×3, perek by perek, each halacha numbered א. ב. ג.
  • Daf Yomi, amud by amud, with Rashi on each passage
  • Hayom Yom, when Sefaria has it

Library

📚 Library (l) is for learning through whole works:

  • Mishnayos: 63 masechtos, perek by perek
  • Shas: the Bavli daf by daf, with Rashi
  • Mishneh Torah: 14 sefarim, hilchos perek by perek

Pick a collection, a seder or sefer, a masechta or hilchos, then a perek or daf. Continue picks up where you left each one, in any session; next › runs straight on into the next masechta. Texts load from Sefaria only when you open them.

Hebrew that reads right

  • Fonts: Frank Ruhl (default) or Shlomo, bundled, with nikkud. Te'amim are left out.
  • English: off, under each paragraph, or side by side. Rashi's English too (Rosenbaum & Silbermann).
  • Every surface: the Desktop app draws each reading with the font built in; Ghostty gets every letter placed and its Hebrew mapped to the font; macOS Terminal is best effort (it reorders Hebrew itself).

All of it is on the sidebar's ⚙ Settings page (s), and in /config under dl.

Commands and keys

/dlOpen the sidebar
/dl-toggleHide or show the line above the prompt
/dl:textPrint today's text in the chat, to ask Claude about it
t l sToday, Library, Settings
1–7A section from today's menu
j kNext, previous (perek, amud, daf)

Sources

Texts and calendar from Sefaria, the Hebrew date from hebcal, with chabad.org as a fallback.

Develop

claude --plugin-dir .      # run from a clone; edits apply on /reload-plugins
claude plugin test .       # tests
claude plugin validate .   # what the mod does, and anything the engine would refuse

hooks/register.tsx is the mod (line, sidebar, fetching); hooks/data.ts, library.ts, hebrew.ts and svg.ts are pure helpers it draws with; scripts/dl.py fetches the day's text. An installed copy is a snapshot: bump version in .claude-plugin/plugin.json, then claude plugin update dl@daily-learning.

License

Code: MIT (LICENSE). Fonts: SIL Open Font License 1.1, Shlomo © SIL International and Shlomo Orbach, Frank Ruhl Libre © The Frank Ruhl Libre Project Authors (fonts/).

Source 7 files
hooks/register.tsx 892 lines
1// dl (Daily Learning): today's learning in a band above the prompt, and the full text in a
2// sidebar (/dl). Both are cached per Gregorian day, in memory and in
3// the mod's $.store. Data: ./data.ts; Hebrew layout: ./hebrew.ts.
4
5import { atom, read, update } from "claude-code";
6import type { EngineInterface as Host, Register, RenderChildren } from "claude-code";
7
8import type { Day, Status, TabId } from "../types";
9import { briefFrom, calendarUrl, DAY_V, hebcalUrl, hebrewDate, linesFor, todayKey, toDay } from "./data";
10import type { English } from "./data";
11import { COLL_ORDER, COLLS, findBook, libPart, parseShape, rashiRef, step, textRef, unitLabel, unitName } from "./library";
12import type { Coll, LibBook } from "./library";
13import type { Brief } from "./data";
14import { cleanHe, cleanText, termVisual, visual, wrap } from "./hebrew";
15import { svgPages } from "./svg";
16import type { EmbeddedFont, FontKey, Line } from "./svg";
17
18const PANE = "dl";
19const TABS: [TabId, string][] = [
20  ["chumash", "Chumash + Rashi"], ["tehillim", "Tehillim"],
21  ["tanya", "Tanya"], ["rambam1", "Rambam ×1"], ["rambam3", "Rambam ×3"], ["hayom", "Hayom Yom"],
22  ["daf", "Daf Yomi"],
23];
24
25
26// ---- pane state ---------------------------------------------------------
27const day = atom({ plugin: "dl", key: "day" } as const, null);
28const status = atom({ plugin: "dl", key: "status" } as const, null);
29const tab = atom({ plugin: "dl", key: "tab" } as const, null);
30const pageAtom = atom({ plugin: "dl", key: "page" } as const, "read");
31const libPos = atom({ plugin: "dl", key: "libPos" } as const, {});
32const libShapes = atom({ plugin: "dl", key: "libShapes" } as const, {});
33const libText = atom({ plugin: "dl", key: "libText" } as const, null);
34const libStatus = atom({ plugin: "dl", key: "libStatus" } as const, null);
35const libLast = atom({ plugin: "dl", key: "libLast" } as const, {});
36const prefsOver = atom({ plugin: "dl", key: "prefsOver" } as const, {});
37const todayOpened = atom({ plugin: "dl", key: "todayOpened" } as const, null);
38const perek = atom({ plugin: "dl", key: "perek" } as const, null);
39
40// ---- band state ---------------------------------------------------------
41let state: { key: string | null; data: Brief | null; error: string | null; loading: boolean } =
42  { key: null, data: null, error: null, loading: false };
43// The day the band was dismissed for (YYYY-MM-DD), kept in the store so new
44// sessions that day stay quiet; a new day brings the band back.
45let hiddenFor: string | null = null;
46
47async function setHidden($: Host, key: string | null) {
48  hiddenFor = key;
49  if (key) await $.store.set("hidden", key);
50  else await $.store.delete("hidden");
51  $.ui.invalidate("ui.render");
52}
53
54async function getJSON($: Host, url: string) {
55  const res = await $.http.fetch(url, {
56    headers: { "User-Agent": "daily-learning (+https://github.com/sharshi/daily-learning-plugin)", Accept: "application/json" },
57  });
58  if (!res.ok) throw new Error(`${res.status} ${url}`);
59  return JSON.parse(res.text);
60}
61
62// Fetch what the band needs for Gregorian day `d` and shape it.
63async function buildBrief($: Host, d: Date): Promise<Brief> {
64  const [heb, cal] = await Promise.all([getJSON($, hebcalUrl(d)), getJSON($, calendarUrl(d))]);
65  let monthHas30 = true;
66  if (+heb.hd === 29) {
67    const next = new Date(d); next.setDate(d.getDate() + 1);
68    try { monthHas30 = +(await getJSON($, hebcalUrl(next))).hd === 30; } catch {}
69  }
70  return briefFrom(d, hebrewDate(heb), cal, monthHas30);
71}
72
73// The band's brief for `key`: from memory, the store, or the network. The band
74// redraws when it lands.
75async function ensure($: Host, key: string) {
76  if (state.key === key && (state.data || state.loading)) return;
77  state = { key, data: null, error: null, loading: true };
78  const storeKey = `brief-${key}`;
79  try {
80    const cached = (await $.store.get(storeKey)) as Brief | undefined;
81    if (cached) {
82      state.data = cached;
83      return;
84    }
85    const data = await buildBrief($, new Date(`${key}T12:00:00`));
86    if (state.key !== key) return; // day rolled over mid-fetch
87    state.data = data;
88    // Keep only today's brief in the store.
89    for (const k of await $.store.keys()) if (k.startsWith("brief-") && k !== storeKey) await $.store.delete(k);
90    await $.store.set(storeKey, data);
91  } catch (e: any) {
92    if (state.key === key) state.error = e?.message || String(e);
93  } finally {
94    if (state.key === key) state.loading = false;
95    $.ui.invalidate("ui.render");
96  }
97}
98
99// The fetch in flight, by day. A module variable, not $.state: a reload drops
100// it with the fetch it tracked, so a "loading" left by an old module never
101// blocks the next load.
102let dayFetch: { key: string; done: Promise<void> } | null = null;
103
104function ensureDay($: Host) {
105  const key = todayKey();
106  if (dayFetch?.key !== key) dayFetch = { key, done: loadDay($, key) };
107  return dayFetch.done;
108}
109
110async function loadDay($: Host, key: string) {
111  const d0 = await read($, day);
112  if (d0?.key === key && d0.v === DAY_V) {
113    await update($, status, () => ({ key, phase: "ready" }) as Status);
114    return;
115  }
116  await update($, status, () => ({ key, phase: "loading" }) as Status);
117  const storeKey = `day-v${DAY_V}-${key}`;
118  try {
119    let d = (await $.store.get(storeKey)) as Day | undefined;
120    // Older builds kept a Day under day-<date>; the prune below removes it.
121    if (!d) {
122      const script = `${$.plugin.root}/scripts/dl.py`;
123      const r = await $.process.run(["python3", script, "--json", "--full"], { timeoutMs: 300_000 });
124      if (r.exitCode !== 0) throw new Error(r.stderr.trim().split("\n").pop() || `exit ${r.exitCode}`);
125      d = toDay(key, JSON.parse(r.stdout));
126      for (const k of await $.store.keys()) if (k.startsWith("day-") && k !== storeKey) await $.store.delete(k);
127      await $.store.set(storeKey, d);
128    }
129    await update($, day, () => d!);
130    await update($, status, () => ({ key, phase: "ready" }) as Status);
131  } catch (e: any) {
132    if (dayFetch?.key === key) dayFetch = null; // let the next open retry
133    await update($, status, () => ({ key, phase: "error", error: e?.message || String(e) }) as Status);
134  }
135}
136
137// A section Sefaria had no text for (Hayom Yom today, often) is left out of
138// the sidebar and the band instead of pointing at a page that does not load.
139const hasText = (d: Day, id: TabId) => (d.parts[id] ?? []).some((p) => p.he.length || p.en.length);
140const shownTabs = (d: Day | null) => TABS.filter(([id]) => !d || hasText(d, id));
141
142// Open the sidebar on a section of the day, or on the day's menu.
143async function openPane($: Host, t?: TabId) {
144  await update($, tab, () => t ?? null);
145  await update($, pageAtom, () => "read");
146  void ensureDay($);
147  await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
148}
149
150// Terminals with a bidi of their own, which get termVisual's order (see
151// hebrew.ts); every other terminal gets visual's.
152const RLM = "\u200F";
153const BIDI_TERMINALS = new Set(["Apple_Terminal"]);
154
155// The chosen font as WOFF, base64, for the Desktop app's SVG pages; null for
156// "System", until read, or if it can't be (the Desktop app then gets text).
157let embedded: EmbeddedFont | null = null;
158
159async function loadFont($: Host, choice: unknown) {
160  const f = FONTS[String(choice)];
161  if (!f) return;
162  try {
163    const r = await $.fs.read(`${$.plugin.root}/fonts/${f.file}.woff`, { as: "bytes" });
164    embedded = typeof r === "string" ? null : { key: f.key, base64: r.base64 };
165    $.ui.invalidate("ui.render");
166  } catch {}
167}
168
169// ---- library ----------------------------------------------------------------
170// Everything loads when first needed: a collection's structure when it is
171// opened (kept for good: it doesn't change), a unit's text when it is read
172// (the store keeps the last LIB_KEEP). One fetch at a time per thing.
173const LIB_KEEP = 12;
174const libFetching = new Map<string, Promise<void>>();
175const once = (what: string, run: () => Promise<void>) => {
176  if (!libFetching.has(what)) libFetching.set(what, run().finally(() => libFetching.delete(what)));
177  return libFetching.get(what)!;
178};
179// Text colours, as theme keys so they follow the person's light or dark theme:
180// the body in the theme's own text colour (not the terminal's default, which
181// a dark terminal under a light theme draws pale on the pane), amber titles
182// (yellow in dark, a darker amber in light), indigo Rashi, blue headings.
183const BODY = "text", TITLE = "warning", RASHI = "permission", ACCENT = "ide";
184
185const sefaria = (path: string) => `https://www.sefaria.org/api/${path}`;
186const enc = (s: string) => s.split("/").map(encodeURIComponent).join("/");
187
188function ensureShape($: Host, coll: Coll) {
189  return once(`shape:${coll}`, async () => {
190    if ((await read($, libShapes))[coll]) return;
191    await update($, libStatus, () => ({ what: COLLS[coll].name, phase: "loading" as const }));
192    try {
193      const key = `lib-shape-${coll}`;
194      let shape = (await $.store.get(key)) as ReturnType<typeof parseShape> | undefined;
195      if (!shape?.sections?.length) {
196        shape = parseShape(coll, await getJSON($, sefaria(`shape/${enc(COLLS[coll].shape)}`)));
197        await $.store.set(key, shape);
198      }
199      await update($, libShapes, (v) => ({ ...v, [coll]: shape }));
200      await update($, libStatus, () => null);
201    } catch (e: any) {
202      await update($, libStatus, () => ({ what: COLLS[coll].name, phase: "error" as const, error: e?.message || String(e) }));
203    }
204  });
205}
206
207function ensureLibText($: Host, coll: Coll, b: LibBook, unit: number) {
208  const ref = textRef(b, unit);
209  return once(`text:${ref}`, async () => {
210    if ((await read($, libText))?.ref === ref) return;
211    await update($, libStatus, () => ({ what: ref, phase: "loading" as const }));
212    try {
213      const key = `lib-text-${ref}`;
214      let part = (await $.store.get(key)) as ReturnType<typeof libPart> | undefined;
215      if (!part) {
216        const q = "?version=hebrew&version=english&return_format=text_only";
217        const [text, rashi] = await Promise.all([
218          getJSON($, sefaria(`v3/texts/${encodeURIComponent(ref)}${q}`)),
219          coll === "bavli" ? getJSON($, sefaria(`v3/texts/${encodeURIComponent(rashiRef(b, unit))}?version=hebrew&return_format=text_only`)).catch(() => undefined) : undefined,
220        ]);
221        part = libPart(coll, b, unit, text, rashi);
222        // Keep the newest LIB_KEEP texts.
223        const recent = (((await $.store.get("lib-recent")) as string[] | undefined) ?? []).filter((k) => k !== key);
224        recent.unshift(key);
225        for (const old of recent.splice(LIB_KEEP)) await $.store.delete(old);
226        await $.store.set("lib-recent", recent);
227        await $.store.set(key, part);
228      }
229      await update($, libText, () => ({ ref, part: part! }));
230      await update($, libStatus, () => null);
231    } catch (e: any) {
232      await update($, libStatus, () => ({ what: ref, phase: "error" as const, error: e?.message || String(e) }));
233    }
234  });
235}
236
237// Open a unit: move the menu there, remember it as where this collection was
238// last read, and load its text.
239async function openUnit($: Host, coll: Coll, b: LibBook, unit: number) {
240  await update($, libPos, () => ({ coll, section: b.section, book: b.title, unit }));
241  const at = await $.clock.now();
242  const last = await update($, libLast, (v) => ({ ...v, [coll]: { book: b.title, unit, at } }));
243  await $.store.set("lib-last", last);
244  void ensureLibText($, coll, b, unit);
245}
246
247// Where a collection was last read, as "Ketubot 5" (the book's short name).
248const lastLabel = (l: { book: string; unit: number }) => `${l.book.replace(/^(Mishnah|Mishneh Torah,) /, "")} ${l.unit}`;
249
250// Continue a collection where it was left: load its structure if need be,
251// open the unit, and show it in the Library.
252async function continueLib($: Host, c: Coll) {
253  const l = (await read($, libLast))[c];
254  if (!l) return;
255  await ensureShape($, c);
256  const sh = (await read($, libShapes))[c];
257  const bk = sh ? findBook(sh, l.book) : undefined;
258  if (bk) await openUnit($, c, bk, l.unit);
259}
260
261// ---- while Claude works -----------------------------------------------------
262// A turn that runs long turns the line above the prompt into a suggestion to
263// learn meanwhile: where the Library was left, or the next section of today
264// not yet opened. Gone when the turn ends; at most once every 30 minutes;
265// never on a day the line was hidden; never takes the keyboard.
266const WAIT_MS: Record<string, number> = { "After 45 seconds": 45_000, "After 2 minutes": 120_000 };
267const SUGGEST_GAP_MS = 30 * 60_000;
268let waitTimer: { cancel: () => void } | null = null;
269let suggesting = false;     // the suggestion is on the line now
270let suggestedTurn = false;  // it was shown during this turn
271
272async function startWait($: Host, setting: unknown) {
273  waitTimer?.cancel();
274  waitTimer = null;
275  const ms = WAIT_MS[String(setting ?? "After 45 seconds")];
276  if (!ms) return;
277  waitTimer = $.clock.after(ms, () => void suggest($));
278}
279
280async function suggest($: Host) {
281  waitTimer = null;
282  if (hiddenFor === todayKey()) return;
283  const now = await $.clock.now();
284  const last = (await $.store.get("suggest-last")) as number | undefined;
285  if (last != null && now - last < SUGGEST_GAP_MS) return;
286  await $.store.set("suggest-last", now);
287  suggesting = suggestedTurn = true;
288  $.ui.invalidate("ui.render");
289}
290
291function endWait($: Host) {
292  waitTimer?.cancel();
293  waitTimer = null;
294  if (suggesting) { suggesting = false; $.ui.invalidate("ui.render"); }
295  if (suggestedTurn) { suggestedTurn = false; $.ui.toast("Daily Learning: Claude is done."); }
296}
297
298// Note a section of today as opened (the suggestion offers the next one not).
299async function markOpened($: Host, id: TabId) {
300  const key = todayKey();
301  const v = await update($, todayOpened, (o) => {
302    const ids = o?.key === key ? o.ids : [];
303    return { key, ids: ids.includes(id) ? ids : [...ids, id] };
304  });
305  await $.store.set("today-opened", v);
306}
307
308// ---- settings -------------------------------------------------------------
309// The sidebar's settings are the plugin's own (userConfig): they persist, show
310// in /config, and the settings page changes them with $.config.set, which
311// reloads the mod with the new values.
312const ENGLISH: Record<string, English> = { "Off": "off", "Staggered": "staggered", "Side by side": "side" };
313const SIZES: Record<string, number> = { "Small": 0.88, "Medium": 1, "Large": 1.18 };
314
315type Prefs = { font: string; english: English; nikkud: boolean; rashi: boolean; size: string };
316function prefsOf(o: Record<string, unknown>): Prefs {
317  return {
318    font: String(o.hebrew_font ?? "Frank Ruhl"),
319    english: ENGLISH[String(o.english)] ?? "off",
320    nikkud: o.nikkud !== false,
321    rashi: o.rashi !== false,
322    size: SIZES[String(o.text_size)] ? String(o.text_size) : "Medium",
323  };
324}
325
326// A setting's /config row key. Builds name plugin rows differently
327// (`dl.nikkud`, `dl@daily-learning.nikkud`, ...), so it's looked up in the
328// list by the owning plugin and the field rather than guessed.
329async function rowKey($: Host, field: string) {
330  const rows = await $.config.list();
331  const ours = (r: (typeof rows)[number]) => /^dl(@|$)/.test(String(r.provider?.plugin ?? "")) || /^dl[@.]/.test(r.key);
332  const tail = new RegExp(`(^|[^A-Za-z0-9_])${field}$`);
333  return (rows.find((r) => r.key === `dl.${field}`)
334    ?? rows.find((r) => ours(r) && tail.test(r.key))
335    ?? rows.find((r) => tail.test(r.key) && /(^|[^A-Za-z0-9_])dl([^A-Za-z0-9_]|$)/.test(r.key)))?.key;
336}
337
338const PREF_FIELDS = ["hebrew_font", "english", "nikkud", "rashi", "text_size", "wait_suggest"];
339const optionsKey = (o: Record<string, unknown>) => JSON.stringify(PREF_FIELDS.map((f) => o[f] ?? null));
340
341// Change one setting from the sidebar. It takes effect at once and is kept in
342// the plugin's store, over the plugin's options; it is also written to
343// /config where this build lists the plugin's rows there (the Desktop app's
344// may not), which reloads the mod with it. A later change in /config wins.
345async function setPref($: Host, field: string, value: string | boolean) {
346  const over = await update($, prefsOver, (v) => ({ ...v, [field]: value }));
347  await $.store.set("prefs-over", over);
348  if (field === "hebrew_font") { void applyFont($, value); void loadFont($, value); }
349  try {
350    const key = await rowKey($, field);
351    if (key) await $.config.set({ key, value });
352  } catch {}
353}
354
355// ---- ui -----------------------------------------------------------------
356// The "Hebrew font" setting's choices: each font's key (for its letter widths),
357// its file under fonts/, and the family name inside it (Shlomo's is inherited
358// from SIL's Ezra).
359const FONTS: Record<string, { key: FontKey; file: string; family: string }> = {
360  "Shlomo": { key: "shlomo", file: "Shlomo", family: "Ezra SIL SR" },
361  "Frank Ruhl": { key: "frank", file: "FrankRuhlLibre-Regular", family: "Frank Ruhl Libre" },
362};
363
364// Install the chosen font and map Ghostty's Hebrew to it, or take the mapping
365// back out for "System". Says so only when something changed.
366async function applyFont($: Host, choice: unknown) {
367  const script = `${$.plugin.root}/scripts/install-font.sh`;
368  const f = FONTS[String(choice)];
369  const argv = f ? ["/bin/sh", script, f.file, f.family] : ["/bin/sh", script, "--remove"];
370  try {
371    const r = await $.process.run(argv, { timeoutMs: 30_000 });
372    const changed = r.stdout.split("\n").filter((l) => l.startsWith("changed:"));
373    if (r.exitCode !== 0)
374      $.ui.toast(`Daily Learning: Hebrew font setting failed: ${r.stderr.trim().split("\n").pop() || `install-font.sh exited ${r.exitCode}`}`);
375    else if (changed.length)
376      $.ui.toast(`Daily Learning: ${changed.map((l) => l.slice(9)).join("; ")}. Reload Ghostty's config (cmd+shift+,) to see it.`);
377  } catch (err: any) {
378    $.ui.toast(`Daily Learning: Hebrew font setting failed: ${err?.message || err}`);
379  }
380}
381
382export const register: Register = (on, options) => {
383  const opts = options as Record<string, unknown>;
384  on("session.start", async ($, e, next) => {
385    // Sidebar choices stand until /config changes one of the plugin's options.
386    let over = ((await $.store.get("prefs-over")) as Record<string, string | boolean> | undefined) ?? {};
387    if ((await $.store.get("prefs-seen")) !== optionsKey(opts)) {
388      over = {};
389      await $.store.set("prefs-over", over);
390      await $.store.set("prefs-seen", optionsKey(opts));
391    }
392    await update($, prefsOver, () => over);
393    const font = over.hebrew_font ?? options.hebrew_font;
394    void applyFont($, font);
395    void loadFont($, font);
396    const last = (await $.store.get("lib-last")) as Partial<Record<Coll, { book: string; unit: number; at?: number }>> | undefined;
397    if (last) await update($, libLast, () => last);
398    const opened = (await $.store.get("today-opened")) as { key: string; ids: TabId[] } | undefined;
399    if (opened?.key === todayKey()) await update($, todayOpened, () => opened);
400    hiddenFor = ((await $.store.get("hidden")) as string | undefined) ?? null;
401    await $.command.register({ name: "dl-toggle", description: "Show or hide the daily learning line above the prompt" });
402    await $.command.register({ name: "dl", description: "Open today's learning in a sidebar" });
403    void ensure($, todayKey());
404    void ensureDay($); // warm the sidebar text: from the store when cached
405    return next(e);
406  });
407
408  // A turn's start and end drive the suggestion to learn meanwhile.
409  on("prompt.submit", async ($, e, next) => {
410    const over = await read($, prefsOver);
411    void startWait($, over.wait_suggest ?? options.wait_suggest);
412    return next(e);
413  });
414  on("turn.complete", async ($, e, next) => {
415    endWait($);
416    return next(e);
417  });
418
419  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
420    const { Box, Text, Button } = $.ui.resolve(e);
421    const key = todayKey();
422    if (hiddenFor === key) return next(e);
423    const last = await read($, libLast);
424    void ensure($, key); // no-op unless the date rolled over
425
426    if (state.loading && !state.data)
427      return <Box paddingX={1}><Text dimColor>✡ dl · loading…</Text></Box>;
428    if (state.error && !state.data)
429      return <Box paddingX={1}><Text color="red">✡ dl · {state.error}</Text></Box>;
430    if (!state.data) return next(e);
431
432    // While a long turn runs: the suggestion, in place of the launcher.
433    if (suggesting) {
434      const [dd, opened] = await Promise.all([read($, day), read($, todayOpened)]);
435      const recent = COLL_ORDER.filter((c) => last?.[c]).sort((a, b) => (last![b]!.at ?? 0) - (last![a]!.at ?? 0))[0];
436      const today = dd?.key === key ? shownTabs(dd) : [];
437      const done = opened?.key === key ? opened.ids : [];
438      const nextToday = today.find(([id]) => !done.includes(id));
439      const go = async (run: () => Promise<unknown>) => {
440        suggesting = false;
441        await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
442        await run();
443        $.ui.invalidate("ui.render");
444      };
445      return (
446        <Box paddingX={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
447          <Text color={TITLE}>⏳ Claude's working. Learn while you wait:</Text>
448          {recent ? <Button key="wait-lib" plain label={`▶ ${lastLabel(last![recent]!)}`}
449            onPress={() => go(async () => { await continueLib($, recent); await update($, pageAtom, () => "library"); })} /> : null}
450          {nextToday
451            ? <Button key="wait-today" plain label={`📅 ${nextToday[1]}`}
452                onPress={() => go(async () => { void ensureDay($); await update($, tab, () => nextToday[0]); await update($, pageAtom, () => "read"); await markOpened($, nextToday[0]); })} />
453            : <Button key="wait-today" plain label="📅 Today's learning"
454                onPress={() => go(async () => { void ensureDay($); await update($, tab, () => null); await update($, pageAtom, () => "read"); })} />}
455          <Button key="wait-later" plain label="later" onPress={() => { suggesting = false; $.ui.invalidate("ui.render"); }} />
456        </Box>
457      );
458    }
459
460    // One line, a launcher: the date, then Today's learning, the Library, and
461    // a continue button for each Library collection being read; × hides the
462    // line for the rest of the day.
463    const { heb } = state.data;
464    const date = `✡ ${heb.hd} ${heb.hm} ${heb.hy}` + (heb.events.length ? ` · ${heb.events.join(", ")}` : "");
465    const show = async (to: "read" | "library") => {
466      if (to === "read") void ensureDay($);
467      await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
468      if (to === "read") await update($, tab, () => null);
469      else await update($, libPos, () => ({}));
470      await update($, pageAtom, () => to);
471    };
472    return (
473      <Box paddingX={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
474        <Text key="date" color={ACCENT}>{date}</Text>
475        <Button key="band-today" plain label="📅 Today's learning" onPress={() => show("read")} />
476        <Button key="band-library" plain label="📚 Library" onPress={() => show("library")} />
477        {COLL_ORDER.filter((c) => last?.[c]).map((c) => (
478          <Button key={`band-lib-${c}`} plain label={`▶ ${lastLabel(last![c]!)}`} onPress={async () => {
479            await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
480            await continueLib($, c);
481            await update($, pageAtom, () => "library");
482          }} />
483        ))}
484        <Button key="dismiss" plain role="dismiss" label="×" onPress={() => setHidden($, key)} />
485      </Box>
486    );
487  });
488
489  // /dl-toggle hides the line for today, or brings it back.
490  on("command.run", { command: "dl-toggle" }, async ($) => {
491    const key = todayKey();
492    const hide = hiddenFor !== key;
493    await setHidden($, hide ? key : null);
494    return { text: hide ? "Hidden for today (/dl-toggle shows it again)." : "Shown." };
495  });
496
497  // /dl opens the sidebar and fetches the text in the background.
498  on("command.run", { command: "dl" }, async ($) => {
499    await openPane($);
500    return { text: "Opened the sidebar. Keys: t Today · l Library · s Settings · number keys pick from today's menu · j/k next/previous · ↑↓ scroll · Esc back to prompt." };
501  });
502
503  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
504    const { Box, Text, Button } = $.ui.resolve(e);
505    const Svg = e.surface === "desktop" ? $.ui.resolve(e).Svg : null;
506    const [d, st, t, pk, pg, lp, shapes, lt, ls, ll, po] = await Promise.all([
507      read($, day), read($, status), read($, tab), read($, perek), read($, pageAtom),
508      read($, libPos), read($, libShapes), read($, libText), read($, libStatus), read($, libLast), read($, prefsOver),
509    ]);
510    const prefs = prefsOf({ ...opts, ...po });
511    const nk = prefs.nikkud;
512    // Load on sight: a render never writes state, so start it on a timer.
513    if ((d?.key !== todayKey() || d.v !== DAY_V) && st?.phase !== "error") $.clock.after(0, () => void ensureDay($));
514    const width = Math.max(20, e.props.bodyColumns - 1);
515    // Where the Hebrew is drawn decides its order: reversed here for a terminal
516    // with no bidi, word order only for one that reverses each word, and reading
517    // order for the Desktop app, which lays it out itself.
518    const term = e.surface === "terminal" ? await $.env.get("TERM_PROGRAM") : undefined;
519    const isBidiTerminal = e.surface === "terminal" && BIDI_TERMINALS.has(term ?? "");
520    const isFlipped = e.surface === "terminal" && !isBidiTerminal;
521    // How a line of Hebrew words is ordered for this surface. The Desktop app
522    // lays Hebrew out itself but in a left-to-right line, so each line is
523    // wrapped in right-to-left marks to keep edge punctuation on the Hebrew side.
524    const order = isFlipped ? visual : isBidiTerminal ? termVisual : (ws: string[]) => RLM + ws.join(" ") + RLM;
525
526    // A Hebrew paragraph, wrapped here and right-aligned line by line, each line
527    // ordered for the surface. `label` (a halacha's or a verse's "א.") leads the
528    // paragraph in bold, at the right end of its first line.
529    const he = (s: string, k: string, color?: string, label?: string, w = width) => {
530      const txt = cleanHe(s, nk);
531      if (!txt) return null;
532      const lines = wrap(label ? `${label} ${txt}` : txt, w);
533      return (
534        <Box key={k} flexDirection="column" alignItems="flex-end">
535          {lines.map((ws, li) =>
536            label && li === 0
537              ? <Box flexDirection="row">
538                  <Text color={color ?? BODY}>{order(ws.slice(1))} </Text>
539                  <Text bold color={color ?? BODY}>{order(ws.slice(0, 1))}</Text>
540                </Box>
541              : <Text wrap="truncate" color={color ?? BODY}>{order(ws)}</Text>)}
542        </Box>
543      );
544    };
545    const en = (s: string, k: string, color?: string, label?: string) => {
546      const txt = cleanText(s);
547      if (!txt) return null;
548      return (
549        <Box key={k} flexDirection="row">
550          {label ? <Text bold dimColor={!color} color={color ?? BODY}>{label} </Text> : null}
551          <Text dimColor={!color} color={color ?? BODY}>{txt}</Text>
552        </Box>
553      );
554    };
555    // A bold chapter heading, right-aligned like the Hebrew under it.
556    const heading = (s: string, k: string) => (
557      <Box key={k} flexDirection="column" alignItems="flex-end">
558        <Text bold color={ACCENT}>{order(s.split(" "))}</Text>
559      </Box>
560    );
561
562    // A line of the body drawn as terminal text; a pair puts the English in
563    // the left half and the Hebrew in the right.
564    const half = Math.max(10, Math.floor((width - 2) / 2));
565    const draw = (l: Line, k: string) =>
566      l.kind === "gap" ? <Text key={k}> </Text>
567        : l.kind === "heading" ? heading(l.text, k)
568        : l.kind === "he" ? he(l.text, k, l.rashi ? RASHI : undefined, l.label)
569        : l.kind === "en" ? en(l.text, k, l.rashi ? RASHI : undefined, l.label)
570        : (
571          <Box key={k} flexDirection="row" justifyContent="space-between">
572            <Box width={half} flexDirection="column">{en(l.en, `${k}e`, l.rashi ? RASHI : undefined, l.enLabel)}</Box>
573            <Box width={half} flexDirection="column">{he(l.he, `${k}h`, l.rashi ? RASHI : undefined, l.label, half)}</Box>
574          </Box>
575        );
576
577    // The body's lines in the Desktop app: one SVG with the Hebrew font in it,
578    // or text when the font isn't at hand or the page is too big for an SVG.
579    const page = (lines: Line[], k: string) => {
580      if (e.surface === "desktop" && embedded) {
581        // About 7.8 CSS pixels a column, so the page draws near its real size
582        // and its text sits with the app's own.
583        const svgs = svgPages(lines.map((l) =>
584          l.kind === "he" ? { ...l, text: cleanHe(l.text, nk) }
585            : l.kind === "heading" ? { ...l, text: cleanHe(l.text, nk) }
586            : l.kind === "en" ? { ...l, text: cleanText(l.text) }
587            : l.kind === "pair" ? { ...l, he: cleanHe(l.he, nk), en: cleanText(l.en) } : l),
588          embedded, { width: (width + 1) * 7.8, scale: SIZES[prefs.size]! });
589        if (svgs && Svg) return svgs.map((src, i) => <Svg key={`${k}svg${i}`} source={src} alt="The day's text in Hebrew" />);
590      }
591      return lines.map((l, i) => draw(l, `${k}l${i}`));
592    };
593
594    // The settings page: each setting a row of choices; a press writes it.
595    const settings = () => {
596      const row = (field: string, title: string, choices: [string, string | boolean][], current: string | boolean, hint: string) => (
597        <Box key={`set-${field}`} flexDirection="column" marginBottom={1}>
598          <Text bold color={BODY}>{title}</Text>
599          <Box flexDirection="row" flexWrap="wrap">
600            {choices.map(([label, value]) => (
601              <Button key={`set-${field}-${label.replace(/\W+/g, "-").toLowerCase()}`} label={label}
602                variant={value === current ? "primary" : "secondary"} dimColor={value !== current}
603                onPress={() => value === current ? undefined : setPref($, field, value)} />
604            ))}
605          </Box>
606          <Text dimColor>{hint}</Text>
607        </Box>
608      );
609      const onOff: [string, boolean][] = [["On", true], ["Off", false]];
610      return [
611        row("hebrew_font", "Hebrew font", [["Frank Ruhl", "Frank Ruhl"], ["Shlomo", "Shlomo"], ["System", "System"]], prefs.font,
612          "Drawn in the Desktop app; installed and mapped in Ghostty. System leaves fonts alone."),
613        row("english", "English", [["Off", "Off"], ["Staggered", "Staggered"], ["Side by side", "Side by side"]],
614          Object.keys(ENGLISH).find((k) => ENGLISH[k] === prefs.english)!, "Staggered: under each paragraph. Side by side: English left, Hebrew right."),
615        row("nikkud", "Nikkud", onOff, prefs.nikkud, "The Hebrew with or without vowels."),
616        row("rashi", "Rashi", onOff, prefs.rashi, "Under each verse of Chumash and each passage of the Daf."),
617        row("wait_suggest", "While Claude works", [["After 45 seconds", "After 45 seconds"], ["After 2 minutes", "After 2 minutes"], ["Off", "Off"]],
618          String((po.wait_suggest ?? opts.wait_suggest) || "After 45 seconds"), "When a turn runs long, suggest learning on the line above the prompt (at most every 30 minutes)."),
619        row("text_size", "Text size", [["Small", "Small"], ["Medium", "Medium"], ["Large", "Large"]], prefs.size,
620          e.surface === "terminal" ? "For the Desktop app; a terminal uses its own size." : "The sidebar's text in the Desktop app."),
621        <Button key="settings-done" dimColor label="‹ Back to the text" onPress={() => update($, pageAtom, () => "read")} />,
622      ];
623    };
624
625    // The Library: collection › section › book › unit, one level at a time,
626    // each loaded when first opened. Breadcrumbs go back up; Continue goes to
627    // where a collection was last read; next and previous cross into the
628    // next book.
629    const library = () => {
630      // Hebrew in a label goes through the surface's ordering, like the text.
631      const heb = (x: string) => order(x.split(" "));
632      const pos = lp ?? {};
633      const go = (p: typeof pos) => update($, libPos, () => p);
634      const coll = pos.coll;
635      const shape = coll ? shapes?.[coll] : undefined;
636      if (coll && !shape) $.clock.after(0, () => void ensureShape($, coll));
637      const book = shape ? findBook(shape, pos.book) : undefined;
638      if (coll && book && pos.unit != null && lt?.ref !== textRef(book, pos.unit) && ls?.phase !== "error")
639        $.clock.after(0, () => void ensureLibText($, coll, book, pos.unit!));
640
641      const crumbs = [
642        <Button key="crumb-library" plain dimColor label="📚 Library" onPress={() => go({})} />,
643        ...(coll ? [<Text key="c1" dimColor> › </Text>, <Button key="crumb-coll" plain dimColor label={COLLS[coll].name} onPress={() => go({ coll })} />] : []),
644        ...(coll && pos.section && (shape?.sections.length ?? 0) > 1
645          ? [<Text key="c2" dimColor> › </Text>, <Button key="crumb-section" plain dimColor label={pos.section} onPress={() => go({ coll, section: pos.section })} />] : []),
646        ...(book ? [<Text key="c3" dimColor> › </Text>, <Button key="crumb-book" plain dimColor label={book.name} onPress={() => go({ coll, section: pos.section, book: book.title })} />] : []),
647      ];
648      const head = <Box key="crumbs" flexDirection="row" flexWrap="wrap" marginBottom={1}>{crumbs}</Box>;
649      const loading = ls && ls.phase === "loading" ? <Text key="loading" dimColor>Loading {ls.what}…</Text> : null;
650      const failed = ls && ls.phase === "error"
651        ? <Box key="failed" flexDirection="column"><Text color="red">Could not load {ls.what}: {ls.error}</Text>
652            <Button key="retry" variant="primary" label="Try again" onPress={async () => { await update($, libStatus, () => null); }} /></Box>
653        : null;
654      // "Continue: Berakhot 5" for a collection read before.
655      const cont = (c: Coll) => {
656        const l = ll?.[c];
657        if (!l) return null;
658        const s2 = shapes?.[c], b2 = s2 ? findBook(s2, l.book) : undefined;
659        const label = b2 ? `Continue ${unitLabel(b2, l.unit)}` : `Continue ${lastLabel(l)}`;
660        return <Button key={`continue-${c}`} variant="primary" label={label} onPress={() => continueLib($, c)} />;
661      };
662
663      // Collections.
664      if (!coll) return [
665        head,
666        ...COLL_ORDER.map((c) => (
667          <Box key={`coll-${c}`} flexDirection="column" marginBottom={1}>
668            <Box flexDirection="row">
669              <Button key={`open-${c}`} dimColor label={`${COLLS[c].name} · ${heb(COLLS[c].he)}`} onPress={() => go({ coll: c })} />
670              {cont(c)}
671            </Box>
672            <Text dimColor>{COLLS[c].blurb}</Text>
673          </Box>
674        )),
675      ];
676      if (!shape) return [head, failed ?? loading ?? <Text key="wait" dimColor>Loading {COLLS[coll].name}…</Text>];
677
678      // Sections (sedarim, sefarim); skipped when there is only one.
679      const section = pos.section ?? (shape.sections.length === 1 ? shape.sections[0]!.name : undefined);
680      if (!section) return [
681        head,
682        <Box key="cont" flexDirection="row">{cont(coll)}</Box>,
683        ...shape.sections.map((sec) => (
684          <Button key={`section-${sec.name.replace(/\W+/g, "-")}`} dimColor label={`${sec.name} · ${heb(sec.he)}  (${sec.books.length})`}
685            onPress={() => go({ coll, section: sec.name })} />
686        )),
687      ];
688
689      // Books (masechtos, hilchos).
690      if (!book) {
691        const sec = shape.sections.find((x) => x.name === section);
692        return [
693          head,
694          ...(sec?.books ?? []).map((b) => (
695            <Button key={`book-${b.title.replace(/\W+/g, "-")}`}
696              label={`${b.name} · ${heb(b.he)}  (${b.last - b.first + 1} ${COLLS[coll].unit === "daf" ? "dapim" : "perakim"})`}
697              variant={ll?.[coll]?.book === b.title ? "primary" : "secondary"} dimColor={ll?.[coll]?.book !== b.title}
698              onPress={() => go({ coll, section, book: b.title })} />
699          )),
700        ];
701      }
702
703      // Units (perakim, dapim): a grid, the last one read marked.
704      if (pos.unit == null) {
705        const lastHere = ll?.[coll]?.book === book.title ? ll[coll]!.unit : undefined;
706        return [
707          head,
708          <Text key="pick" dimColor>{book.name} · {heb(book.he)}: pick a {COLLS[coll].unit}</Text>,
709          <Box key="grid" flexDirection="row" flexWrap="wrap">
710            {Array.from({ length: book.last - book.first + 1 }, (_, i) => book.first + i).map((u) => (
711              <Button key={`unit-${u}`} label={heb(unitName(coll, u).split(" ")[1]!)}
712                variant={u === lastHere ? "primary" : "secondary"} dimColor={u !== lastHere}
713                onPress={() => openUnit($, coll, book, u)} />
714            ))}
715          </Box>,
716        ];
717      }
718
719      // Reading: the unit's text, with previous and next across books.
720      const unit = pos.unit;
721      const prev = step(shape, book.title, unit, -1), next = step(shape, book.title, unit, 1);
722      const nav = (where: "top" | "end") => (
723        <Box key={`libnav-${where}`} flexDirection="row" justifyContent="space-between">
724          {prev ? <Button key={`lib-prev-${where}`} plain dimColor hotkey={where === "end" ? "k" : undefined}
725            label={`‹ ${unitLabel(prev.book, prev.unit)}`} onPress={() => openUnit($, coll, prev.book, prev.unit)} /> : <Text> </Text>}
726          <Text dimColor>{unitLabel(book, unit)}</Text>
727          {next ? <Button key={`lib-next-${where}`} plain hotkey={where === "end" ? "j" : undefined} variant="primary"
728            label={`${unitLabel(next.book, next.unit)} ›`} onPress={() => openUnit($, coll, next.book, next.unit)} /> : <Text dimColor>end ✓</Text>}
729        </Box>
730      );
731      const part = lt?.ref === textRef(book, unit) ? lt.part : null;
732      const lines = part ? (part.sections ?? []).flatMap((_, i) => linesFor(part, i, { english: prefs.english, rashi: prefs.rashi })) : [];
733      return [
734        head,
735        nav("top"),
736        ...(part ? [<Text key="ltitle" color={TITLE} bold>{part.title}</Text>, <Text key="lsp"> </Text>]
737          : [failed ?? loading ?? <Text key="lwait" dimColor>Loading {unitLabel(book, unit)}…</Text>]),
738        ...(part ? page(lines, "lib") : []),
739        nav("end"),
740      ];
741    };
742
743    const openTab = async (id: TabId | null) => {
744      await update($, tab, () => id);
745      await update($, pageAtom, () => "read");
746      if (id) await markOpened($, id);
747    };
748
749    // Today: a menu of the day's sections, each with what it is today.
750    const todayMenu = (shown: [TabId, string][]) => [
751      <Text key="today-title" bold color={BODY}>Today's learning</Text>,
752      <Text key="today-sp"> </Text>,
753      ...shown.map(([id, label], i) => (
754        <Box key={`today-${id}`} flexDirection="column" marginBottom={1}>
755          <Button key={`tab-${id}`} dimColor label={label} hotkey={String(i + 1)} onPress={() => openTab(id)} />
756          <Text dimColor wrap="truncate">  {d!.parts[id]?.[0]?.title ?? ""}</Text>
757        </Box>
758      )),
759    ];
760
761    const body = () => {
762      if (pg === "settings") return settings();
763      if (pg === "library") return library();
764      if (!d || d.key !== todayKey()) {
765        if (st?.phase === "error") return <Text color="red">Could not load: {st.error}</Text>;
766        return <Text dimColor>Loading today's text… (the first fetch takes a little while)</Text>;
767      }
768      const shown = shownTabs(d);
769      // No section chosen (or one hidden today, or kept from an older build): the menu.
770      const ti = shown.findIndex(([id]) => id === t);
771      if (ti < 0) return todayMenu(shown);
772      const tabId = shown[ti]![0];
773      const parts = d.parts[tabId] ?? [];
774      // Back to the menu, and on to the day's previous or next section.
775      const crumbs = (
776        <Box key="today-crumbs" flexDirection="row" marginBottom={1}>
777          <Button key="crumb-today" plain dimColor label="📅 Today" onPress={() => openTab(null)} />
778          <Text dimColor> › </Text><Text color={BODY}>{shown[ti]![1]}</Text>
779        </Box>
780      );
781      const prevTab = shown[ti - 1], nextTab = shown[ti + 1];
782      const dayNav = (
783        <Box key="day-nav" flexDirection="row" justifyContent="space-between" marginTop={1}>
784          {prevTab ? <Button key="day-prev" plain dimColor label={`‹ ${prevTab[1]}`} onPress={() => openTab(prevTab[0])} /> : <Text> </Text>}
785          {nextTab ? <Button key="day-next" variant="primary" label={`${nextTab[1]} ›`} onPress={() => openTab(nextTab[0])} />
786            : <Button key="day-done" variant="primary" label="Today's menu ›" onPress={() => openTab(null)} />}
787        </Box>
788      );
789      if (!parts.length) return [crumbs, <Text key="none" dimColor>Nothing listed for this today.</Text>, dayNav];
790      return [crumbs, ...parts.map((p, pi) => {
791        const sections = p.sections ?? [];
792        const n = sections.length;
793        const at = Math.min(pk?.key === d.key ? pk.at[tabId] ?? 0 : 0, Math.max(n - 1, 0));
794        const go = (i: number) => update($, perek, (v) =>
795          ({ key: d.key, at: { ...(v?.key === d.key ? v.at : {}), [tabId]: i } }));
796        // Previous and next section (perek, amud), above the text and below it.
797        const nav = (where: "top" | "end") => n > 1 ? (
798          <Box key={`${pi}nav-${where}`} flexDirection="row" justifyContent="space-between">
799            {at > 0
800              ? <Button key={`prev-${where}`} plain dimColor hotkey={where === "end" ? "k" : undefined}
801                  label={`‹ ${p.unit} ${sections[at - 1]!.short}`} onPress={() => go(at - 1)} />
802              : <Text> </Text>}
803            <Text dimColor>{p.unit} {at + 1} of {n}</Text>
804            {at < n - 1
805              ? <Button key={`next-${where}`} hotkey={where === "end" ? "j" : undefined}
806                  variant="primary" label={`next ${p.unit} ${sections[at + 1]!.short} ›`} onPress={() => go(at + 1)} />
807              : <Text dimColor>done ✓</Text>}
808          </Box>
809        ) : null;
810        const empty = !p.he.length && !p.en.length;
811        return (
812          <Box key={`p${pi}`} flexDirection="column" marginBottom={1}>
813            <Text color={TITLE} bold>{p.title}</Text>
814            {empty && <Text dimColor>No text from Sefaria for this one; read it on chabad.org:</Text>}
815            {empty && (p.chabad || p.link) && <Text dimColor>{p.chabad || p.link}</Text>}
816            <Text> </Text>
817            {nav("top")}
818            {page(linesFor(p, at, { english: prefs.english, rashi: prefs.rashi }), `p${pi}`)}
819            {nav("end")}
820          </Box>
821        );
822      }), dayNav];
823    };
824
825    const hebDate = d?.hebrew ? cleanHe(d.hebrew, nk) : "";
826    // Missing text hides its tab, so its "... text: 404" line says nothing useful.
827    const issues = (d?.errors ?? []).filter((x) => !/ text: /.test(x));
828    // The header: date, tabs, toggles. Rows are packed here (a Button draws as
829    // "[ label ]", a plain one as "k: label") so the header's height is known.
830    const pack = <T,>(items: T[], w: (x: T) => number) => {
831      const rows: T[][] = [];
832      let row: T[] = [], n = 0;
833      for (const x of items) {
834        if (row.length && n + w(x) > width + 1) { rows.push(row); row = []; n = 0; }
835        row.push(x); n += w(x);
836      }
837      if (row.length) rows.push(row);
838      return rows;
839    };
840    // Where to go: today's learning, the Library, the settings. The current one is marked.
841    const navItems: [string, string, "read" | "library" | "settings", string][] = [
842      ["today", "📅 Today", "read", "t"], ["library", "📚 Library", "library", "l"], ["settings", "⚙ Settings", "settings", "s"],
843    ];
844    const navRows = pack(navItems, ([, label]) => label.length + 4);
845    const header = [
846      <Box key="date" flexDirection="row" justifyContent="space-between">
847        <Text color={ACCENT} bold wrap="truncate">✡ {d?.title ?? "Daily Learning"}</Text>
848        {hebDate ? <Text color={ACCENT} wrap="truncate">{order(hebDate.split(" "))}</Text> : null}
849      </Box>,
850      ...navRows.map((row, r) => (
851        <Box key={`nav${r}`} flexDirection="row">
852          {row.map(([key, label, to, hotkey]) => (
853            <Button key={key} label={label} hotkey={hotkey}
854              variant={pg === to ? "primary" : "secondary"} dimColor={pg !== to}
855              onPress={() => to === "read" && pg === "read" ? openTab(null) : update($, pageAtom, () => to)} />
856          ))}
857        </Box>
858      )),
859      e.surface === "terminal" ? <Text key="rule" dimColor>{"─".repeat(width)}</Text> : <Text key="rule"> </Text>,
860    ];
861
862    // In the terminal the header stays put: it is drawn over the body at the
863    // window's current top (the pane redraws as it scrolls), on a layer of
864    // spaces that blanks the text scrolling beneath it, and a spacer of the
865    // same height keeps the body's first rows clear of it.
866    if (e.surface === "terminal") {
867      const top = e.props.scroll?.offset ?? 0;
868      const tall = header.length; // not `h`: that name is JSX's element factory
869      return (
870        <Box flexDirection="column">
871          {Array.from({ length: tall }, (_, i) => <Text key={`gap${i}`}> </Text>)}
872          {body()}
873          {issues.length ? <Text dimColor>Issues: {issues.join("; ")}</Text> : null}
874          <Box key="blank" position="absolute" top={top} left={0} flexDirection="column">
875            {Array.from({ length: tall }, (_, i) => <Text key={`blank${i}`}>{" ".repeat(width + 1)}</Text>)}
876          </Box>
877          <Box key="header" position="absolute" top={top} left={0} flexDirection="column" width={width + 1}>
878            {header}
879          </Box>
880        </Box>
881      );
882    }
883    return (
884      <Box flexDirection="column">
885        {header}
886        {body()}
887        {issues.length ? <Text dimColor>Issues: {issues.join("; ")}</Text> : null}
888      </Box>
889    );
890  });
891};
892
hooks/data.ts 226 lines
1// The day's learning, shaped for drawing. Pure: the fetching happens in
2// register.tsx, the one file the engine lets call $.
3// - the band: hebcal.com (Hebrew date) and the Sefaria calendar;
4// - the sidebar: scripts/dl.py --json --full, shaped into a Day.
5
6import type { Day, Part, Section, TabId } from "../types";
7import { gematria } from "./hebrew";
8import type { Line } from "./svg";
9
10const ALIYOT = ["Rishon", "Sheini", "Shlishi", "Revi'i", "Chamishi", "Shishi", "Shvi'i"];
11const TEHILLIM: Record<number, string> = {
12  1: "1-9", 2: "10-17", 3: "18-22", 4: "23-28", 5: "29-34", 6: "35-38", 7: "39-43",
13  8: "44-48", 9: "49-54", 10: "55-59", 11: "60-65", 12: "66-68", 13: "69-71",
14  14: "72-76", 15: "77-78", 16: "79-82", 17: "83-87", 18: "88-89", 19: "90-96",
15  20: "97-103", 21: "104-105", 22: "106-107", 23: "108-112", 24: "113-118",
16  25: "119:1-96", 26: "119:97-176", 27: "120-134", 28: "135-139", 29: "140-144",
17  30: "145-150",
18};
19const HEB_MONTH: Record<string, string> = { "Sh'vat": "Shevat", Iyyar: "Iyar", Tamuz: "Tammuz" };
20
21// Shape version of a cached Day: bump it when Day changes, and old caches refetch.
22export const DAY_V = 8;
23
24// What the band shows: the Hebrew date and one [label, ref] row per section.
25export type Brief = {
26  heb: { hd: number; hm: string; hy: number; hebrew: string; events: string[] };
27  sections: [string, string][];
28};
29
30// The local Gregorian date as YYYY-MM-DD: what caches are keyed by.
31export function todayKey(d = new Date()) {
32  const p = (n: number) => String(n).padStart(2, "0");
33  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
34}
35
36// ---- band ---------------------------------------------------------------
37export const hebcalUrl = (d: Date) =>
38  `https://www.hebcal.com/converter?cfg=json&g2h=1&gy=${d.getFullYear()}&gm=${d.getMonth() + 1}&gd=${d.getDate()}`;
39export const calendarUrl = (d: Date) =>
40  `https://www.sefaria.org/api/calendars?year=${d.getFullYear()}&month=${d.getMonth() + 1}&day=${d.getDate()}&diaspora=1`;
41
42// hebcal's converter reply as the band's Hebrew date.
43export function hebrewDate(j: any): Brief["heb"] {
44  return { hd: +j.hd, hm: j.hm, hy: +j.hy, hebrew: j.hebrew || "", events: j.events || [] };
45}
46
47// The band's rows for Gregorian day `d`, from its Hebrew date and Sefaria
48// calendar. `monthHas30` says whether this Hebrew month has a 30th day: on a
49// 29th without one, Tehillim finishes the book (140-150).
50export function briefFrom(d: Date, heb: Brief["heb"], cal: any, monthHas30 = true): Brief {
51  const items = Object.fromEntries((cal.calendar_items || []).map((i: any) => [i.title.en, i]));
52  const out: Brief = { heb, sections: [] };
53
54  const par = items["Parashat Hashavua"];
55  if (par) {
56    const wd = d.getDay(); // Sun=0 … Sat=6
57    const aliyot = par.extraDetails?.aliyot || [];
58    const ref = aliyot[wd] || par.ref;
59    out.sections.push(["Chumash", `${par.displayValue.en} · ${ALIYOT[wd]} (${ref})`]);
60  }
61
62  const teh = heb.hd === 29 && !monthHas30 ? "140-150" : TEHILLIM[heb.hd];
63  out.sections.push(["Tehillim", `${teh}  (day ${heb.hd})`]);
64
65  const tanya = items["Tanya Yomi"];
66  if (tanya) out.sections.push(["Tanya", tanya.displayValue.en]);
67
68  const r3 = items["Daily Rambam (3 Chapters)"], r1 = items["Daily Rambam"];
69  if (r3) out.sections.push(["Rambam ×3", r3.displayValue.en]);
70  if (r1) out.sections.push(["Rambam ×1", r1.displayValue.en]);
71
72  out.sections.push(["Hayom Yom", `${HEB_MONTH[heb.hm] || heb.hm} ${heb.hd}`]);
73
74  const daf = items["Daf Yomi"];
75  if (daf) out.sections.push(["Daf Yomi", daf.displayValue.en]);
76  return out;
77}
78
79// ---- sidebar ------------------------------------------------------------
80// Sefaria nests chapters as arrays of arrays; flatten to paragraphs.
81function flat(v: unknown): string[] {
82  if (v == null) return [];
83  if (Array.isArray(v)) return v.flatMap(flat);
84  return [String(v)];
85}
86
87function part(title: string, s: any, text: any): Part {
88  return { title, link: s?.link, chabad: s?.chabad, he: flat(text?.he), en: flat(text?.en) };
89}
90
91// Each verse of a Chumash reading numbered as Rambam's halachot are ("כא.",
92// "21."), with a heading where each chapter starts (פרק ד) as Rambam's perakim
93// have. From the ref's start ("Genesis 2:20-3:21") and the text's chapters,
94// which a range spanning several needs; none when the counts don't add up.
95export function verseLabels(ref: string, text: any): NonNullable<Part["labels"]> | undefined {
96  const m = /(\d+):(\d+)(-\d+:\d+)?(?:-\d+)?$/.exec(ref ?? "");
97  const verses = flat(text?.he).length;
98  if (!m || !verses) return undefined;
99  const chapters: number[] = Array.isArray(text?.he_chapters) && text.he_chapters.length
100    ? text.he_chapters.map((c: unknown[]) => c.length)
101    : m[3] ? [] : [verses];
102  if (chapters.reduce((a, b) => a + b, 0) !== verses) return undefined;
103  const out: NonNullable<Part["labels"]> = [];
104  chapters.forEach((n, ci) => {
105    const c = Number(m[1]) + ci;
106    for (let k = 0; k < n; k++) {
107      const v = (ci === 0 ? Number(m[2]) : 1) + k;
108      out.push({ he: `${gematria(v)}.`, en: `${v}.`, ...(k === 0 ? { heading: `פרק ${gematria(c)}` } : {}) });
109    }
110  });
111  return out;
112}
113
114// A Rambam reading, one perek at a time, its halachot numbered; the first
115// perek's number comes from the ref ("Mishneh Torah, Damages to Property 12-14" → 12).
116function rambamPart(title: string, r: any, chabad: string): Part {
117  const p = part(title, { ...r, chabad }, r.text);
118  const he: string[][] = r.text?.he_chapters ?? [];
119  const en: string[][] = r.text?.en_chapters ?? [];
120  const first = Number(/(\d+)(?:-\d+)?$/.exec(r.ref ?? "")?.[1] ?? 1);
121  if (he.length) {
122    p.sections = he.map((h, i): Section => ({
123      name: `פרק ${gematria(first + i)}`, short: gematria(first + i), he: h, en: en[i] ?? [],
124    }));
125    p.unit = "perek";
126    p.numbered = true;
127  }
128  return p;
129}
130
131// The daf, one amud at a time ("18a" → דף יח., "18b" → דף יח:), each passage
132// with its Rashi.
133function dafPart(d: any): Part {
134  const amudim: any[] = d.amudim ?? [];
135  const p: Part = { title: `Daf Yomi · ${d.display}`, link: d.link, he: [], en: [] };
136  if (!amudim.length) return p;
137  p.sections = amudim.map((a): Section => {
138    const m = /^(\d+)([ab])$/.exec(a.amud ?? "");
139    const short = m ? `${gematria(Number(m[1]))}${m[2] === "a" ? "." : ":"}` : String(a.amud ?? "");
140    return { name: `דף ${short}`, short, he: a.he ?? [], en: a.en ?? [], rashi: a.rashi_he ?? [] };
141  });
142  p.he = amudim.flatMap((a) => a.he ?? []);
143  p.en = amudim.flatMap((a) => a.en ?? []);
144  p.unit = "amud";
145  return p;
146}
147
148// dl.py's --json output as the Day the sidebar draws, one list of parts per tab.
149export function toDay(key: string, j: any): Day {
150  const s = j.sections || {};
151  const ch = s.chumash, rb = s.rambam || {};
152  const labels = ch && verseLabels(ch.ref, ch.text);
153  const parts: Record<TabId, Part[]> = {
154    chumash: ch ? [{
155      ...part(`${ch.parsha} · ${ch.aliyah} (${ch.ref}) with Rashi`, ch, ch.text),
156      ...(labels ? { labels } : {}),
157      ...(ch.rashi ? { rashi: { he: ch.rashi.he_verses || [], en: ch.rashi.en_verses || [] } } : {}),
158    }] : [],
159    tehillim: s.tehillim ? [part(`Tehillim ${s.tehillim.ref} (day ${s.tehillim.day_of_month})`, s.tehillim, s.tehillim.text)] : [],
160    // "Tanya, Part IV; Iggeret HaKodesh 25:1-5" → "Iggeret HaKodesh 25:1-5"
161    tanya: s.tanya ? [part(`Tanya · ${s.tanya.display} (${String(s.tanya.ref ?? "").split("; ").pop()})`, s.tanya, s.tanya.text)] : [],
162    rambam1: rb.one_perek ? [rambamPart(`Rambam ×1 · ${rb.one_perek.display}`, rb.one_perek, rb.chabad)] : [],
163    rambam3: rb.three_perakim ? [rambamPart(`Rambam ×3 · ${rb.three_perakim.display}`, rb.three_perakim, rb.chabad)] : [],
164    hayom: s.hayom_yom ? [part(s.hayom_yom.ref, s.hayom_yom, s.hayom_yom.text)] : [],
165    daf: s.daf_yomi ? [dafPart(s.daf_yomi)] : [],
166  };
167  const hd = j.hebrew || {};
168  return {
169    v: DAY_V,
170    key,
171    title: `${j.weekday} ${j.gregorian} · ${hd.hd} ${hd.hm} ${hd.hy}`,
172    hebrew: hd.hebrew || "",
173    parts,
174    errors: j.errors || [],
175  };
176}
177
178// How the body shows English and Rashi (the sidebar's settings).
179export type English = "off" | "staggered" | "side";
180export type LineOptions = { english: English; rashi: boolean };
181
182// What a part draws, line by line: for a sectioned part, section `at` under
183// its heading; otherwise verse by verse. Each paragraph is followed by its
184// English (staggered: under it; side: beside it) and its Rashi.
185export function linesFor(p: Part, at: number, opt: LineOptions): Line[] {
186  const out: Line[] = [];
187  const showEn = opt.english !== "off";
188  const para = (he: string[], en: string[], rashi: string[][] | undefined, rashiEn: string[][] | undefined,
189    numbered: boolean, labels?: Part["labels"]) => {
190    const paired = he.length === en.length;
191    // Rashi lines up with the paragraphs when it has no more entries than they
192    // do: Sefaria leaves the list short when the last ones have no Rashi.
193    const r = opt.rashi && rashi && rashi.length <= he.length ? rashi : null;
194    he.forEach((h, i) => {
195      if (labels?.[i]?.heading) out.push({ kind: "heading", text: labels[i]!.heading! });
196      const label = numbered ? `${gematria(i + 1)}.` : labels?.[i]?.he;
197      const enLabel = numbered ? `${i + 1}.` : labels?.[i]?.en;
198      if (opt.english === "side" && paired) {
199        out.push({ kind: "pair", he: h, en: en[i]!, ...(label ? { label, enLabel } : {}) });
200      } else {
201        out.push({ kind: "he", text: h, ...(label ? { label } : {}) });
202        if (showEn && paired) out.push({ kind: "en", text: en[i]!, ...(enLabel ? { label: enLabel } : {}) });
203      }
204      const rh = r?.[i] ?? [], re = showEn && r ? rashiEn?.[i] ?? [] : [];
205      if (opt.english === "side" && rh.length && rh.length === re.length) {
206        rh.forEach((c, j) => out.push({ kind: "pair", he: c, en: re[j]!, rashi: true }));
207      } else {
208        rh.forEach((c) => out.push({ kind: "he", text: c, rashi: true }));
209        re.forEach((c) => out.push({ kind: "en", text: c, rashi: true }));
210      }
211      out.push({ kind: "gap" });
212    });
213    if (showEn && !paired) en.forEach((x) => out.push({ kind: "en", text: x }, { kind: "gap" }));
214    // Rashi that did not line up with the paragraphs still shows, after them.
215    if (opt.rashi && rashi && !r) rashi.flat().forEach((c) => out.push({ kind: "he", text: c, rashi: true }));
216  };
217  if (p.sections?.length) {
218    const s = p.sections[Math.min(Math.max(at, 0), p.sections.length - 1)]!;
219    out.push({ kind: "heading", text: s.name });
220    para(s.he, s.en, s.rashi, undefined, !!p.numbered);
221  } else {
222    para(p.he, p.en, p.rashi?.he, p.rashi?.en, false, p.labels);
223  }
224  return out;
225}
226
hooks/library.ts 117 lines
1// The Library: whole works to learn through at your own pace (Mishnayos, Shas,
2// Mishneh Torah). Pure: the structure comes from Sefaria's shape API and the
3// text from its texts API, both fetched by register.tsx when first needed.
4
5import type { Coll, LibBook, LibSection, LibShape, Part, Section } from "../types";
6import { gematria } from "./hebrew";
7
8export type { Coll, LibBook, LibPos, LibSection, LibShape } from "../types";
9
10export const COLLS: Record<Coll, { name: string; he: string; shape: string; unit: "perek" | "daf"; blurb: string }> = {
11  mishnah: { name: "Mishnayos", he: "משניות", shape: "Mishnah", unit: "perek", blurb: "Six sedarim, 63 masechtos" },
12  bavli: { name: "Shas", he: "ש״ס בבלי", shape: "Bavli", unit: "daf", blurb: "Talmud Bavli, daf by daf, with Rashi" },
13  mt: { name: "Mishneh Torah", he: "משנה תורה", shape: "Halakhah/Mishneh Torah", unit: "perek", blurb: "The Rambam's 14 sefarim, perek by perek" },
14};
15export const COLL_ORDER: Coll[] = ["mishnah", "bavli", "mt"];
16
17// Section names in Hebrew: the sedarim, and the Rambam's sefarim.
18const SECTION_HE: Record<string, string> = {
19  "Seder Zeraim": "סדר זרעים", "Seder Moed": "סדר מועד", "Seder Nashim": "סדר נשים",
20  "Seder Nezikin": "סדר נזיקין", "Seder Kodashim": "סדר קדשים", "Seder Tahorot": "סדר טהרות",
21  "Introduction": "הקדמה", "Sefer Madda": "ספר המדע", "Sefer Ahavah": "ספר אהבה", "Sefer Zemanim": "ספר זמנים",
22  "Sefer Nashim": "ספר נשים", "Sefer Kedushah": "ספר קדושה", "Sefer Haflaah": "ספר הפלאה", "Sefer Zeraim": "ספר זרעים",
23  "Sefer Avodah": "ספר עבודה", "Sefer Korbanot": "ספר הקרבנות", "Sefer Taharah": "ספר טהרה", "Sefer Nezikim": "ספר נזיקים",
24  "Sefer Kinyan": "ספר קנין", "Sefer Mishpatim": "ספר משפטים", "Sefer Shoftim": "ספר שופטים",
25};
26
27// Sefaria's shape for a collection as sections of books, in Sefaria's order.
28// Bavli's `chapters` counts segments per amud from 1a, so a daf's first amud
29// is index 2(daf-1); a book's first daf is where its first text is.
30export function parseShape(coll: Coll, json: unknown): LibShape {
31  const sections: LibSection[] = [];
32  for (const x of (Array.isArray(json) ? json : []) as any[]) {
33    if (!x || typeof x.title !== "string" || x.isComplex) continue;
34    const ch = x.chapters;
35    let first = 1, last = typeof ch === "number" ? ch : Array.isArray(ch) ? ch.length : Number(x.length) || 0;
36    if (coll === "bavli" && Array.isArray(ch)) {
37      const i = ch.findIndex((n: unknown) => Number(n) > 0);
38      first = Math.floor(Math.max(i, 0) / 2) + 1;
39      last = Math.floor((ch.length - 1) / 2) + 1;
40    }
41    if (last < first) continue;
42    const name = coll === "mishnah" ? x.title.replace(/^Mishnah /, "")
43      : coll === "mt" ? x.title.replace(/^Mishneh Torah, /, "") : x.title;
44    const he = String(x.heTitle ?? x.heBook ?? "").replace(/^משנה תורה, /, "").replace(/^משנה /, "");
45    const sec = String(x.section ?? "");
46    let s = sections.find((y) => y.name === sec);
47    if (!s) sections.push(s = { name: sec, he: SECTION_HE[sec] ?? sec, books: [] });
48    s.books.push({ title: x.title, he, name, section: sec, first, last });
49  }
50  return { sections };
51}
52
53export const books = (shape: LibShape) => shape.sections.flatMap((s) => s.books);
54export const findBook = (shape: LibShape, title?: string) => books(shape).find((b) => b.title === title);
55
56// A unit's name: פרק ג, or דף ה.
57export const unitName = (coll: Coll, n: number) => `${COLLS[coll].unit === "daf" ? "דף" : "פרק"} ${gematria(n)}`;
58// The same in English, for buttons and titles: "Berakhot 5", "Sabbath 3".
59export const unitLabel = (b: LibBook, n: number) => `${b.name} ${n}`;
60
61// The next or previous unit, across books: after a book's last unit comes the
62// next book's first, and before its first, the previous book's last.
63export function step(shape: LibShape, title: string, unit: number, dir: 1 | -1): { book: LibBook; unit: number } | null {
64  const all = books(shape);
65  const i = all.findIndex((b) => b.title === title);
66  if (i < 0) return null;
67  const b = all[i]!;
68  const u = unit + dir;
69  if (u >= b.first && u <= b.last) return { book: b, unit: u };
70  const nb = all[i + dir];
71  return nb ? { book: nb, unit: dir > 0 ? nb.first : nb.last } : null;
72}
73
74// Sefaria refs for a unit, and for its Rashi (Shas only).
75export const textRef = (b: LibBook, unit: number) => `${b.title} ${unit}`;
76export const rashiRef = (b: LibBook, unit: number) => `Rashi on ${b.title} ${unit}`;
77
78// A texts API reply as he/en lists of paragraphs, by section when nested
79// (a daf's two amudim), with the ref Sefaria resolved ("Berakhot 64a").
80type Versions = { ref?: string; versions?: { language?: string; text?: unknown }[] };
81const flat = (v: unknown): string[] => v == null ? [] : Array.isArray(v) ? v.flatMap(flat) : [String(v)];
82function byLang(j: Versions) {
83  const out: { he: unknown; en: unknown } = { he: [], en: [] };
84  for (const v of j.versions ?? []) out[v.language === "he" ? "he" : "en"] = v.text ?? [];
85  return out;
86}
87const nested = (t: unknown): unknown[][] =>
88  Array.isArray(t) && t.length && t.every((x) => Array.isArray(x)) ? (t as unknown[][]) : [Array.isArray(t) ? t : []];
89
90// The Part the sidebar draws for one unit. A perek is one numbered section; a
91// daf is its amudim, each passage with its Rashi.
92export function libPart(coll: Coll, b: LibBook, unit: number, text: Versions, rashi?: Versions): Part {
93  const { he, en } = byLang(text);
94  const title = `${COLLS[coll].name} · ${unitLabel(b, unit)}`;
95  const link = `https://www.sefaria.org/${encodeURIComponent(textRef(b, unit).replace(/ /g, "_"))}`;
96  if (coll !== "bavli") {
97    const s: Section = { name: `${b.he} ${unitName(coll, unit)}`, short: gematria(unit), he: flat(he), en: flat(en) };
98    return { title, link, he: s.he, en: s.en, sections: [s], numbered: true };
99  }
100  // A daf: one section per amud.
101  // Sefaria resolves a one-amud daf to "64a"; then the text is one amud, and
102  // Rashi's lists are per passage, not per amud.
103  const single = /\d+([ab])$/.exec(text.ref ?? "")?.[1];
104  const heA = nested(he), enA = nested(en);
105  const rHe = rashi ? byLang(rashi).he : [];
106  const rA = single ? [Array.isArray(rHe) ? rHe : []] : nested(rHe);
107  const sections: Section[] = heA.map((h, i) => {
108    const side = single ?? (i === 0 ? "a" : "b");
109    const short = `${gematria(unit)}${side === "a" ? "." : ":"}`;
110    return {
111      name: `${b.he} דף ${short}`, short, he: flat(h), en: flat(enA[i]),
112      rashi: (rA[i] ?? []).map((seg) => flat(seg)),
113    };
114  }).filter((s) => s.he.length || s.en.length);
115  return { title, link, he: sections.flatMap((s) => s.he), en: sections.flatMap((s) => s.en), sections, unit: "amud" };
116}
117
hooks/hebrew.ts 108 lines
1// Hebrew text for a terminal: cleanup, mark order, wrapping, and the three ways
2// a line of Hebrew is ordered for the surface that draws it. Pure functions.
3
4// Combining marks: te'amim (cantillation) and nikkud (vowels, dagesh, shin dots).
5const MARK = /[\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7]/;
6const TEAMIM = /[\u0591-\u05AF\u05BD]/g;
7const NIKKUD = /[\u05B0-\u05BC\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7]/g;
8const HEB = /[\u05D0-\u05EA]/;
9const MIRROR: Record<string, string> = { "(": ")", ")": "(", "[": "]", "]": "[", "{": "}", "}": "{", "<": ">", ">": "<" };
10
11// Sefaria text to plain text: no HTML, entities, or {ס}/{פ} section markers.
12export function cleanText(s: string) {
13  return s
14    .replace(/<br\s*\/?>/gi, " ")
15    .replace(/<[^>]+>/g, "")
16    .replace(/&nbsp;|&thinsp;|&ensp;|&emsp;/g, " ")
17    .replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'")
18    .replace(/&[a-z]+;/g, "")
19    .replace(/\{[\u05E1\u05E4]\}/g, "")
20    .replace(/\s+/g, " ")
21    .trim();
22}
23
24// Hebrew for display: te'amim and paseq dropped, nikkud kept or dropped, marks
25// in the order fonts expect.
26export function cleanHe(s: string, withNikkud: boolean) {
27  let t = cleanText(s).replace(TEAMIM, "").replace(/\s*\u05C0\s*/g, " ");
28  if (!withNikkud) t = t.replace(NIKKUD, "");
29  return markOrder(t);
30}
31
32// Unicode's canonical order puts a letter's vowel before its dagesh (בְּ is
33// bet, sheva, dagesh), but terminals don't reorder marks the way text shapers
34// do, and fonts place a dagesh or shin dot right only when it comes first.
35// So each letter's marks go: shin/sin dot, dagesh, rafe, then the rest.
36const MARK_RANK: Record<string, number> = { "\u05C1": 0, "\u05C2": 0, "\u05BC": 1, "\u05BF": 2 };
37const rank = (m: string) => MARK_RANK[m] ?? 3;
38export function markOrder(t: string) {
39  return t.replace(/([\u05D0-\u05EA\uFB1D-\uFB4F])([\u0591-\u05C7]+)/g, (_, base: string, marks: string) =>
40    base + [...marks].map((m, i) => [m, i] as const)
41      .sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]).map(([m]) => m).join(""));
42}
43
44// A number in Hebrew letters, as halachot and chapters are counted: יא, טו, טז, קכ.
45export function gematria(n: number) {
46  const units = ["", "א", "ב", "ג", "ד", "ה", "ו", "ז", "ח", "ט"];
47  const tens = ["", "י", "כ", "ל", "מ", "נ", "ס", "ע", "פ", "צ"];
48  const hundreds = ["", "ק", "ר", "ש", "ת"];
49  const out = hundreds[Math.min(4, Math.floor(n / 100))] ?? "";
50  const rest = n % 100;
51  if (rest === 15) return out + "טו";
52  if (rest === 16) return out + "טז";
53  return out + tens[Math.floor(rest / 10)] + units[rest % 10];
54}
55
56// Base letters with their marks, so reversing keeps each vowel on its letter.
57export function clusters(w: string) {
58  const out: string[] = [];
59  for (const ch of w) {
60    if (MARK.test(ch) && out.length) out[out.length - 1] += ch;
61    else out.push(ch);
62  }
63  return out;
64}
65
66// Words into lines of at most `width` cells; marks take no cell.
67export function wrap(s: string, width: number) {
68  const lines: string[][] = [];
69  let line: string[] = [], n = 0;
70  for (const w of s.split(" ").filter(Boolean)) {
71    const c = clusters(w).length;
72    if (line.length && n + 1 + c > width) { lines.push(line); line = []; n = 0; }
73    n += (line.length ? 1 : 0) + c;
74    line.push(w);
75  }
76  if (line.length) lines.push(line);
77  return lines;
78}
79
80// Visual order for a terminal that draws everything left to right (Ghostty):
81// words right to left, letters inside a Hebrew word reversed, brackets
82// mirrored, numbers and Latin kept as is.
83export function visual(words: string[]) {
84  return words
85    .slice()
86    .reverse()
87    .map((w) => (HEB.test(w) ? clusters(w).reverse().map((c) => MIRROR[c] ?? c).join("") : w))
88    .join(" ");
89}
90
91// Visual order for a terminal that turns each word around itself (macOS
92// Terminal, which inside Claude Code gets the screen a word at a time): words
93// right to left, letters as written, and the punctuation at each end of a word
94// moved to the other end, mirrored, since the terminal leaves it where it is
95// sent. No direction controls (LRM, RLM, overrides): Terminal draws them as boxes.
96export function termVisual(words: string[]) {
97  const across = (p: string) => [...p].reverse().map((c) => MIRROR[c] ?? c).join("");
98  return words
99    .slice()
100    .reverse()
101    .map((w) => {
102      if (!HEB.test(w)) return w;
103      const m = /^([^\u05D0-\u05EA\uFB1D-\uFB4F]*)(.*?)([^\u05D0-\u05EA\uFB1D-\uFB4F\u0591-\u05C7]*)$/su.exec(w)!;
104      return across(m[3]!) + m[2] + across(m[1]!);
105    })
106    .join(" ");
107}
108
hooks/svg.ts 162 lines
1// The Desktop app's Hebrew: one SVG per reading, with the chosen font embedded,
2// so the app's own text engine shapes the nikkud and lays the lines right to
3// left. The terminal draws the same Line list as text (see register.tsx).
4
5import { ADVANCE } from "./font-metrics";
6
7// A bundled font: which (for its letter widths), and its WOFF as base64.
8export type FontKey = keyof typeof ADVANCE;
9export type EmbeddedFont = { key: FontKey; base64: string };
10
11// One line of the sidebar body, before it is drawn. A "pair" is a paragraph
12// with its English beside it (English on the left, Hebrew on the right).
13export type Line =
14  | { kind: "he"; text: string; label?: string; rashi?: boolean }
15  | { kind: "en"; text: string; label?: string; rashi?: boolean }
16  | { kind: "pair"; he: string; en: string; label?: string; enLabel?: string; rashi?: boolean }
17  | { kind: "heading"; text: string }
18  | { kind: "gap" };
19
20// How the page is drawn: its width in CSS pixels (the sidebar's, so the SVG
21// draws at about its real size) and a text-size factor (1 = medium).
22export type PageOptions = { width: number; scale: number };
23
24// An Svg element's source is capped at 131072 characters.
25export const SVG_LIMIT = 131072;
26
27const PAD = 6;
28const GUTTER = 18;
29const EN_EM = 0.5; // the system sans: about half an em a character
30
31const MARK = /[\u0591-\u05C7]/;
32const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
33
34// A Hebrew word's width in ems, by the font's own advances; marks take none.
35function ems(w: string, adv: Record<number, number>) {
36  let n = 0;
37  for (const ch of w) if (!MARK.test(ch)) n += adv[ch.codePointAt(0)!] ?? 0.55;
38  return n;
39}
40
41// Words into lines no wider than `max`, each word measured by `measure`.
42function fit(words: string[], max: number, measure: (w: string) => number, space: number) {
43  const lines: string[][] = [];
44  let line: string[] = [], n = 0;
45  for (const w of words) {
46    const c = measure(w);
47    if (line.length && n + space + c > max) { lines.push(line); line = []; n = 0; }
48    n += (line.length ? space : 0) + c;
49    line.push(w);
50  }
51  if (line.length) lines.push(line);
52  return lines;
53}
54
55const words = (text: string, label?: string) => [...(label ? [label] : []), ...text.split(" ").filter(Boolean)];
56
57// The page as SVGs: one when it fits an Svg element, else several, split
58// between paragraphs, each with the font. Null when even one paragraph alone
59// is too big (the caller then draws it as text).
60export function svgPages(lines: Line[], font: EmbeddedFont, opt: PageOptions = { width: 560, scale: 1 }): string[] | null {
61  const adv = ADVANCE[font.key];
62  const W = Math.max(280, Math.round(opt.width));
63  // Sizes chosen to sit with the app's own ~14px text: Hebrew a little larger,
64  // since a Hebrew face looks smaller than a Latin one at the same size.
65  const s = opt.scale;
66  const HE = { size: 18 * s, line: 30 * s };
67  const EN = { size: 14 * s, line: 21 * s };
68  const HEADING = { size: 19 * s, line: 32 * s };
69  const GAP = 10 * s;
70  const at = (n: number) => Math.round(n);
71
72  const heLines = (text: string, label: string | undefined, width: number) =>
73    fit(words(text, label), width / HE.size, (w) => ems(w, adv), adv[0x20] ?? 0.3);
74  const enLines = (text: string, label: string | undefined, width: number) =>
75    fit(words(text, label), width / (EN.size * EN_EM), (w) => w.length, 1);
76  // Classes: h Hebrew, e English, t heading, r Rashi, b bold.
77  const text = (cls: string, ws: string[], first: boolean, label: string | undefined, x: number, y: number) => {
78    const head = label && first ? `<tspan class="b">${xml(ws[0]!)}</tspan> ` : "";
79    const rest = xml((label && first ? ws.slice(1) : ws).join(" "));
80    return `<text class="${cls}" x="${at(x)}" y="${at(y)}">${head}${rest}</text>`;
81  };
82
83  // Each paragraph (up to a gap) becomes a block of text drawn from y = 0.
84  type Block = { body: string[]; height: number };
85  const blocks: Block[] = [];
86  let cur: Block = { body: [], height: 0 };
87  const right = W - PAD;
88  const full = W - 2 * PAD;
89  const half = (full - GUTTER) / 2;
90  for (const l of lines) {
91    let y = cur.height;
92    if (l.kind === "gap") {
93      cur.height += GAP;
94      blocks.push(cur); cur = { body: [], height: 0 };
95      continue;
96    }
97    if (l.kind === "heading") {
98      y += HEADING.line;
99      cur.body.push(`<text class="t" x="${right}" y="${at(y - HEADING.line * 0.3)}">${xml(l.text)}</text>`);
100    } else if (l.kind === "he") {
101      heLines(l.text, l.label, full).forEach((ws, i) => {
102        y += HE.line;
103        cur.body.push(text(l.rashi ? "h r" : "h", ws, i === 0, l.label, right, y - HE.line * 0.3));
104      });
105    } else if (l.kind === "en") {
106      enLines(l.text, l.label, full).forEach((ws, i) => {
107        y += EN.line;
108        cur.body.push(text(l.rashi ? "e r" : "e", ws, i === 0, l.label, PAD, y - EN.line * 0.28));
109      });
110    } else {
111      // Side by side: both columns start on the same row; the taller sets the height.
112      const y0 = y;
113      const he = heLines(l.he, l.label, half);
114      const en = enLines(l.en, l.enLabel, half);
115      he.forEach((ws, i) => cur.body.push(text(l.rashi ? "h r" : "h", ws, i === 0, l.label, right, y0 + (i + 1) * HE.line - HE.line * 0.3)));
116      en.forEach((ws, i) => cur.body.push(text(l.rashi ? "e r" : "e", ws, i === 0, l.enLabel, PAD, y0 + (i + 1) * EN.line - EN.line * 0.28)));
117      y = y0 + Math.max(he.length * HE.line, en.length * EN.line);
118    }
119    cur.height = y;
120  }
121  if (cur.body.length || cur.height) blocks.push(cur);
122
123  const style =
124    `<style>` +
125    `@font-face{font-family:DLHebrew;src:url(data:font/woff;base64,${font.base64}) format("woff")}` +
126    `.h,.t{font-family:DLHebrew,"SF Hebrew","Arial Hebrew",serif;direction:rtl}` +
127    `.h{font-size:${at(HE.size)}px;fill:#1f1f1f}.t{font-size:${at(HEADING.size)}px;font-weight:bold;fill:#2b8aa6}` +
128    `.e{font-family:-apple-system,system-ui,sans-serif;font-size:${at(EN.size)}px;fill:#6b6b6b}` +
129    `.r{fill:#4b5bd6}.b{font-weight:bold}` +
130    `@media (prefers-color-scheme:dark){.h{fill:#e8e8e8}.e{fill:#a3a3a3}.r{fill:#b1b9f9}.t{fill:#62c4e0}}` +
131    `</style>`;
132  const svg = (parts: string[], height: number) => {
133    const h = Math.ceil(height + 2 * PAD);
134    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}">${style}` +
135      `<g transform="translate(0 ${PAD})">${parts.join("")}</g></svg>`;
136  };
137
138  // Pack the blocks into as few SVGs as fit.
139  const room = SVG_LIMIT - svg([], 0).length - 64;
140  const pages: string[] = [];
141  let parts: string[] = [], height = 0, size = 0;
142  for (const b of blocks) {
143    const g = `<g transform="translate(0 ${at(height)})">${b.body.join("")}</g>`;
144    if (g.length > room) return null;
145    if (size + g.length > room && parts.length) {
146      pages.push(svg(parts, height)); parts = []; height = 0; size = 0;
147      const g2 = `<g transform="translate(0 0)">${b.body.join("")}</g>`;
148      parts.push(g2); size += g2.length; height += b.height;
149      continue;
150    }
151    parts.push(g); size += g.length; height += b.height;
152  }
153  if (parts.length) pages.push(svg(parts, height));
154  return pages;
155}
156
157// The page as one SVG, or null when it needs more than one (tests, mostly).
158export function svgPage(lines: Line[], font: EmbeddedFont, opt?: PageOptions): string | null {
159  const pages = svgPages(lines, font, opt);
160  return pages && pages.length === 1 ? pages[0]! : null;
161}
162
hooks/font-metrics.ts 262 lines
1// Generated by scripts/make-woff.py from fonts/*.ttf: each character's advance
2// width, in ems, per bundled font, for wrapping Hebrew in the Desktop app.
3// Combining marks take no width; a character missing here is taken as 0.55em.
4export const ADVANCE: Record<"shlomo" | "frank", Record<number, number>> = {
5  shlomo: {
6    0x0020: 0.342,
7    0x0021: 0.433,
8    0x0022: 0.333,
9    0x0023: 0.743,
10    0x0024: 0.563,
11    0x0025: 0.861,
12    0x0026: 0.713,
13    0x0027: 0.171,
14    0x0028: 0.537,
15    0x0029: 0.537,
16    0x002A: 0.504,
17    0x002B: 0.833,
18    0x002C: 0.211,
19    0x002D: 0.321,
20    0x002E: 0.289,
21    0x002F: 0.501,
22    0x0030: 0.554,
23    0x0031: 0.554,
24    0x0032: 0.554,
25    0x0033: 0.554,
26    0x0034: 0.554,
27    0x0035: 0.554,
28    0x0036: 0.554,
29    0x0037: 0.554,
30    0x0038: 0.554,
31    0x0039: 0.554,
32    0x003A: 0.293,
33    0x003B: 0.289,
34    0x003C: 0.833,
35    0x003D: 0.833,
36    0x003E: 0.833,
37    0x003F: 0.523,
38    0x0040: 0.936,
39    0x0041: 0.664,
40    0x0042: 0.609,
41    0x0043: 0.633,
42    0x0044: 0.698,
43    0x0045: 0.585,
44    0x0046: 0.542,
45    0x0047: 0.698,
46    0x0048: 0.743,
47    0x0049: 0.33,
48    0x004A: 0.479,
49    0x004B: 0.638,
50    0x004C: 0.526,
51    0x004D: 0.871,
52    0x004E: 0.716,
53    0x004F: 0.736,
54    0x0050: 0.565,
55    0x0051: 0.742,
56    0x0052: 0.65,
57    0x0053: 0.53,
58    0x0054: 0.599,
59    0x0055: 0.704,
60    0x0056: 0.666,
61    0x0057: 0.931,
62    0x0058: 0.633,
63    0x0059: 0.625,
64    0x005A: 0.587,
65    0x005B: 0.32,
66    0x005C: 0.501,
67    0x005D: 0.32,
68    0x005E: 0.958,
69    0x005F: 0.5,
70    0x0060: 0.35,
71    0x0061: 0.512,
72    0x0062: 0.55,
73    0x0063: 0.47,
74    0x0064: 0.562,
75    0x0065: 0.495,
76    0x0066: 0.326,
77    0x0067: 0.53,
78    0x0068: 0.572,
79    0x0069: 0.303,
80    0x006A: 0.308,
81    0x006B: 0.539,
82    0x006C: 0.297,
83    0x006D: 0.855,
84    0x006E: 0.577,
85    0x006F: 0.548,
86    0x0070: 0.561,
87    0x0071: 0.542,
88    0x0072: 0.396,
89    0x0073: 0.409,
90    0x0074: 0.34,
91    0x0075: 0.569,
92    0x0076: 0.529,
93    0x0077: 0.783,
94    0x0078: 0.513,
95    0x0079: 0.53,
96    0x007A: 0.475,
97    0x007B: 0.624,
98    0x007C: 0.5,
99    0x007D: 0.624,
100    0x007E: 0.833,
101    0x05BE: 0.465,
102    0x05C3: 0.397,
103    0x05D0: 0.596,
104    0x05D1: 0.665,
105    0x05D2: 0.362,
106    0x05D3: 0.674,
107    0x05D4: 0.674,
108    0x05D5: 0.337,
109    0x05D6: 0.345,
110    0x05D7: 0.674,
111    0x05D8: 0.662,
112    0x05D9: 0.337,
113    0x05DA: 0.674,
114    0x05DB: 0.616,
115    0x05DC: 0.656,
116    0x05DD: 0.659,
117    0x05DE: 0.649,
118    0x05DF: 0.367,
119    0x05E0: 0.35,
120    0x05E1: 0.622,
121    0x05E2: 0.592,
122    0x05E3: 0.616,
123    0x05E4: 0.616,
124    0x05E5: 0.591,
125    0x05E6: 0.641,
126    0x05E7: 0.609,
127    0x05E8: 0.674,
128    0x05E9: 0.698,
129    0x05EA: 0.658,
130    0x05F3: 0.259,
131    0x05F4: 0.462,
132  },
133  frank: {
134    0x0020: 0.239,
135    0x0021: 0.285,
136    0x0022: 0.364,
137    0x0023: 0.497,
138    0x0024: 0.527,
139    0x0025: 0.859,
140    0x0026: 0.722,
141    0x0027: 0.212,
142    0x0028: 0.333,
143    0x0029: 0.333,
144    0x002A: 0.344,
145    0x002B: 0.532,
146    0x002C: 0.264,
147    0x002D: 0.343,
148    0x002E: 0.278,
149    0x002F: 0.32,
150    0x0030: 0.563,
151    0x0031: 0.47,
152    0x0032: 0.546,
153    0x0033: 0.545,
154    0x0034: 0.535,
155    0x0035: 0.529,
156    0x0036: 0.557,
157    0x0037: 0.52,
158    0x0038: 0.533,
159    0x0039: 0.557,
160    0x003A: 0.278,
161    0x003B: 0.273,
162    0x003C: 0.59,
163    0x003D: 0.536,
164    0x003E: 0.59,
165    0x003F: 0.467,
166    0x0040: 0.74,
167    0x0041: 0.646,
168    0x0042: 0.618,
169    0x0043: 0.651,
170    0x0044: 0.725,
171    0x0045: 0.587,
172    0x0046: 0.548,
173    0x0047: 0.664,
174    0x0048: 0.759,
175    0x0049: 0.345,
176    0x004A: 0.379,
177    0x004B: 0.639,
178    0x004C: 0.576,
179    0x004D: 0.838,
180    0x004E: 0.702,
181    0x004F: 0.71,
182    0x0050: 0.57,
183    0x0051: 0.71,
184    0x0052: 0.605,
185    0x0053: 0.521,
186    0x0054: 0.576,
187    0x0055: 0.734,
188    0x0056: 0.65,
189    0x0057: 0.912,
190    0x0058: 0.658,
191    0x0059: 0.619,
192    0x005A: 0.573,
193    0x005B: 0.292,
194    0x005C: 0.328,
195    0x005D: 0.292,
196    0x005E: 0.583,
197    0x005F: 0.497,
198    0x0060: 0.385,
199    0x0061: 0.49,
200    0x0062: 0.563,
201    0x0063: 0.483,
202    0x0064: 0.549,
203    0x0065: 0.506,
204    0x0066: 0.309,
205    0x0067: 0.566,
206    0x0068: 0.579,
207    0x0069: 0.296,
208    0x006A: 0.24,
209    0x006B: 0.518,
210    0x006C: 0.281,
211    0x006D: 0.856,
212    0x006E: 0.587,
213    0x006F: 0.531,
214    0x0070: 0.563,
215    0x0071: 0.537,
216    0x0072: 0.395,
217    0x0073: 0.441,
218    0x0074: 0.303,
219    0x0075: 0.583,
220    0x0076: 0.492,
221    0x0077: 0.706,
222    0x0078: 0.495,
223    0x0079: 0.483,
224    0x007A: 0.466,
225    0x007B: 0.283,
226    0x007C: 0.26,
227    0x007D: 0.283,
228    0x007E: 0.535,
229    0x05BE: 0.322,
230    0x05C3: 0.278,
231    0x05D0: 0.53,
232    0x05D1: 0.482,
233    0x05D2: 0.34,
234    0x05D3: 0.451,
235    0x05D4: 0.53,
236    0x05D5: 0.275,
237    0x05D6: 0.254,
238    0x05D7: 0.545,
239    0x05D8: 0.532,
240    0x05D9: 0.273,
241    0x05DA: 0.483,
242    0x05DB: 0.476,
243    0x05DC: 0.486,
244    0x05DD: 0.538,
245    0x05DE: 0.537,
246    0x05DF: 0.229,
247    0x05E0: 0.334,
248    0x05E1: 0.507,
249    0x05E2: 0.475,
250    0x05E3: 0.485,
251    0x05E4: 0.505,
252    0x05E5: 0.429,
253    0x05E6: 0.477,
254    0x05E7: 0.5,
255    0x05E8: 0.477,
256    0x05E9: 0.613,
257    0x05EA: 0.566,
258    0x05F3: 0.17,
259    0x05F4: 0.322,
260  },
261};
262
types/index.d.ts 71 lines
1// State contract for the dl (Daily Learning) mod: the values the sidebar pane draws from.
2
3// The Library's collections, and their structure as Sefaria's shape API gives
4// it: sections (sedarim, sefarim) of books (masechtos, hilchos), each with its
5// first and last unit (perek, or daf).
6export type Coll = "mishnah" | "bavli" | "mt";
7export type LibBook = { title: string; he: string; name: string; section: string; first: number; last: number };
8export type LibSection = { name: string; he: string; books: LibBook[] };
9export type LibShape = { sections: LibSection[] };
10// Where the reader is: a collection, a section, a book, a unit.
11export type LibPos = { coll?: Coll; section?: string; book?: string; unit?: number };
12
13export type TabId = "chumash" | "tehillim" | "tanya" | "rambam1" | "rambam3" | "hayom" | "daf";
14
15// One block of text in a tab: a ref, its links, and parallel he/en paragraphs.
16// `rashi` holds Rashi's comments per verse, aligned with `he` (Chumash only).
17export type Part = {
18  title: string; link?: string; chabad?: string; he: string[]; en: string[];
19  rashi?: { he: string[][]; en: string[][] };
20  // A reading read one section at a time (Rambam's perakim, the daf's amudim):
21  // `unit` names them in the next/previous buttons, `numbered` leads each
22  // paragraph with its number (a halacha's א.), and `rashi` is per paragraph.
23  sections?: Section[];
24  unit?: "perek" | "amud";
25  numbered?: boolean;
26  // Each paragraph's own number, aligned with `he` (a Chumash verse's "ב."),
27  // and the heading drawn above it where a chapter starts (פרק ג).
28  labels?: { he: string; en: string; heading?: string }[];
29};
30
31// One section: `name` heads it (פרק יב, דף יח.), `short` names it in a button (יב, יח.).
32export type Section = { name: string; short: string; he: string[]; en: string[]; rashi?: string[][] };
33
34export type Day = {
35  v: number; // shape version: a cached Day of another version is fetched again
36  key: string; // Gregorian YYYY-MM-DD
37  title: string; // "Tuesday 2026-10-06 · 25 Tishrei 5787"
38  hebrew: string; // Hebrew date in Hebrew letters
39  parts: Record<TabId, Part[]>;
40  errors: string[];
41};
42
43export type Status = { key: string; phase: "loading" | "ready" | "error"; error?: string };
44
45declare module "claude-code" {
46  interface PluginState {
47    dl: {
48      day: Day | null;
49      status: Status | null;
50      tab: TabId | null; // null: the menu of the day's sections
51      // Which page the sidebar shows: the day's text, its settings, or the Library.
52      page: "read" | "settings" | "library";
53      // The Library: where the menu is, the collections loaded so far, the unit
54      // open (its ref and text), what is loading, and where each collection
55      // was last read.
56      libPos: LibPos;
57      libShapes: Partial<Record<Coll, LibShape>>;
58      libText: { ref: string; part: Part } | null;
59      libStatus: { what: string; phase: "loading" | "error"; error?: string } | null;
60      libLast: Partial<Record<Coll, { book: string; unit: number; at?: number }>>;
61      // Today's sections opened so far, for the date they belong to.
62      todayOpened: { key: string; ids: TabId[] } | null;
63      // Settings chosen on the sidebar's settings page, over the plugin's
64      // options (userConfig) until /config changes one of those.
65      prefsOver: Record<string, string | boolean>;
66      // Which section (perek, amud) each sectioned tab shows, for which day.
67      perek: { key: string; at: Partial<Record<TabId, number>> } | null;
68    };
69  }
70}
71