Store bookmarks and progress

A bookmark is the one thing your app writes that has to be read back a year later, by a newer build, under a different reading. Three fields that look like stable keys are not. This page is the record to write, the call that resolves it, and the cases where resolving returns two verses or none.

Blocks used
Platforms
Anywhere you persist state
Time
About 10 minutes

The three things that shift

Page breaks belong to the muṣḥaf being printed, and a word number belongs to one build of the dataset. The record that survives is which edition, its own ayah number, and the text it was built from: not a page number without an edition, not a word number without a rebuild guard, and never a bare 2:255 that assumes the count.

Looks stableShifts whenPersist instead
2:255 alonethe user switches riwayah — Warsh numbers āyat al-Kursī 2:253, and 2:255 is another versethe edition key beside the number, and convert with .to()
a page numbera different muṣḥaf is on screen — page breaks belong to the printingthe ayah, and derive the page from the edition being shown
the shared word number, e.g. 5177a future publisher release adds or removes one word, shifting every number after itthe ayah plus the 1-based word index within it, or the word index’s content-derived key
  1. Write the record

    Everything below is read off the loaded muṣḥaf, so the record is right for whatever edition the user was reading. The reference is stored as the ayah key, surah and ayah together (Engineering, rule 2.1), and source is the SHA-256 of the publisher’s package the words came from, the provenance that lets a later build pin what was read (Versioning and corrections, rule 6.3) and tell whether the text underneath has changed.

    import { Mushaf } from "quran-text";
    
    const hafs = await Mushaf.hafs();
    
    /** What to write to storage: the edition, its own count, and the text it came from. */
    function bookmark(m, surah, ayah) {
      const a = m.ayah(surah, ayah);
      return {
        edition: m.key,                     // "hafs"
        counting: m.countingSystem,         // "kufi"
        surah: a.surah.number,              // 2
        ayah: a.number,                     // 255 — in this edition's own count
        source: m.provenance.text.sha256,   // "cdec7341b7c684e7…"
      };
    }
    
    bookmark(hafs, 2, 255);
    // { edition: "hafs", counting: "kufi", surah: 2, ayah: 255, source: "cdec7341b7c684e7b8dd469c4b68988914d2784e924c7e6cbb9cb4b57c24f013" }
    

    For a reading position inside a verse, add the word’s 1-based index within the ayah — hafs.word(2, 255, 3).index is 3 — not its position in the array and not its shared number.

  2. Resolve it against whatever is on screen

    A bookmark made under Ḥafṣ and opened under Warsh has to be translated, and the translation is one call. The result names its relation.

    const warsh = await Mushaf.load("data/mushaf/warsh.json");
    const editions = { hafs, warsh };
    
    /** Resolve a stored bookmark against the edition being displayed now. */
    function resolve(b, target) {
      const origin = editions[b.edition];
      const match = origin.ayah(b.surah, b.ayah).to(target);
      return { key: match.key, relation: match.relation, ayahs: match.ayahs };
    }
    
    resolve(bookmark(hafs, 2, 255), warsh);   // { key: "2:253-254", relation: "split",  ayahs: [Ayah, Ayah] }
    resolve(bookmark(warsh, 2, 253), hafs);   // { key: "2:255",     relation: "merged", ayahs: [Ayah] }
    resolve(bookmark(hafs, 1, 4), warsh);     // { key: "1:3",       relation: "same",   ayahs: [Ayah] }
    

    If the origin edition is not loaded on the device, data/ayah-map.json answers the same question without any muṣḥaf file — AyahMap.convert(2, 255, "warsh") returns { surah: 2, ayah: 253, ayahLast: 254, relation: "split" }. It maps from Ḥafṣ, so store Ḥafṣ coordinates if you intend to rely on it.

  3. Branch on relation, every time

    relation is one of same, merged, split, shifted, unnumbered or missing. Two of them change how many verses you highlight, and one of them gives you nothing to highlight at all.

    const r = resolve(b, current);
    
    switch (r.relation) {
      case "same":
      case "shifted":
        return scrollTo(r.ayahs[0]);              // one verse, possibly a different number
      case "split":
        return highlight(r.ayahs);                // two verses in this edition — show both
      case "merged":
        return scrollTo(r.ayahs[0]);              // this edition's verse contains the bookmarked one
      case "unnumbered":
        return scrollTo(current.surah(b.surah).basmalah);   // see below
      case "missing":
        return explain("not in this reading");
    }
    
    A bookmark on 1:1 resolves to an empty key under four editions

    Ḥafṣ numbers the opening formula of al-Fātiḥa as ayah 1. Warsh, Qālūn, al-Dūrī and al-Sūsī print it unnumbered. So hafs.ayah(1, 1).to(warsh) returns relation: "unnumbered", ayahs: [] — and key is the empty string "", while AyahMap.convert(1, 1, "warsh").key is "1:0" for the same case. Code that uses key as a lookup gets "" and shows nothing. Read relation first; the words are in surah(1).basmalah.

  4. Guard against a rebuild

    On load, compare the stored source with the current edition’s. The same digest means the same text and the record is exact. A different digest means the publisher released a correction; the ayah numbers are almost certainly still right, and the word index may not be.

    const current = editions[b.edition];
    const sameText = current.provenance.text.sha256 === b.source;
    
    if (!sameText && b.word) {
      // The text moved under the bookmark. Keep the ayah; re-derive the word by
      // content rather than by index, or drop to ayah precision and say so.
    }
    

    If you need a word-level key that survives a rebuild, the word index’s key field is content-derived (surah:pointed#occurrence, for example 2:اسرايل#1), and its hafs block gives Ḥafṣ coordinates in the Kufan count. Both are stable across rebuilds of the same sources; the running number is not.

warsh.ayah(2, 255) is a valid call that returns the wrong verse

Skip the conversion and warsh.ayah(2, 255).text opens لَآ إِكْرَاهَ, the passage Ḥafṣ numbers 2:256, not āyat al-Kursī. The edition key in the record is what makes the conversion possible.

The whole thing

// bookmarks.mjs — run with: node bookmarks.mjs
import { Mushaf } from "quran-text";

const hafs = await Mushaf.hafs();
const warsh = await Mushaf.load("data/mushaf/warsh.json");
const editions = { hafs, warsh };

function bookmark(m, surah, ayah) {
  const a = m.ayah(surah, ayah);
  return { edition: m.key, counting: m.countingSystem, surah: a.surah.number, ayah: a.number, source: m.provenance.text.sha256 };
}

function resolve(b, target) {
  const match = editions[b.edition].ayah(b.surah, b.ayah).to(target);
  return { key: match.key, relation: match.relation, ayahs: match.ayahs };
}

const b = bookmark(hafs, 2, 255);
console.log(resolve(b, warsh).key, resolve(b, warsh).relation);                 // 2:253-254 split
console.log(resolve(bookmark(warsh, 2, 253), hafs).key);                        // 2:255
console.log(resolve(bookmark(hafs, 1, 1), warsh).relation);                     // unnumbered

const w = hafs.word(2, 255, 3);
console.log({ surah: 2, ayah: 255, word: w.index }, w.number, w.to(warsh).ayah.key);   // { … word: 3 } 5177 2:253

Beyond the seven editions

The conversion above is between these seven printed editions. If your references come from somewhere else — a translation keyed to a different counting system, a scholarly citation — the hub is Kufan numbering and the tool is Qiraat Ayah Map, which gives the same advice: store one numbering, translate on the way to the screen, and branch on status.

Try it live

The same ayah in all seven editions, with each one’s own boundaries — the conversion this page persists across.