| Page | Entry point | Loads |
|---|---|---|
src/books/index.html |
Inline module → dashboard.js |
common.js |
src/books/reader.html |
reader.js |
common.js |
src/books/library-search.html |
library-search-page.js (self-initialising) |
common.js |
src/books/info.html |
book-info.js (self-initialising) |
common.js |
| Module | Purpose |
|---|---|
src/js/common.js |
Shared init: theme, fonts, i18n, sidebar, settings, keyboard, unified modals, toast |
src/js/book-data.js |
Book registry, tag resolution, page bootstrap |
src/js/book-info.js |
Book/author info modal and the src/books/info.html page: markdown notes renderer, tabs, in-modal search, copy, copy-link, pane export — see below |
src/js/dashboard.js |
Dashboard UI: card/table grid, search, tags, sort, modals, keyboard |
src/js/pins-history.js |
Pins & history: localStorage CRUD, modal UI, sidebar wiring |
src/js/reader.js |
Book viewer core: CSV parsing, rendering, loaders, STATE, goTo, keyboard, deep links |
src/js/radheef-merge.js |
Virtual merged radheef book (RDF-all): isMergedRadheefBook(), loadMergedRadheefBook() — see below |
src/js/reader-position.js |
Reader position: pagination strip, visible-page detector, scroll block (progress, milestones, URL sync, read-history) |
src/js/reader-search-ui.js |
In-book search UI: results, history, whole-word toggle, advanced search |
src/js/table-scroll-sync.js |
Table view top scrollbar: width sync, RTL-aware transform, arrow/wheel scrolling |
src/js/library-search-page.js |
Library search page UI: ?q=/?tags=, chip scoping, grouped results, peek previews |
src/js/export.js |
Export formats (15 formats) — initExports(ctx) receives a context object; the PDF/HTML/Word/EPUB builders are module-scope pure functions (buildPdfHTML, buildHtmlBook, buildWordHTML, exportEPUB, downloadFile) shared with book-info.js's pane export |
src/js/quran-data.js |
Quran pure data: loading, merging, decoration, structural derivation — lazy-loaded with quran-ui.js for QRN books only |
src/js/quran-ui.js |
Quran UI: dropdowns, presets, surah selector. Re‑exports quran-data.js (barrel); dynamically imported by reader.js on QRN detection |
src/js/search-utils.js |
Search engine: normalisation, parsing, matching, history |
src/js/export-xlsx.js |
XLSX writer, createXLSX() — lazy-loaded on demand |
src/js/export-epub.js |
EPUB 3 e-book writer, createEPUB() — lazy-loaded on demand |
src/js/export-zip.js |
Minimal store-only ZIP writer, zipStore() — shared by the XLSX + EPUB writers |
src/js/i18n.js |
Translations (dv/en/ar), t(), tagLabel(), progress milestones |
src/js/csv.js |
CSV parsing, serialisation, and fetch helpers |
Tiny CSV utilities (~1 KB). No DOM dependencies. Imported by book-data.js, reader.js, quran-data.js, quran-ui.js, and export.js.
| Function | Description |
|---|---|
parseCSV(text) |
Parses CSV text into a 2D array. Handles quoted fields, commas inside quotes, multiline values, and \r\n / \r / \n line endings. |
unparseCSV(rows) |
Converts a 2D array back to CSV text. Quotes fields containing commas, double‑quotes, or newlines. |
fetchCSVRows(path) |
Fetches a CSV file, parses it, and returns a 2D array with empty rows filtered out. Single pass — parseCSV already skips empty rows, so no intermediate row array is built, and the raw text is released as soon as parsing completes. |
fetchBookCSVCached(bookCode, version, path, keepEmpty, streamOpts) |
Fetches a book CSV through the on‑device IndexedDB cache (hadithmv DB, books store, keyed by bookCode). Cache hit + version match (registry content hash) → returns the stored rows with zero download/parse; mismatch or empty version → fetch + parse + refresh (write is fire‑and‑forget). Every failure degrades to a plain fetch. IndexedDB returns a structured clone per read, so callers may mutate the result safely. streamOpts ({onFirstRow, onRows, onProgress, signal}) opts into the streaming path for large responses (fetchCSVStreamed); a cache hit never streams. An aborted signal rejects the fetch — the IDB write happens only after the fetch resolves, so a cancelled download never lands in the cache. The stored record is identical in both paths (full array, header first — the write happens after the stream completes). |
createStreamParser(keepEmpty, onRow) |
Chunk‑fed CSV parser with the exact row semantics of parseCSV (trims, keepEmpty, "" escaping, \r\n/\r/\n). push(chunk) consumes text; finish() returns the full 2D array. Handles a \r held for a possible \r\n across chunks, a held trailing " that might start "", and inQuote carry for multiline quoted fields. Feed it a TextDecoder({stream:true}) so multi‑byte Thaana/Arabic split across chunks stays intact. |
fetchCSVStreamed(path, keepEmpty, opts) |
Streaming fetch + parse for large CSVs — same final result as fetchCSVRows (full 2D array, header first). Responses ≥ 256 KB (Content‑Length) with ReadableStream + TextDecoder support are consumed incrementally: onFirstRow fires when the header parses, onRows receives batches of 128 data rows, onProgress gets 0..1 from Content‑Length vs bytes read (clamped — the browser reports decompressed bytes against the compressed total). The final batch is delivered after finish() so a row the chunks never closed (trailing lone \r, or a file with no trailing newline) still reaches onRows. opts.signal aborts the download: an already‑aborted signal rejects immediately, a mid‑stream abort drops the result, and an abort after the body was fully read still discards it (callers that commit shared state should re‑check signal.aborted after the settle — a cancel inside the final synchronous parse can't interrupt it). Smaller responses, a missing Content‑Length, or no stream support fall back to the exact whole‑file behaviour — the callbacks never fire (an abort still drops the result). |
parseCSVWithHeader(text) |
Parses CSV text into an array of objects using the first row as keys. Trims both headers and values. |
fetchCSVObjects(path) |
Fetches a CSV file and parses it into objects via parseCSVWithHeader. Convenience wrapper for registry files. |
Reader-page entry point (src/books/reader.html). Reads ?book=CODE from the URL.
- No
?book=→ returns (the dashboard is initialized bydashboard.js) - Book found → calls
callback(metadata) - Book not found → shows error
import { initializePageWithMetadata } from "../js/book-data.js";
initializePageWithMetadata(async function (metadata) {
// metadata.bookCode — "AQD-qawaidulArbau"
// metadata.titleEN — "Qawaidul Arbau"
// metadata.titleAR — "القواعد الأربع"
// metadata.titleDV — "ހަތަރު ގަވާއިދު"
// metadata.csvPath — "../../data/content/AQD-qawaidulArbau.csv"
});Loads and caches 01-registry-bookTags.csv → Map<tagCode, {label: {dv,en,ar}, aliases: {dv,en,ar}, palette}> (palette is a golden‑ratio HSL slot index; the trilingual labels and alias words come straight from the file). Also injects the palette CSS. Returns the empty map on error (cached, no retry). Must resolve before extractTags() returns tags — the library search page awaits it before rendering chips.
Loads and caches 02-registry-bookAuthors.csv → Map<authorCode, {name: {dv,en,ar}, bornAH, diedAH}> (Hijri years as strings, "" when unknown). Returns the empty map on error (cached, no retry). Preloaded by initializePageWithMetadata() and initializeDashboard() alongside the tag definitions — bookAuthorLine() renders "" until it resolves.
Synchronous accessor for the loaded author map ({} until loadAuthorDefinitions() resolves). Used by bookAuthorLine() and the library page's period buckets.
Hijri years text for one author definition — "–256 AH" (died only — the bare en-dash marks the missing born year, the same dash the born–died range uses between its years, glued to the year like the range's own dash), "194–256 AH" (born + died), "" (neither). The template strings come from t("authorDied") / t("authorLife") — language-neutral, so they look identical in every UI language; the digits are the plain registry numbers.
One display line for a book registry entry's authors, in the current language — "al-Bukhari (–256 AH)" — multi-author books joined with authorListSeparator(): the Latin comma ", " in the English layout, the Arabic comma "، " in the Dhivehi/Arabic ones. "" when the book has no author (authorCode empty or unresolvable). Drives the library result cards, the dashboard cards, and the info modal's Book-tab author fact.
The same derivation as an array of {code, text} — one entry per author, each name with its Hijri years. The reader header splits these into one button per author (joined with authorListSeparator()), each button carrying its data-author code and opening that author's Author tab.
", " when the current UI language is English, "، " otherwise — the script-appropriate comma between author names.
English-only author names, no years, comma‑joined — the portable form for the EPUB dc:creator. "" when the book has no author (the EPUB writer falls back to Hadithmv).
Fetches and caches 03-registry-bookMeta.csv. Returns Array of book objects (bookCode, authorCode — optional, comma‑separated codes from 02-registry-bookAuthors.csv, titleAR, titleDV, titleEN, tags — secondary tags, comma‑separated). Returns [] on error.
Looks up a single book by code (async). Returns the metadata object or null.
Synchronous lookup — returns titleDV (or titleEN) for a book code. Requires the book registry to already be loaded (it is after page init). Returns null if the cache isn't populated or the book isn't found. Used by book-data.js itself for QRN source-book labels and by pins-history.js for modal book names.
Resolves a possibly-stale book code to a current registry code. Renames keep the base name and change the tag prefix (e.g. AKLQ-… → DFK-…), and old codes survive in stored pins/history — the pins/history modal runs every stored code through this before showing a title or building a reader.html?book=…&row=… link. Exact match wins; otherwise the registry code sharing the longest dash-segment suffix — requiring 2+ shared segments, or a unique 1-segment tail; ambiguous matches (two candidates claim the same tail) return the code unchanged. Returns the input unchanged when the registry isn't loaded or nothing matches.
Returns the data CSV path: "../../data/content/" + bookCode + ".csv".
Synchronous version lookup — returns the registry's version hash for a book ("" when the cache isn't populated or the book is missing). Used by the reader and Quran loader to validate the IndexedDB cache.
Returns a book's tags: the PRIMARY is the first registered prefix segment of the bookCode; SECONDARY tags come from the registry entry's tags column (comma‑separated codes). Pass the registry row (entry) whenever available (book-data and reader both have it in scope). Returns Array<{code, label: {dv,en,ar}, aliases: {dv,en,ar}, palette}> (palette is an integer index used with .tag-palette-N CSS classes).
extractTags("HDT-muwattaMalik", { tags: "DRFT" });
// [{code:"HDT", label:{dv:"ޙަދީޘް", en:"Hadith", ar:"حديث"}, aliases:{...}, palette: 0},
// {code:"DRFT", label:{...}, aliases:{...}, palette: 1}]All searchable words a book's tags contribute — every tag's labels plus alias lists, all three languages, space-joined. This is the tag row's text that search matches against the code: a query word hitting an alias or label finds every book carrying that tag's code. Wired into the dashboard search haystacks and the scope-modal filter; empty aliases contribute nothing.
Aliases are word-level only. Script-level equivalence — hamza/tashkeel forms, Thaana thikijehi, the guarded definite-article strip — comes from normaliseForSearch and must not be duplicated in alias cells (an alias that normalises to the label's own normalised form adds nothing; see 01-registry-bookTags.csv in ARCHITECTURE.md). Since the filter boxes are always‑fuzzy (scoreFilterTokens — length‑scaled: ≤ 2 edits for 6+ char terms, ≤ 1 for 4–5, exact below), an alias within its term's scaled tolerance of its own label is dead weight too — the label already catches it.
Dashboard state and rendering moved to dashboard.js when the module was split out of book-data.js — see below.
DRFT-prefix → Draft badge (⚠️ ), visible on dashboard-HDNsuffix → hidden from dashboard- Run
data/04-update-bookRegistry.ps1to auto‑generatetitleENfrombookCode, rename* - Sheet1.csvfiles (replacing existing targets), register new books, and sort the book registry bybookCode(the tag and author registries are never rewritten)
The three-tab info modal: click the reader's book title, its Arabic subtitle (Dhivevi layout) or Alt+I → Book tab; click the reader's author line → Author tab; the Authors browse modal's per-row ℹ button (the grid's leading column) opens it stacked on top. A third Works tab (the author's other works — hidden when the modal has no author) sits alongside. Imports only i18n / book-data / search-utils / csv / export — never facet-browse.js or reader.js (cycle prevention). Re-opening while open re-renders in place (never double-pushes the modal stack).
The same shell renders as a standalone page, src/books/info.html — the deep-link target behind the modal's exports and copy-link button. openInfoPage(cfg) (see below) wires a #infoPageShell container instead of an overlay; the page keeps the full actions row — copy, copy-link and the four exports run here exactly as they do in the modal (the copy-link button copies the page's own deep-link URL) — and the page's inline styles only let the pane flow with the body. Because the page renders only registry data + notes, a book's info never fetches its content CSV — the reader's rows/chapters counts (which need the CSV) are not part of the page. The modal's look comes from the base .modal rule (RTL shell + the Hadithmv webfont); the page has no .modal wrapper, so it carries direction: rtl + font-family: var(--font-mixed, …) itself, and loads reader-search.css for the shared search-input wrap (its #readerPanelSearch rules are id-scoped and never leak). Tab switches pushState (?book= / ?author= / &tab=works — each switch a history entry), so the URL always names the pane and back/forward step through the tabs; the module's popstate listener (active only in page mode) re-resolves the location via openInfoPageFromLocation(). The modal never touches the host page's URL.
Opens (or re-renders) the modal. cfg:
| Field | Meaning |
|---|---|
bookCode |
Book for the Book tab (registry facts, notes) |
author |
Author code for the Author tab (bio, fact strip) and the Works tab (his works — the tab is hidden without one) |
tab |
"book" | "author" | "works" — explicit tab wins over the fallback (author → Author, else Book); "works" requires author |
counts |
{rows, chapters} computed by the caller (the reader computes them at load; the facet pages pass what they have) |
Opens stacked (window.openModalOnTop) when any modal is already open, else window.openModal — Escape and the shared Tab-trap/backdrop/✕ behaviour come from the common.js modal layer. createModal("infoOverlay", "infoModalTitle", "infoModalBody", "info-modal") — the extra class lands on .modal, the .open state on #infoOverlay.
Renders the same panes into the info page (#infoPageShell — src/books/info.html), with no overlay: cfg is identical to openInfoModal's ({bookCode, author, tab}), and the page resolves it from its query string (?book=CODE → Book, ?author=CODE → Author, &tab=works → Works). Unknown book codes render the quiet "No notes yet" placeholder; a bare visit (or back to one) shows the empty-state line (infoPageEmpty) with the shell tucked away. This is the URL the exports print and the copy-link button copies. openInfoPageFromLocation() re-resolves window.location.search on load and on popstate (page mode only) — tab switches pushState new query strings, so back/forward step through the panes and a refresh keeps the active one.
The active pane's URL, "" when the pane has no export metadata: INFO_PAGE_HREF + "?book=" + bookCode on the Book tab, "?author=" + authorCode on the Author tab, plus "&tab=works" on the Works tab. INFO_PAGE_HREF is derived from import.meta.url, so it is correct from every host page (reader, library-search, dashboard) and under file:// and https:// alike.
Renders the notes subset of markdown. Returns {html, headings, plainText}:
- Block level:
#→<h2 id="info-hN">,##→<h3 id="info-hN">,--led lines →<ul><li>, blank-line-separated paragraphs. Every block carriesdir="auto". - Inline pass runs on the escaped text (escapeHTML first — the notes files are the user's own content, raw-by-design like the CSVs):
**b**→<strong>,*i*→<em>,[label](url)→ external link (target="_blank" rel="noopener"),[[book:CODE]]→reader.html?book=CODEtitled viagetBookTitleSync(CODE) || CODE. Everything else renders literally. headings= the rendered h2/h3 texts with theirinfo-hNids; the caller builds a TOC whenheadings.length >= 2(click = scrollIntoView).plainText= the copy/export path: strips#/##and hyphen-list prefixes, keeps inline markers literal, skips blank lines.
Chapters = runs of the first column whose lowercased header starts with kitab (else bab), falling back to rows.length when neither matches (e.g. Muwatta's basmalah-only columns). Purely derived at load — never stored.
- Search re-targets the active tab:
normaliseForSearch-case-insensitive matching,<mark>highlighting (highlightMatches), count = the mark count (one counting path — cannot drift), ▲/▼ triangle buttons (previous match = up, next = down; the pair sits 2px apart inside the row's 10px gap via.info-search-nav + .info-search-nav'smargin-inline-start: -8px, direction-aware in the RTL shell) plus Enter/Shift+Enter cycle the matches (scrollIntoView({block:"center"})), no-match shows the mutedinfoNoMatchline, and a clear ✕ (the search window's shared.search-clear-btn—.visibletoggled on the query) clears the field and re-runs the search. The query survives tab switches; a stale async render is dropped by a render-sequence guard (_renderSeq). - 📋 copies the active pane's plain text via
window.copyToClipboard(_plain.join("\n"), "toastCopied")— blank lines are structural: the tab builders push""entries at block boundaries (head → facts → tags → notes) andrenderMarkdownkeeps blank source lines as""(paragraph gaps), so the gaps sit exactly where the rendered sections have them. - 🔗 Copy link (the button after 📋) copies
infoLink()— the same URL the exports print — raw, unescaped: the clipboard is not HTML, so a Works-tab link keeps its literal&tab=works. Disabled whenever the pane has no export metadata. - The format menu exports Word / PDF / HTML Book / EPUB only of the active tab, reusing export.js's shared builders: pane sections (
sections.map(s => [s.title, s.body])) under a synthetic["headInfo", "bodyInfo"]header row withhasRowNums: false— the existing headinfo/bodyinfo heuristics style the export, EPUB gets one chapter per section. Every export opens with a title page: the kind line (infoExportKindAuthor/Book/Works— "Biography of the author" …), a hairline, the name pair (h1 + Arabic subtitle), a hairline,Hadithmv - v6.9.85+ the site URL as a live link (an<a href>— the readers' title pages and the EPUB cover anchor print it too; href and text share one escaped string, so a&tab=worksquery survives as&in the exported HTML bytes), then — info exports only — a hairline and the fact strip (carried out-of-band viaexportExtra.facts— the strip is not a section; the tags fact is deliberately excluded from the exported facts, the pane and copy keep it). The page-break is a real-character<p class="page-break"> </p>withpage-break-before:always— Word drops an empty-div break, so the break must carry content. A Contents page follows when the pane has 2+ markdown headings (exportExtra.toc— the same rule as the pane's auto-TOC). The reader's exports share the same title-page/TOC shape (no kind/facts; entries derived from head/kitab/bab rows). Busy state disables the whole actions group (.info-actions button— the tab band's buttons, one container at any width) during async exports (font fetch + dynamic import) and swaps the clicked button's label toexportPreparing("Preparing…"), restored when the export lands. - Filenames are kind-first, no language tag, no version stamp:
book-info - <titleEN>.doc,author-bio - <authorEN>.doc,author-works - <authorEN>.doc(per pane;.doc/.pdf/.html/.epubper format) — the vocabulary matches the notes directory (static/notes/works/), not the modal's tabs. - The buttons live inline in the tab band (
.info-tab-band: tabs inline-start, actions inline-end — copy + copy-link + hairline + the four formats; the band wraps when tight, dropping the actions to their own line). ≤600px the actions collapse behind the reader's 📥 export chip (#infoActionsToggle— thebtnExportTextlabel,aria-expanded/aria-controlson#infoActions) and open as an anchored dropdown menu over the search row (absolute under the band, card look — the reader's export-menu pattern; Copy and Copy link lead the menu, a hairline separates them from the four formats). The menu is transient — an outside click, picking an item, or a tab switch closes it, exactly like the reader's own export menu.
One file per book/author; the filename is the index — no registry, no version churn. static/notes/works/{bookCode}.md (book notes) and static/notes/authors/{authorCode}.md (author bio). Fetched lazily on open (cached per path); 404/network failure → quiet "No notes yet" placeholder, never an error. One bio per author, language-invariant — the same file shows in all three site languages (per-paragraph dir="auto" handles mixed bidi). Write notes in the subset above; anything else renders literally.
- Syntax-check ES modules with the stdin-pipe form:
cat src/js/book-info.js | node --check --input-type=module -(recent Node rejects the file-path form). - The EPUB export embeds a timestamp inside its deflate-compressed container, so byte-goldens cover Word/PDF/HTML only — the battery's EPUB assertions are structural (PK header, stored
mimetype, embedded font). Re-runtools/hmv-golden-capture.mjsdeliberately (and commit the new goldens) after any change that alters export output — a version bump, an export-header edit, a builder refactor.
Dashboard page UI (src/books/index.html) — built on the metadata layer in book-data.js. Split out of book-data.js so the metadata module keeps no dashboard UI.
| Function | Description |
|---|---|
initializeDashboard() |
Page entry point. ?book= links redirect to the reader; otherwise preloads tag definitions, applies ?tags= deep-link filters (plus ?authors= / ?period= via the shared facet module), loads the registry, then renders. On registry fetch failure, shows the error with a ↺ Retry button (re-runs the load; controls are wired only after a successful load, so no duplicate listeners). |
renderDashboard(bookNames) |
Renders the card grid or table view, tag chips with counts, active facet chips, result count, and the continue-reading card. |
setupDashboardControls() |
Wires search, tag chips, sort, view toggle, pins/history modals, the Authors/Periods browse buttons, the library-search jump, scroll arrows, and keyboard shortcuts. |
Module state: _dashFilter — { search, tags[], sort, pinsOnly } — current filter state; _dashTableMode — boolean — card grid vs table view. Author/period facets live in facet-browse.js (shared with the library page and search window): the functions panel's ✍️ Authors / 🗓️ Periods buttons open the shared browse modals, active selections render as accent‑tinted chips in the tags row and filter the grid; ?authors=…&period=… deep links and ?tags= are kept in the address bar by syncDashURL (the reset button clears everything via clearFacets()). The search box is always‑fuzzy, exact‑ranked (scoreFilterTokens — see below): titles and tag words may match within a length‑scaled edit distance (4–5 chars → 1 edit, 6+ → 2, shorter exact‑only), the book code is exact‑only; a search re‑sorts the grid by match score first, then the chosen order. Re-renders on dashboardReset and languagechange (when visible).
Pins & history: localStorage CRUD + modal UI + sidebar wiring. Extracted from book-data.js. Imported by book-data.js (re‑exports addPin, removePin, isPinned, addReadHistory for reader.js). Stored book codes can predate a rename (tag-prefix change), so the modal resolves every entry via resolveBookCode() — both the displayed title (bookDisplayName) and the jump links use the resolved code.
| Function | Description |
|---|---|
getPinnedBooks() / getReadHistory() |
Returns the full pins/history arrays from localStorage. |
addPin(bookCode, row, label?) |
Adds or updates a pin. One entry per book — calling it for an already‑pinned book updates the existing entry's row/label rather than adding a second (an update keeps the pin's position in the list). In practice this path is exercised by the reader's position auto‑update while reading; the reader's 📌 button itself TOGGLES (calls removePin when already pinned). At the cap (10) the oldest pin is evicted to make room (mirroring read history) and its display name is returned — the caller shows a replacement toast; returns null when nothing was evicted. Optional label stores a human‑readable position (e.g. "البَقَرَة 5:2"). row is a 1‑based whole‑book data position — the same ?row= contract as deep links; callers writing from filtered views (surah/juz) must map the row back to the full book first. New pins are prepended — newest first, the same ordering as read history. |
removePin(bookCode) |
Removes a pin by book code. |
isPinned(bookCode) |
Returns true if the book is currently pinned. |
addReadHistory(bookCode, row, label?) |
Prepends an entry to reading history (max 10 — the oldest is dropped when full). Same row convention as addPin. |
clearPins() / clearReadHistory() |
Clears all pins or history. |
openPinsModal() |
Opens the pins modal overlay (the shared full-size geometry and flush nav-btn-bg thead-bar styling of the other modals) with reorder/remove/click-to-jump. Also on window for legacy callers. |
openHistoryModal() |
Opens the history modal (same shared styling) with timestamps and clear-all. Also on window for legacy callers. |
Pure logic. No DOM dependencies. Imported by book-data.js, reader.js, quran-data.js, export-xlsx.js, export-epub.js, and export.js.
HTML‑entity escaping. escapeHTML escapes &, <, >. escapeXML also escapes " and ' (needed by export-xlsx.js and export-epub.js for XML output).
Turns https:// URLs in already‑escaped HTML into <a class="reader-link" target="_blank" rel="noopener noreferrer" dir="auto"> links. Runs after highlighting, so <mark>/tashkeel spans and attributes are left intact (matches are skipped when inside a tag); trailing Latin/Arabic punctuation stays outside the link. Used by the reader's card/parallel/table renderers and the in‑book search results. & in URLs is safe — it arrives as &, which browsers decode back in the href.
Normalises text for comparison:
- Strips Arabic tashkeel and tatweel
- Normalises alif variants (
أ إ آ ٱ→ا— incl. alif‑wasla), ya (ى→ي), waw‑hamza (ؤ→و) - Strips apostrophes (straight
'and curly’‘) — EN transliterations match:Qur'an≡Quran(the engine tokeniser would otherwise split on them into garbage tokens). Hyphens/underscores are not stripped here: the dashboard strips them and the engine splits on them, both sides consistently - Normalises Thaana thikijehi (
ޘ→ސ,ޙ→ހ, etc.) - Strips the Arabic definite article at word start — guarded: refused before another ل (
الله,اللهم,اللائيkeep the whole word) and when fewer than 2 letters would remain (أَلْف"thousand", the mysterious-letterالر). Word-internal ال (بال,وال) is untouched - Two passes over the string (mark/hamza map, then the ال-strip) — still the hottest function in the app, so both are single regex scans
Used by dashboard search, book search, the scope-modal filter, the library engine, the search-index build, and regex query patterns (parseQuery normalises /…/ patterns the same way — regexes test the normalised text, so the pattern must match the same normalised form).
Scores one book against the list‑filter boxes (dashboard search box, library scope‑modal filter) — always‑fuzzy, exact‑ranked. Each token scores 0 on an exact (substring) hit in any text field or the code, 1–2 when it lands within a length‑scaled Levenshtein distance of a text field (titles, tag words) — 4–5 char tokens tolerate 1 edit, 6+ tolerate 2, shorter tokens are exact‑only. Returns the sum of per‑token scores, or -1 when any token matches nothing. Codes are exact‑only — they are machine names, and a 2‑edit match on a code is a different book; the fuzzy pass never sees codeText. Callers drop -1 books and sort by score (exact hits first, near‑misses below, then the caller's own order). Text fields and tokens must already be normaliseForSearch'd (the dashboard strips [\s-] on both sides, consistent with its exact matching). The cross‑book index (searchLibrary) is deliberately untouched — it remains whole‑word exact.
Comma-grouped thousands for display only (regex on plain-digit input, passthrough otherwise): search-result #N labels, the search window's count header, and the reader's scroll counter (152,612 / 4). Never used for any numeric computation.
Parses a query string into structured tokens.
parseQuery("الله -رسول .سلام col:2:بسم");
// {
// include: [
// {term:"الله", wholeWord:false, fuzzy:false, col:null},
// {term:"سلام", wholeWord:true, fuzzy:false, col:null},
// {term:"بسم", wholeWord:false, fuzzy:false, col:2}
// ],
// exclude: [
// {term:"رسول", wholeWord:false, fuzzy:false, col:null}
// ]
// }Syntax reference:
| Syntax | Meaning |
|---|---|
word |
Normal substring match |
.word |
Whole‑word match |
-word |
Exclude |
~word~ |
Fuzzy — tolerance scales with the word's length: 4–5 chars → 1 edit, 6+ → 2 edits. Shorter terms are exact (a 2‑edit budget is longer than a 1–3 char word itself); wildcards still cover them. |
* / ? |
Wildcard (any / one char) |
col:N:word |
Scope to column N |
/pattern/flags |
Explicit regex — the pattern is normalised like any term (regexes test the normalised text) |
Checks if a data row (array of cell values) matches a parsed query. Include terms use AND logic; exclude terms filter out matches. Accepts either a raw parseQuery result or a compiled one (compileQuery).
Compiles a parsed query once — normalises each term and precompiles its regex — so a full‑dataset scan never re‑normalises terms or rebuilds RegExps per cell. Returns the same shape as parseQuery plus compiled: true. Feed it to rowMatchesQuery / rowMatchesQueryNorm / buildSnippets.
Norm‑aware variant of rowMatchesQuery: matches against the precomputed normalised cells from buildNormData() and a compiled query. Pass normRow = null to fall back to on‑the‑fly normalisation (identical behaviour to rowMatchesQuery).
Precomputes a parallel structure of normalised cells for every row (null/undefined cells stay null). Built once at book load in reader.js and reused by every search keystroke — this is what keeps full‑scan searches fast on big books. The Quran on‑demand column loader keeps it in sync via the quran-ui.js ctx bridge.
Tests a single term against a text string. Handles wildcards, whole‑word boundaries (Unicode‑aware via \p{L}), and fuzzy matching.
Wraps occurrences of the query in <mark> tags. Uses normalised matching to handle tashkeel/thikijehi — positions are mapped back to the original text.
Finds matching cells in a row, then builds highlighted snippets (~300 chars around each match). Returns Array<string>. parsed may be a raw or compiled query; normRow is the optional precomputed normalised cell row (from buildNormData) to skip re‑normalisation.
| Function | Description |
|---|---|
getSearchHistory() |
Returns array of recent queries |
addSearchHistory(query) |
Debounced (800ms) — adds only completed searches |
removeSearchHistoryItem(index) |
Removes one entry |
clearSearchHistory() |
Clears all history |
MAX_HISTORY |
Max entries (20) |
Saved to localStorage under searchHistory — one shared store for the
reader's search window and the library-search page, so a term searched in
one surface shows up in the other's recent searches.
Cross-book search: loads the machine-generated word index — a small manifest (data/search-index-manifest.json) plus one shard per indexed book (data/search-index/<bookCode>.json) — and answers "which books contain all of these words?". Pure module — no DOM. Used by the library search page (library-search-page.js) and by the index build script (data/08-rebuild-searchIndex.mjs imports tokenizeText so build and query agree on what a word is). Loading is two-stage and scope-aware — loadIndexMeta() (the manifest alone; the scope picker's whole dependency) and loadScopedIndex(scopeBookCodes) (the manifest + only the shards for the books in scope).
Returns Promise<meta> — the manifest's {version, built, bookIds, books, excluded, rows, words, shards}. Fetched with a conditional request (cache: "no-cache" → a cheap 304 when unchanged; ~2 KB to parse — a full JSON.parse, no head-parsing tricks). Memoized; cleared on failure so retries work. Offline fallback: if the fetch throws (network down — the SW never caches the index, so a failed fetch is real), the stored on-device record's meta is served as-is — it was validated against meta.version when stored; a pre-shard record (no shards field) can't resolve shard versions, so it is stale and the original throw stands. The next successful load re-stores a complete record.
Returns Promise<{meta, words}> in the shape searchLibrary consumes. Loads the manifest, then the shards for exactly the books in scope — null / absent / [] = every indexed book; unknown codes are dropped, so a garbage deep link never 404-fetches a nonexistent shard. Each shard fetches as data/search-index/<code>.json?v=<shardHash> — the URL changes exactly when the shard changes, so the HTTP cache can never serve stale postings while the manifest flips — is memoized per code:version (cleared on failure), and is written fire-and-forget to the on-device IndexedDB copy (hadithmvSearch DB — separate from the book cache in csv.js, so the two modules never contend on a version bump); already-loaded shards are reused across calls as scopes widen. Shards merge into a module-scope master dict — the same shape the pre-shard single file had, so the query engine is untouched. Any needed shard failing to load rejects the whole call — result counts stay truthful — and the page's error + Retry path re-attempts just the missing pieces. Offline rule: search works offline when the manifest + all needed shards are in IndexedDB; a missing needed shard + fetch failure → error + Retry, never partial results.
Thin alias of loadScopedIndex(null) — the whole index — kept for callers without a scope.
Pure query against a parsed index. Normalises + tokenises the query (whole words only, AND across words at row level), intersects with scopeBookCodes (omit for every book in the index), and returns per-book results Array<{bookCode, count, firstRow}> sorted by match count descending — [] when the query has no searchable terms or nothing matched. count is the number of matching rows; firstRow is the first one, as a 1-based data position (the reader's ?row= contract — the index stores positions, not CSV # values, which are not always sequential) for deep links. Word lookup is exact — رحم does not find الرحمن (substring matching stays in-book).
Splits normalised text into words — \p{L}\p{M}\p{N} runs, so Thaana fili (combining marks) stay part of the word; pure-number tokens are dropped. Shared with the index build script: the query side and the build side MUST agree on what a word is.
The src/books/library-search.html page module — self-initialising (runs init() on load), exports nothing.
- URL params —
?q=TERMprefills and immediately runs the search;?tags=A,B,?authors=A,B(OR — any author of the book) and?period=N|modern(death-century bucket) activate chips. Typing, chip toggles, and clear keep the address bar in sync viahistory.replaceState— the URL stays shareable.?tags=also scopes the picker to the tag groups — chips and the picker share one scope (see the default-scope bullet below). - Flow — reads params → awaits
loadTagDefinitions()+loadAuthorDefinitions()+loadBookNames()(book-data.js) → renders tag chips (counts over visible books,-HDNexcluded) → searches when_qis set, otherwise shows the type-hint. - Authors & Periods browse — the state, chips and browse modals live in the shared
facet-browse.jsmodule (the same surface the dashboard's functions panel and the search window's All-books tab use). The two buttons in the search panel open the modals: an authors list (a leading 1-based index — the row's position in the shown list, renumbered when the filter narrows it — then trilingual names, Arabic always shown, Hijri years and their Gregorian (miladi) equivalent, book counts, registry row order, a filter input above a sticky-header table) and a period table (death-century buckets + the singlemodernera — 15th century AH (1401) and later, or no death year — chronological,modernlast, its row name carrying the open-ended "(+15)" marker and its range cells at the open-ended "from" forms — "+1401 AH" and the CE of 1401, "+1981 CE"). Selection feedscomputeScope()alongside tags and renders as accent‑tinted chips in the tags row; the page subscribes viaonFacetChangeto re-sync URL, chips and results. - HDT default scope — fresh visits (no
?books=/?tags=state) default the picker to the HDT group viainitScopePicker({ defaultTag: "HDT", … }):library-scope-picker.jsapplies it insideensureSearchableBooks()unless the user or a share link already set the scope (_scopeExplicit), and the button reads "Search in: N books ▾". The All chip, the reset button, or any modal interaction widens back to every book (and marks the scope explicit).syncURL()omits?books=while the selection equals the default (defaultScopeCodes()) or exactly the active chips' groups — a share link round-trips to the same state. Chip clicks replace the picker selection (groupBookCodes(tag)). The reader's search window inits the picker with the same default and no callback (initSearchWindow's reader branch) — its All-books tab also starts scoped to the HDT group; the window's summary (sharedscopeSummaryText()) shows it, andsearchAllBooksfetches only the group's shards. The default's apply notifies like any scope change (page callback +libScopeChange), so every surface's label catches up; the URL-restore sites (?books=inreadURLParams, the?tags=block afterensureSearchableBooks) dispatch the same event so the window's summary reflects a share-link scope too. When the selection is exactly one full tag group, the scope modal leads with it — the group's chip first in the rail, its books first in the list (unions and single books keep the registry's palette order); the page's chips row mirrors the scope the same way, so the group's chip shows active and the All chip turns off (no more "All tags" next to "16 books"). A chip lights only when the scope IS its group exactly (scopesEqual) — merely covering it isn't enough, so a subset tag (Incomplete, say, whose 8 books all sit inside the HDT default) stays off: the rail and the row show exactly the one group that was chosen. - Empty-scope guard — active tags/authors/period matching no books render "No results" instead of passing
[]tosearchLibrary(which would mean "every book"). - Keyboard —
/orCtrl+Ffocuses the input,Escapein the input clears it,Alt+Ztoggles focus mode (collapses chips + count).Ctrl+,settings /Ctrl+Bback are handled by common.js. - Peek previews — per-book expandable snippets (8 per batch, "Show next N" pager), cached per book+query in module scope (two-level
_peekCache[bookCode][q]— cache write fixes the old book-data.js bug wherekeywas undefined and nothing was ever stored), deep-linkingreader.html?book=X&row=N&q=….
Returns the translated string for key in the current language.
Sets language ("dv", "en", "ar"). Persists to localStorage. Dispatches languagechange event.
Returns the current language code.
Returns the translated label for a tag code. fallback is the tag's loaded definition label ({dv,en,ar} from 01-registry-bookTags.csv); a plain string is accepted too. Resolution order: the requested language, then English, then the fallback, then the code itself.
Processes all data-i18n attributes in the DOM and sets initial language from localStorage.
Shows a brief toast message at the bottom of the screen. Single shared implementation in common.js — used by reader, quran, and book-data modules. Auto-dismisses after 2.5s.
Failure variant of showToast — prepends a
Copies text to the clipboard. Tries navigator.clipboard.writeText() first; falls back to a hidden textarea + execCommand("copy") for older browsers. Shows a toast with the given i18n keys for success/failure.
Centralised object of all localStorage key strings. Keys include theme, fontSize, fontSystem, contentWidth, lang, focus, pinnedBooks, readHistory, readerPrefix, and several reader:*-prefixed keys. Defined in common.js; available globally. Prefer these over raw string literals for any listed key (some older call sites still use the raw strings directly).
Creates a modal overlay dynamically (for modals not in static HTML). Appends to body, registers with window.MODAL_IDS, wires backdrop-click and close-button via wireModal. The generated overlay carries the standard .modal-title / .modal-body classes, matching the static modals. Returns the overlay element. Used by pins-history.js for the pins/history modal.
All modals (settings, font, pins/history) share the same open/close/Escape pattern.
| Function | Description |
|---|---|
window.openModal(id) |
Closes all other modals, then opens the one with the given overlay ID. Moves focus into the modal (first focusable) and remembers the trigger so close restores it. |
window.closeModal(id) |
Closes a specific modal by overlay ID and restores focus to the element that opened it. |
window.closeAllModals() |
Closes every registered modal. |
window.MODAL_IDS |
Array of registered modal overlay IDs. Pins/history self-registers on first open. |
wireModal(id) in common.js auto-wires backdrop-click-to-close and the .modal-close button for any modal at page load. Dynamically-created modals (pins/history) wire themselves on creation.
Pure data/logic — no DOM dependencies. CSV loading, data merging, ayah decoration, structural derivation. Detection + column classification (and the column source map + registry) moved to book-data.js so every book's reader gets them without pulling the Quran modules in. Imported by quran-ui.js only — reader.js lazy-loads the pair (dynamic import) when a QRN-prefixed book opens.
DOM-heavy UI — initQuranUI(ctx). Surah/ayah/juz dropdowns, content presets, display options, surah selector overlay, on-demand column loading. Re-exports the quran-data.js symbols (barrel pattern). Dynamically imported by reader.js on QRN detection; the shared classification helpers it needs come from book-data.js, not from the lazy modules.
Memoized. Derives the base structure — [juzNo-HDN, surahNo-HDN, ayahNo-HDN, basmalah, ayahImlai] per row, 6,236 rows — at load time from three sources: Imlai text via loadQuranBookCSV(QRN-DATA-ayahImlai.csv) (version-gated IndexedDB cache), surah spans and the per-surah basmalah from 05-registry-quranSurahs.csv, juz cut points from 06-registry-quranJuz.csv. Structural cells are String-typed to match CSV byte semantics. Also fills the O(1) lookup tables behind getSurahStartRow / getJuzStartRow.
O(1) lookups into the start-row tables built by loadQuranBaseData; return -1 before the base data is loaded. A juz range's end is the next juz's start row (or allData.length after juz 30); a surah's end is its start plus its ayahCount. Slice indices equal base-row indices — merge never reorders rows, so these work even with book columns inserted.
Loads base data + the current book's CSV + surah names, then merges into a single {headerRow, allData}. Base columns come first, then book-specific columns appended.
The reader's streaming twin of mergeQuranData. Loads the base skeleton first, then streams the book CSV through fetchBookCSVCached with keepEmpty and streamOpts (onFirstRow receives the MERGED header — base columns plus the book's; onRows receives already-merged batches; onProgress passes through). A running merge cursor crosses batch boundaries so the 128-row batches never shift the row alignment; rows past the base's end are dropped, and a count mismatch vs the base's 6,236 logs the same console warning as mergeQuranData. Resolves null when the stream engaged (the reader consumed every row via the callbacks; the IDB record written by csv.js is the RAW book CSV, byte-compatible with the whole-file path) or {headerRow, allData} in the whole-file fallback (cache hit, sub-threshold, no ReadableStream) — one promise shape either way.
Fetches and parses one translation/tafsir book CSV into {headerRow, allData}. Keeps a one‑entry cache (most recent book only): the content modal inserts columns one at a time, but the registry lists each book's columns together, so consecutive inserts from the same book reuse the cache instead of re‑fetching and re‑parsing the whole file per column. Memory stays bounded — only one book's parsed rows are retained at a time. streamOpts ({onProgress, signal}, forwarded to fetchBookCSVCached) opts into the streaming path — the content modal's on‑demand loads use it for the progress line and the Cancel button; absent (the merge paths) means the exact whole‑file behaviour. Unlike mergeQuranDataStreamed this always resolves with the full {headerRow, allData} — the modal needs the whole array to extract one column.
Pure column‑layout rebuild — the heart of the content modal's reorder feature. Takes {baseCount, headerRow, allData, normAllData?, loadedMap, hiddenColumns, order, pending?} and returns a fresh {headerRow, allData, normAllData, loadedMap, hiddenColumns} where:
- Base columns (first
baseCount) keep their fixed front positions - Loaded columns appear in
order(the modal's list order) — not insertion order loadedMapentries with value-1are pending inserts carried bypending(key →{name, values, normValues}), placed at their list position- Hidden indices are remapped to follow their columns
quran-ui.js applies the result in place (the reader holds the same array references) and rebuilds the reader.
Fetches 07-registry-quranColumns.csv — a registry of all available Quran columns across all books. Each entry has sourceBook, sourceCol, displayDV, displayEN.
QRN_PRESET_MAIN and QRN_PRESET_ARABIC arrays in quran-data.js define which source books (by book code) are included in the Main and Arabic preset buttons in the content modal. Edit these arrays to change which books are shown. Reset clears all externals; All shows everything.
The content modal (quran-ui.js) lists every available column in _colOrder (registry order by default) with checkboxes and ▲▼ reorder buttons. The reader's column layout is always rebuilt from this order via applyColumnOrder — so loaded columns appear in the list's order, not the order they were added. Base columns (juz/surah/ayah numbers, basmalah, ayah text) are fixed at the front; only their checkboxes are active. Moving a loaded column reorders the reader immediately; moving an unloaded one sets where it lands when checked. The modal is created once via window.createModal with the unified modal layer (backdrop, close, Escape).
Returns the book-level title (from 03-registry-bookMeta.csv) for the source book that column colIndex belongs to. Returns null for base data columns. Falls back to the raw book code if the book isn't in the registry. Used by the card renderer and clipboard exporter to label each book's content.
Returns the source book code that column colIndex belongs to (or null for base data columns). The renderer uses this to skip the source-book label for the Uthmani-script column.
Returns true when any column from a book other than currentBookCode or the base data is loaded. The renderer uses this to decide whether to show source-book labels.
Rebuilds the source map from the internal loadedColMap. Called automatically after column loading — consumer code should not need to invoke this.
Wraps ayah text in ﴿ ﴾ braces and appends the ayah number (as Arabic numeral). Respects user display preferences from localStorage.
Returns true if the column header is an ayah text column (ayahimlai, ayahuthmani).
Shared across card/parallel/table renderers and all export formats. Imported by reader.js and export-epub.js.
| Function | Returns | Description |
|---|---|---|
columnFieldClass(hdr) |
"" or CSS class |
Maps header prefix (head/kitab/bab/matn/sharh) to reader-field-* class |
columnTdClass(hdr, isQuran) |
"" or HTML attr |
Maps header prefix + language to class="td-matn" / class="td-sharh" / class="td-ar" for table mode |
isFootnoteColumn(hdr) |
boolean | true if header starts with foot |
isArDvTransition(prev, curr) |
boolean | true if prev ends with ar and curr ends with dv |
isMatnSharhTransition(prev, curr) |
boolean | true if prev starts with matn and curr starts with sharh |
classifyColumnLang(hdr, isQuran) |
"ar" / "dv" / "neutral" |
Language classification for parallel text view |
isArabicColumn(hdr, isQuran) |
boolean | true for Arabic content columns (…AR suffix, Quran ayah texts, basmalah) — drives the reader's content wash (--color-wash-bg, the site's one tint token shared with RDF-all's merged-row-rasmee rows) on .reader-ar-region in card/parallel views and td.td-ar in table mode |
Finds the indices of juz/surah/ayah columns in the header. Cached.
Extract ayah, juz, and surah numbers from a data row.
Syncs the surah/ayah/juz inputs and labels with quranState.
| Function | Description |
|---|---|
getSurahInfo(surahNo) |
Returns {nameAR, nameDV, nameEN, ayahCount} |
buildSurahListHTML(query, currentSurah) |
Renders searchable surah selector HTML |
toArabicNumeral(n) |
Converts a number to Arabic-Indic numerals (١٢٣) |
Shared mutable state object:
{ currentSurah: 1, currentAyah: 1, currentJuz: 1 }Consumes reader-position.js, reader-search-ui.js, table-scroll-sync.js, search-utils.js, i18n.js, book-data.js, csv.js, export.js, book-info.js. The quran-ui.js pair (with quran-data.js) is dynamically imported on QRN detection only — non-QRN readers never fetch it. Key internal functions (search/position/scrollbar APIs live in their own modules below):
| Function | Description |
|---|---|
window.setFocus(on) |
(common.js) Toggles data-focus on <html>, updates #btnFocus, persists to LS, dispatches focuschange event. Shared across both pages. |
goTo(rowIdx) |
Scrolls to a specific row, lazy‑loading chunks as needed |
loadInitial() |
Renders the first chunk of rows |
rebuildAll() |
Re‑renders all visible rows (used after settings change) |
renderPageTags() |
Renders tag badges in the reader header |
renderRowHTML(row, rowNum) |
Card‑view row renderer — builds vertical <div> stack with field classes and spacers. |
renderParallelRowHTML(row, rowNum) |
Parallel‑view row renderer — partitions fields by language suffix (ar/dv) into a two‑column grid. |
rowText(row, rowNum) |
Formats a row for clipboard copy — decorated ayah text for Quran, header‑aware formatting for other books. |
updateViewModeUI() |
Syncs the 📖 View dropdown trigger button and checkboxes with the current viewMode (mutual exclusion). |
window.closeAllDropdowns() |
Closes all registered dropdowns at once. |
window.openDropdown(dd, anchorEl, gap) |
Closes other dropdowns, positions dd below anchorEl with the given gap (default 4px), and shows it. Used by all dropdown toggles. |
window.registerDropdown(id, dd, anchor) |
Registers a dropdown ID and wires its outside‑click‑to‑close handler. The ID is added to the shared close list automatically. |
trapWheel(el) (quran-ui.js) |
Stops wheel events on el from propagating — prevents dropdown scroll from hijacking the horizontal .quran-nav row. |
| Event | Dispatched by | Listened by | Purpose |
|---|---|---|---|
readerReset |
common.js (btnResetSettings) |
reader.js |
Delegates reader‑specific reset to the reader module (view mode, hidden columns, tashkeel, Quran display) without tight coupling. |
dashboardReset |
common.js (btnResetSettings) |
dashboard.js |
Delegates dashboard‑specific reset (pins, history, search, filters) without tight coupling. |
languagechange |
i18n.js |
All modules | Triggers UI re‑render when the user changes language. |
focuschange |
common.js (window.setFocus) |
reader.js, dashboard.js | Fires after focus mode toggles. Reader uses it to recalc --table-header-top and scroll padding; dashboard uses it for optional layout adjustments. |
- Standard books — header line
titleDV - titleARfollowed by row text with column separators (AR/DV spacer, matn/sharh divider, footnote divider). - Quran books — no book header line. Ayah text decorated with
﴿ ﴾braces, surah reference[name surahNo : ayahNo], then columns grouped by source book with a book-level label (from03-registry-bookMeta.csv) above each book's columns. Per-column headers are omitted.
The virtual merged radheef book (RDF-all) — a registry book with no content CSV; its rows are assembled in memory at load from the eight source radheef books (see ARCHITECTURE.md → "Virtual merged books" for the design contract). Imported by reader.js only.
| Function | Description |
|---|---|
isMergedRadheefBook(bookCode) |
true for RDF-all (the only virtual book today). Used by reader.js's loadBookData() to pick the virtual load path. |
loadMergedRadheefBook() |
Fetches the 8 sources via fetchBookCSVCached (each keyed by its own registry version), projects every row by header name into wordAR, wordDV, wordEN, meanAR, meanDV, meanEN, source, and resolves { data, headerRow, hasRowNums: false } — the same shape as loadStandardBook. source carries each row's book's Dhivehi title from the registry; blocks concatenate in MERGED_SOURCES order (registry order). A source that fails or has no rows is skipped; if nothing loads, data is empty and the reader's "No data found" path takes over. |
loadMergedRadheefBookStreamed(streamOpts) |
The reader's streaming twin (first visits — the merged book is the library's heaviest, ~15 MB raw). Phase 0 HEADs all 8 sources in parallel and sums the Content-Lengths — that sum is the progress line's total. Phase 1 streams the sources sequentially in MERGED_SOURCES order through fetchBookCSVCached(…, streamOpts), projecting each batch into the merged schema (projectBatch — shared with the whole-file path, byte-identical) before onRows. onFirstRow receives the static MERGED_HEADERS once, before the first source. Aggregate onProgress = (completed sources' bytes + the current source's share) / total, clamped. Cache-hit/sub-threshold sources deliver whole-file rows through the same onRows bridge; failed sources are skipped with their bytes advanced. Resolves null when the stream engaged or {data, headerRow, hasRowNums: false} from the whole-file fallback (any phase-0 HEAD failure — network, missing Content-Length, file://) — one promise shape either way. |
Reader position: the pagination strip, the visible-row detector, and the scroll-driven block (progress bar, milestone toasts, scroll counter, URL sync, read-history auto-log + pin update). Extracted from reader.js. Owns module-scope state set by initPosition(ctx); reads core-owned values through ctx accessors. Imports t/currentLang (i18n), addReadHistory/isPinned/addPin (book-data), and quran-ui helpers.
Registers the window scroll listener ({ passive: true }) and logs the initial read-history entry. ctx: { metadata, quranBook, headerRow, allData, getFilteredData, pinLabel, goTo } — metadata/quranBook/headerRow/allData are direct refs (never rebound); getFilteredData is an accessor because search reassigns filteredData; pinLabel/goTo are callbacks. Called from reader.js's initial render before loadInitial — the table branch calls updatePagination(), so the module's ctx must exist by then.
The URL sync, read-history auto-log and pin auto-update all store whole-book row numbers: surah/juz filter views are slices of allData, so the scroll handler maps the visible row back via allData.indexOf(filteredData[vRow]) + 1 before writing — the reader's ?row= handler reads rows against the full book at load (filters never appear in the URL). The Share button and the 📌 bookmark handler in reader.js follow the same convention, and pinLabel takes the same 1-based whole-book row.
Syncs the page strip, First/Prev/Next/Last buttons and the Quran nav row with the current scroll position. Throttled to ~8 fps; skips DOM writes when nothing changed; skips the page-strip rebuild while its number input is focused. Used by the toolbar call sites in reader.js and internally by the scroll handler.
Visible row index: elementFromPoint fast path at viewport centre, linear-scan fallback. Exported for reader.js (toolbar buttons, export ctx) and used internally.
In-book search engine wiring behind the unified search window (src/js/search-window.js): runs the page's search when the window input changes, renders this-book results into #searchWindowResults, and hosts the history section, whole-word toggle, and advanced conditions. Extracted from reader.js; imports updatePagination from reader-position.js, the search engine from search-utils.js, and the window shell API from search-window.js. For RDF-* books (dictionaries) the page's header input keeps filtering in place — see applyRadheefFilter below.
Wires the window (initSearchWindow({ mode: "reader", tabs: true, onInput, onOpen, onOpenAdvanced, ... })), the history section, the search-nav document keydown listener, and the window's result-row click delegation. ctx: { allData, normAllData, maxCols, colLabel, getFilteredData, setFilteredData, getLoadedStart, setLoadedStart, getLoadedEnd, setLoadedEnd, rebuildAll, loadInitial, observeSentinels, goTo } — allData/normAllData/maxCols direct refs (never rebound); the getter/setter pairs cover variables search reassigns (filteredData, loadedStart/loadedEnd).
Entry point for an in-book search. RDF books take the early branch applyRadheefFilter(query) (the header input's in-place filter); every other book routes to applySearchWindow(query). Used by reader.js's settings reset and the ?q= deep-link block.
Runs the search engine and renders this-book results into the window: count header + result rows into #searchWindowResults, selectedResultIdx state for ↑↓ navigation (onSearchKeydown — the document keydown listener, guarded on document.activeElement === winInput), and the hint strip via showWindowHint. Adds the term to history (searchHistory localStorage key, max 20). Zero matches renders count 0 and the no-matches message in the window only.
RDF-family in-place filter: compiles the query (whole-word toggle honoured), filters ctx.setFilteredData(matches) and ctx.rebuildAll()s so only matching rows render — no window involvement. Clearing the input restores all rows; zero matches renders the empty-state message; the scroll counter shows the match count (comma-formatted). History is added on input like the normal flow. The ?q= deep link filters through the same path on load.
Renders the advanced conditions (AND/OR, operators, values) into the window's advanced section. Reached through the shell's onOpenAdvanced path — the advanced toggle, or Ctrl+Shift+F, which opens the window with the section expanded.
Parses a raw query string into the internal query shape, including the whole-word marker. Used by reader.js's ?q= deep-link block.
The unified modal search window shell shared by the reader and library pages (styles in src/css/search-window.css). Built once, eagerly, via createModal("searchWindowOverlay", ...); behaviour flows in through initSearchWindow cfg callbacks — this module imports no page code (wiring lives in reader-search-ui.js and library-search-page.js). Owns: input row (#searchWindowInput + clear), options row (whole-word, advanced toggle), tabs row (reader only), results pane (#searchWindowResults), history section, scope section, Authors/Periods facet section (visible with the scope — cross-book search only), footer strip (hint / status / open-page link).
Builds and configures the shell; returns the UI refs object. cfg: mode ("reader" | "library"), tabs (show the This book / All books tabs), options (false hides the options row), viewToggle (show the card/list view toggle), scope (true keeps the scope section visible in the this-book tab too), and callbacks: onOpen, onOpenAdvanced, onTabChange(tab), onViewChange(view), onInput(value), onHistoryChange, onReset, onOpenPage(value). Reader mode inits the shared scope picker (library-scope-picker.js) for the All-books tab.
Returns the shell's element refs ({ overlay, input, count, reset, options, view, wholeWord, advToggle, advBody, tabs, tabThis, tabAll, scope, results, history, hint, status, ... }), building the shell on first call — page modules resolve refs at init time after import.
Opens the modal and focuses/selects the input (re-focusing past the modal's pop transition so the input beats common.js's close-✕ focus-first). opts.openAdvanced expands the advanced section (the Ctrl+Shift+F path). Fires cfg.onOpen so the page re-runs its current query.
Programmatic query set for the ?q= deep-link path — the window may be closed; opening it later shows the query and re-runs it via cfg.onOpen.
Current tab id — "thisBook" | "allBooks".
Sets the result-count slot (width-reserved so the footer doesn't shift).
Shows/hides the hint strip (↑↓ navigate · Enter follow · Esc close). Pages call it as results arrive — shown only while result rows are on screen.
Cross-book search over the generated index (lazy loadScopedIndex — scope = picker selection, or every book when the picker isn't open; failure → footer status + retry) scoped by that selection intersected with the active author/period facets (facetScopedBooks — a facet state excluding every book renders "No matches" instead of falling through to an unscoped search); renders compact deep-link rows via buildBookRowsHTML and syncs the footer status.
Pure row builder for the All-books tab and the library window's compact list view: escaped titles, tag badges, match counts, deep links (reader.html?book=CODE&row=N&q=…).
Link-row keyboard navigation. The shell's input-level keydown listener owns link rows (.search-window-book-link, .lib-result): ↑↓ toggles .active, Enter follows the row's a.href. It fires before any page document-level handler and no-ops when no link rows are on screen — this-book result rows (.search-result[data-real]) stay owned by the reader page's onSearchKeydown. The footer renders only while the hint, status, or open-page link is visible (syncFooter).
The one Authors/Periods browse used by every surface: the library-search page's chips + buttons, the dashboard's functions panel + chips, and the search window's All-books section. Owns the facet state (one page load, one state — all surfaces on a page read/write the same), the browse modals, and the chip markup; consumers subscribe via onFacetChange and re-render their own surfaces, while the module re-renders its open modals. Imports nothing that imports it (no cycles).
Replace / read the whole facet state — authors is an array of codes (OR semantics), period a single bucket ("3", "modern", or ""). facetState() returns {authors: [...], period} (copies). The URL deep links (?authors=…&period=…) land here on both the library page and the dashboard.
Author chips and modal rows are OR toggles; the period is single-select (clicking the active bucket clears it). clearFacets() empties everything (the dashboard's reset button calls it). All notify subscribers + open modals.
Subscribe to state changes — returns an unsubscribe. The library page re-syncs its URL, chips and (with a query set) re-runs the search; the dashboard re-syncs URL + grid; the search window re-renders its chips and re-runs the All-books search.
Does a registry row pass the active filters? (true when none) — author OR over the row's authorCode tokens, period = any of its authors' death-century buckets (authorPeriodOf — the death century, or the single modern bucket for 15th-century-AH-and-later deaths and blank diedAH).
Per-author and per-period book counts over a given book list; visibleCounts() is the cached counts over the registry's visible (-HDN-excluded) books — the set every surface chips against and the browse lists show. The browse modals list only authors with ≥1 visible book, and only buckets with visible authors.
Chip markup for the active author/period (tag-chip visuals, accent-tinted — .author-chip / .period-chip with data-author / data-period); the click handler toggles the state via the module.
Open the shared browse modals (libAuthorsOverlay / libPeriodsOverlay — the same ids on every page, one page loaded at a time). Each modal is a filter input (#libAuthorsFilter / #libPeriodsFilter, the shared .search-input look) with a result count beside it (#libAuthorsFilterCount / #libPeriodsFilterCount, the search window's "match: N" pattern — always visible, reading the shown rows (the full list with an empty filter), the slot width pre-reserved at open so the count's digit changes never shift the input — styled in common.css because search-window.css isn't loaded on the dashboard) above a pinned thead strip and a scrollport holding only the rows (.facet-table-wrap) — the scrollbar runs beside the list alone; the modal body drops the base .modal-body gap (16px) only — the thead bar is the separator — and keeps the base 24px side padding, so the header, filter row, thead bar and rows all sit on the same band (the search window's all-around padding). The thead strip and the rows share one grid column template (.facet-grid-authors — index, name, Arabic, century, range, age, Gregorian, count, check — the index a fixed 44px track under a bare "#" header, the derived age right after the years, before the Gregorian span — / .facet-grid-periods — century, range, authors, Gregorian, count, check — the authors track right after the years range — the name/Arabic/century/range/Gregorian labels pinned on the authors grid (the range pinned to its widest range text like the Gregorian one — no 1fr anywhere, so the age track sits directly against the years and the row's slack collects at the far end; the periods grid pins its range the same way, so the years sit at their natural width and hug the century label column, which takes the leftover width the way the authors' names do), the age and authors tracks fixed short-number tracks at 48/56px), so the columns align by construction; the thead cells inherit the modal body's rtl (right-aligned, matching the rows — the check column's header carries the same ✓ as the rows' centered glyphs, centered over them). On desktop, author rows are one-line grid divs: the current-language name (the tooltip lists all three names), the Arabic name in its own column — empty in the Arabic UI, where the primary name already is Arabic — then the death century unbracketed (the centuryN label in numeral form — "Century 7" / "ގަރުނު 7" / "القرن 7", the same keys as the period rows and the chips — a modern author's cell reads the century label with the leading plus — "ގަރުނު +15" / "Century +15" / "القرن +15" (the periodFromCentury template) — the same "+" the modern row's name marker and from-forms carry) and the Hijri years bracketed each in their own column (a died-only author leads with the same bare dash the born–died range uses between its years — –179 ހ. — marking the missing born year, glued to the year like the range's own dash), then the derived age — authorAgeText, diedAH − bornAH (a ~ estimate on either end carries over; blank when a date is missing) with the language's year-unit shorthand appended (86 އ. / 86 y. / 86 س.) — muted like the CE — then the Gregorian (miladi) lifetime — authorYearsCeText, the same AH→CE approximation as the period rows (a ~ estimate in the data carries over; died-only authors get a single year, dash-led the same way) — bracketed and rendered in the muted tone (--color-text-muted) against the plain Hijri dates, then a count, in registry row order; period rows are the distinct death-century buckets + modern (the AH span bracketed in its own column, the distinct-author count right after it, and its Gregorian equivalent — periodRangeCeText, the 1 Hijri year ≈ 0.970229 solar years approximation with offset 621.57, rounded, derived at render from the bucket — bracketed next; "modern" has neither a closing year nor a century span — its row name carries the open-ended "(+15)" marker (periodRowName — the plus leading the century number, the bare periodLabel staying pure for the chip and the info modal's guarded century fact) and its range cells show the open-ended "from" forms instead: "+1401 ހ." via the periodFromAH template (the 15th century's first year — MODERN_PERIOD_CENTURY/MODERN_PERIOD_FROM_AH in book-data.js) and the CE of that year, "+1981 މ.", via periodFromCE), chronological, each row carrying the count of distinct authors with a book in the bucket (an author enters a bucket only via a book, so zero-book authors never inflate it; a multi-book author counts once). All text sits at the row's full text size (no downscaling). The rows' cells keep their mobile-friendly DOM order inside the line wrappers, so the desktop grids place every cell explicitly — grid-column for the swapped pair's track and grid-row: 1 on all cells: a column-only pin would let the grid's sparse auto-placer walk its cursor back on the DOM/visual swap and drop the swapped cell plus everything after it into a second band (the periods modal's "two subrows" look); the thead follows the visual order and auto-places. The variable text columns are pinned to their widest content (pinFacetColumn sets --facet-name-w / --facet-ar-w / --facet-century-w / --facet-range-w / --facet-ce-w on the authors overlay — the name and Arabic tracks capped at 220/240px, the range and Gregorian tracks measured first, nowrap like the pins, so their widths feed the share formula, longer names wrap within the cell — and --facet-range-w / --facet-period-w / --facet-ce-w on the periods overlay — the century label column gets its leftover share set directly (its text is short, so the measured-content clamp pinFacetColumn applies would never let it grow) so the header and every row share identical tracks; the thead row mirrors the scrollport gutter (--facet-gutter). The caps scale with the scrollport width: on narrower desktops the name/Arabic caps scale down together, so the text columns yield first and the fixed tracks never cram; on wide desktops they step up (up to +110px each) so the columns fill toward their content instead of sitting at the small-screen maximum. On narrow screens (≤600px) the thead strip folds away entirely and the rows stop being grids: the line wrappers from facet-browse.js (.facet-line-1…2, display: contents on desktop so the column contract holds — the count and check sit in the dates wrapper, their grid columns class-placed so the DOM move never shifts the desktop columns) become plain flowing text lines — index · name · Arabic name / century · years · CE · age · "ފޮތް: N" ✓ (the index leads line 1, dot-free — the .facet-name::before join is scoped to author rows, so the index-less periods rows keep their name at the line lead), two lines for periods (label · years · CE / authors · "ފޮތް: N" ✓ — the authors count leading its line unjoined) — joined by a bare dot with margins (a " · " string would lose its leading space to inline white-space collapsing at the start of the cell's line); the count labels (ފޮތް, Authors, Age — the current language's words) render inline via .facet-count-label spans (hidden on desktop under their own thead columns) and inherit their cells' weight — only the books count's label reads bold (600, in the name's colour by the same inheritance), the age and authors labels stay plain captions of their muted figures; the counts in both modals read bold like the name column, and the periods' first column (the century label) is bold the same way; the ✓ gets the spacing without a dot. Both filters run through normaliseForSearch — the same fuzzy normalizer as the library search. Opens stacked over the search window (openModalOnTop) when one is up, exclusively otherwise.
Table view's top scrollbar widget: mirrors its horizontal scroll onto the table (RTL-aware — Chrome and Firefox disagree on scrollLeft sign), smooth-scrolls one column per arrow click, supports shift+wheel. The widget DOM is created by reader.js's loadInitial (table branch), which calls initTableScroll right after. Imports nothing.
Resolves the widget DOM (#tableTopScroll, #tableWrap, #tableTopScrollInner, arrow buttons) and wires scroll/click/wheel listeners. ctx: { headerRow, getHiddenColumns } — the accessor because the settings reset rebinds hiddenColumns (a captured ref would go stale).
Recomputes table and spacer widths and scrollbar visibility after column toggles or window resize. Safe to call before init — the DOM lookups return null and the guards bail. Called by reader.js's resize listener and the append/prepend loaders.
Lazy-loaded module — only fetched when the user chooses EPUB export. Imports zipStore from export-zip.js, escapeXML from search-utils.js, and bookAuthorNames + the column helpers from book-data.js.
Generates a valid EPUB 3 e-book Blob. Each book row becomes a chapter. The Hadithmv font is optionally embedded for offline reading.
rows— 2D array of cell valuesmeta—{bookCode, authorCode?, titleEN, titleDV, titleAR}opts—{siteURL, fontData?: Uint8Array}- Returns
Blobwith MIME typeapplication/epub+zip dc:creator— the book's author(s) viabookAuthorNames(meta)(English names, no years); falls back toHadithmvwhen the book has no author
Structure: mimetype (first, uncompressed) · META-INF/container.xml · OEBPS/content.opf (Dublin Core metadata) · OEBPS/nav.xhtml (EPUB 3 TOC) · OEBPS/cover.xhtml · OEBPS/chXXX.xhtml (one per row) · OEBPS/styles.css · OEBPS/fonts/hadithmv.woff2 (if embedded).
import("./export-epub.js").then(mod => {
const blob = mod.createEPUB(allData, {
bookCode: "AQD-nawaqidulIslam",
titleEN: "Nawaqid ul-Islam",
titleDV: "ނަވާޤިޟުލް އިސްލާމް",
titleAR: "نواقض الإسلام"
}, { siteURL, fontData: new Uint8Array(fontBuf) });
// download blob…
});Lazy-loaded module — only fetched when the user chooses Excel export. Imports escapeXML from search-utils.js and zipStore from export-zip.js.
Generates a valid .xlsx (Office Open XML) spreadsheet Blob.
rows— 2D array of cell values (null/undefined→ empty cell)sheetName— sheet name, sanitised to ≤31 chars with[ ] : * ? / \\removed- Returns
Blobwith MIME typeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Uses inline strings (no shared-strings table) and store-only ZIP (no compression). The ZIP bundles five XML files: [Content_Types].xml, _rels/.rels, xl/workbook.xml, xl/_rels/workbook.xml.rels, xl/worksheets/sheet1.xml.
import("./export-xlsx.js").then(mod => {
const blob = mod.createXLSX(allData, "MySheet");
// download blob…
});Minimal store-only ZIP writer — shared by the XLSX and EPUB writers (EPUB is a ZIP of XHTML + XML metadata). Lazy-loaded with whichever writer needs it.
Store-only ZIP writer. Takes [{name: string, data: Uint8Array}], returns Uint8Array.
No JSON endpoints. All data is CSV — one source of truth, no duplication. If you need JSON, fetch the CSV and parse it yourself. Base URL: https://hadithmv.github.io/codebase/
GET data/03-registry-bookMeta.csvColumns: bookCode,authorCode,titleAR,titleDV,titleEN,tags,excludeFromIndex,version. One row per registered book. The tags column holds secondary tag codes (comma‑separated); the primary tag is the first segment of bookCode. The authorCode column holds author codes (comma‑separated) from 02-registry-bookAuthors.csv.
GET data/01-registry-bookTags.csvColumns: tagCode,labelAR,labelDV,labelEN,aliasesAR,aliasesDV,aliasesEN. Colours are auto‑generated client‑side using golden‑ratio HSL — unlimited tags, always distinct.
GET data/02-registry-bookAuthors.csvColumns: authorCode,nameAR,nameDV,nameEN,bornAH,diedAH. Hijri years as plain numerals (blank = unknown/living). Row order is the browse list's display order (chronological by death year in the current file).
GET data/content/{bookCode}.csvFirst row is the column header. Column 0 is # (row numbers) or regular content. Headers ending in *AR are Arabic, *DV are Dhivehi. Standard CSV: comma‑delimited, quoted fields, \r\n line endings.
Every language has a CSV parser. Here's how to get started:
// JavaScript — fetch + parse to array of arrays
const csv = await fetch(url).then(r => r.text());
const rows = csv.trim().split(/\r?\n/).map(line => {
const cols = []; let cur = "", inQ = false;
for (const c of line) {
if (inQ) { if (c === '"') inQ = false; else cur += c; }
else { if (c === '"') inQ = true; else if (c === ',') { cols.push(cur); cur = ""; } else cur += c; }
}
cols.push(cur); return cols;
});# Python — stdlib csv module
import csv, urllib.request
with urllib.request.urlopen(url) as r:
rows = list(csv.reader(r.read().decode().splitlines()))# curl into any CSV tool
curl -s https://hadithmv.github.io/codebase/data/03-registry-bookMeta.csv | csvlookcodebase/sw.js is a static service worker registered at the site root
(/codebase/ in production, / under the batteries — registration uses the
scope-relative ../../sw.js from each page, so both resolve correctly). It is
never edited — all version intelligence lives in dist/manifest.json.
A JSON object mapping scope-relative URL → sha256 fingerprint (first 16 hex)
of every file the SW may serve. Written whole by tools/hmv-manifest.mjs
— byte-stable, idempotent, keys sorted, 2-space indent, LF, no trailing
newline. Covered: dist/ (books, js, css, font), the six -registry- CSVs
in data/, and static/notes/. Not covered (pass straight through):
data/content/*.csv, the search-index manifest and the per-book shards
(data/search-index/) — the app's own IndexedDB caches own them
(fetchBookCSVCached for books, loadIndexMeta/loadScopedIndex for the
index), and ~105 MB of corpus plus ~19 MB of postings must never ride the
SW cache.
- On install: best-effort precache of every manifest URL,
skipWaiting. - On activate: delete unknown caches,
clients.claim(). - On fetch (GET, in-scope only): resolve the current manifest
(network-first re-fetch with a 2-minute in-session staleness window; one
shared version per visit so the visit is internally consistent; on failure
the last copy serves and the next request retries). A requested file is
served from the
hmv-filescache when its storedx-hmv-fpresponse header matches the manifest's fingerprint; otherwise it is fetched fresh, a clone is stored with thex-hmv-fpheader, and the original returns. Files not in the manifest pass through to a plainfetch. - Offline: the last manifest + last cached copies serve everything they
cover; the library search falls back to its IndexedDB copies of the
manifest + shards (see
loadIndexMeta/loadScopedIndex— offline search covers the books actually searched online).
Each page carries this inline script (it fails silently on file:// and in
the Tauri/Android app builds, which bundle their own assets):
<script>
if ("serviceWorker" in navigator) {
try { navigator.serviceWorker.register("../../sw.js").catch(function () {}); } catch (e) {}
}
</script>The battery tools/hmv-sw-check.mjs verifies the whole contract (it must
serve over http://127.0.0.1 — a secure context is required for SWs).
No authentication, no rate limiting, no CORS — static files on GitHub Pages.