@sahabaplus/mushaf-engine
Version:
TypeScript implementation of a Quran Mushaf navigation engine
675 lines (674 loc) • 27.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.VersesNavigator = void 0;
const direction_1 = require("./direction");
const navigationSettings_1 = require("./navigationSettings");
const versePosition_1 = require("./versePosition");
/**
* A navigator for moving through verses in the Mushaf with bounded iteration support.
*
* `VersesNavigator` provides controlled navigation through Quranic verses with support
* for bounded ranges and iteration limits. It automatically handles direction changes
* and maintains iteration state for cycling through defined ranges.
*
* # Key Features
*
* ## Bounded Navigation
* - Navigate within specific verse ranges using `NavigationBounds`
* - Control iteration limits to cycle through ranges multiple times
* - Automatic bounds reversal when changing direction
*
* ## Iteration Behavior
* The navigator tracks iteration count and manages cycling behavior:
* - When reaching `lowerBound` with remaining iterations: resets to `upperBound`
* - When reaching `lowerBound` with no remaining iterations: returns `undefined`
* - Iteration count is incremented each time a cycle completes
*
* ## Direction Support
* - **Downwards**: Navigate from lower sura numbers to higher (1→114)
* - **Upwards**: Navigate from higher sura numbers to lower (114→1)
* - Bounds are automatically reversed when direction changes
*/
class VersesNavigator {
/**
* Creates a new `VersesNavigator` with specified settings and direction.
*
* @param mushaf - The Mushaf containing verse data
* @param metadata - Quran metadata for sura information
* @param settings - Navigation settings including bounds and iteration limits
* @param direction - Navigation direction (automatically adjusts bounds)
*/
constructor(mushaf, metadata, settings, direction) {
this.mushaf = mushaf;
this.quranMetadata = metadata;
this.currentPageIdx = 0;
this.currentVerseIdx = 0;
this.settings = settings;
this.direction = direction;
this.iterationCount = 0;
this.crossedBoundaries = false;
// Initialize position to start bound
this.resetPosition(this.getStartBound());
}
/**
* Creates a builder for `VersesNavigator` with default settings.
*
* Default settings:
* - `direction`: `Direction.Downwards`
* - `iterationLimit`: 0 (no cycling)
* - `upperBound`: (1, 1) - beginning of Al-Fatiha
* - `lowerBound`: (114, 6) - end of An-Nas
* - `ignoreSuraHeader`: false (include headers)
*/
static builder(mushaf, metadata) {
return new VersesNavigator(mushaf, metadata, navigationSettings_1.NavigationSettings.builder().build(), direction_1.Direction.Downwards);
}
/**
* Check if navigation direction is wrong for given start and end positions
*/
static isWrongDirection(start, end, direction) {
const startPos = start instanceof versePosition_1.VersePosition
? start
: new versePosition_1.VersePosition(start.sura, start.verse);
const endPos = end instanceof versePosition_1.VersePosition
? end
: new versePosition_1.VersePosition(end.sura, end.verse);
const internalWrongDirection = startPos.getSura() === endPos.getSura() &&
startPos.getVerse() > endPos.getVerse();
const externalWrongDirection = direction === direction_1.Direction.Downwards
? startPos.getSura() > endPos.getSura()
: startPos.getSura() < endPos.getSura();
return internalWrongDirection || externalWrongDirection;
}
/**
* Get the start bound based on current direction
*/
getStartBound() {
const bounds = this.settings.getBounds();
// In excluding mode, bounds are full Quran, not the configured bounds
if (bounds.isExcludingMode()) {
if (this.direction === direction_1.Direction.Downwards) {
return versePosition_1.VersePosition.start(); // (1,1)
}
else {
// Upward navigation reads suras in reverse order (114→1),
// starting at verse 1 of each sura. So start at (114, 1).
return new versePosition_1.VersePosition(114, 1);
}
}
else {
if (this.direction === direction_1.Direction.Downwards) {
return bounds.getUpperBound();
}
else {
// Start at lower_bound for upward navigation.
// Navigation reads forward within sura, then moves to previous suras.
return bounds.getLowerBound();
}
}
}
/**
* Get the end bound based on current direction
*/
getEndBound() {
const bounds = this.settings.getBounds();
// In excluding mode, bounds are full Quran, not the configured bounds
if (bounds.isExcludingMode()) {
if (this.direction === direction_1.Direction.Downwards) {
return versePosition_1.VersePosition.end(); // (114,6)
}
else {
// Upward navigation reads suras in reverse order (114→1),
// ending at the last verse of sura 1. Sura 1 (Al-Fatiha) has 7 verses.
const sura1 = this.quranMetadata.getSuraInfo(1);
if (!sura1) {
throw new Error("Sura info not found for sura 1");
}
return new versePosition_1.VersePosition(1, sura1.totalVerses); // (1, 7)
}
}
else {
if (this.direction === direction_1.Direction.Downwards) {
return bounds.getLowerBound();
}
else {
const upperBound = bounds.getUpperBound();
const lowerBound = bounds.getLowerBound();
if (upperBound.getVerse() === 1) {
// upper_bound starts at verse 1, so navigate to end of that sura
// (or lower_bound if end of sura is out of bounds)
const suraInfo = this.quranMetadata.getSuraInfo(upperBound.getSura());
if (!suraInfo) {
throw new Error(`Sura info not found for sura ${upperBound.getSura()}`);
}
const endOfSura = new versePosition_1.VersePosition(upperBound.getSura(), suraInfo.totalVerses);
if (this.isOutOfBounds(endOfSura)) {
return lowerBound;
}
else {
return endOfSura;
}
}
else if (upperBound.getSura() === lowerBound.getSura()) {
// Same sura, partial range: end at lower_bound
return lowerBound;
}
else {
// Multi-sura with specific upper_bound verse: stop exactly at upper_bound
return upperBound;
}
}
}
}
/**
* Reset the navigator to a specific verse position
*
* @param verse - Verse position to reset to
* @returns The current verse after reset, or undefined if not found or out of bounds
*/
resetPosition(verse) {
if (this.isOutOfBounds(verse)) {
return undefined;
}
const versePos = this.findVerse(verse);
if (versePos) {
const [_, page, idx] = versePos;
this.currentPageIdx = page - 1;
this.currentVerseIdx = idx;
return this.currentVerse();
}
return undefined;
}
/**
* Check if a verse position is out of bounds
*/
isOutOfBounds(verse) {
const verseResult = this.findVerse(verse);
if (!verseResult) {
return true;
}
const [verseObj] = verseResult;
const verseSura = verseObj.sura;
const verseNumber = verseObj.number;
const bounds = this.settings.getBounds();
const upperBound = bounds.getUpperBound();
const lowerBound = bounds.getLowerBound();
// Check if we're in excluding mode
if (bounds.isExcludingMode()) {
// In excluding mode: verse is out of bounds if it's within the excluded range
// (from lowerBound to upperBound, exclusive)
const versePos = new versePosition_1.VersePosition(verseSura, verseNumber);
return ((versePos.getSura() > lowerBound.getSura() ||
(versePos.getSura() === lowerBound.getSura() &&
versePos.getVerse() > lowerBound.getVerse())) &&
(versePos.getSura() < upperBound.getSura() ||
(versePos.getSura() === upperBound.getSura() &&
versePos.getVerse() < upperBound.getVerse())));
}
// Normal inclusive mode: verse is out of bounds if it's outside the range
const isOutOfBoundsSura = verseSura < upperBound.getSura() || verseSura > lowerBound.getSura();
if (isOutOfBoundsSura) {
return true;
}
// Check if bounds span the entire Quran (default bounds case)
// In this case, treat all verses as in bounds regardless of direction
const isFullQuran = upperBound.getSura() === 1 && lowerBound.getSura() === 114;
if (isFullQuran) {
return false;
}
// Verse sura is within bounds
// If both bounds are in the same sura, check both conditions
if (upperBound.getSura() === lowerBound.getSura() &&
verseSura === upperBound.getSura()) {
// Check if upper_bound.verse() exceeds sura length
const suraInfo = this.quranMetadata.getSuraInfo(verseSura);
if (suraInfo) {
const upperVerse = upperBound.getVerse() > suraInfo.totalVerses
? suraInfo.totalVerses
: upperBound.getVerse();
return (verseNumber < upperVerse ||
verseNumber > lowerBound.getVerse());
}
}
if (verseSura === upperBound.getSura()) {
// Check if upper_bound.verse() exceeds sura length
const suraInfo = this.quranMetadata.getSuraInfo(verseSura);
if (suraInfo && upperBound.getVerse() > suraInfo.totalVerses) {
// Upper bound exceeds sura length, so all verses in this sura are in bounds
return false;
}
// Direction-aware bounds for upper_bound sura:
// - Downward: verses < upper_bound.verse() are out of bounds
// - Upward: verses > upper_bound.verse() are out of bounds
// Special case: when upper_bound.verse() == 1, include entire sura
if (this.direction === direction_1.Direction.Downwards) {
return verseNumber < upperBound.getVerse();
}
else {
// Upwards
if (upperBound.getVerse() === 1) {
// When upper_bound starts at verse 1, include entire sura
return false;
}
return verseNumber > upperBound.getVerse();
}
}
if (verseSura === lowerBound.getSura()) {
// Direction-aware bounds for lower_bound sura:
// - Downward: verses > lower_bound.verse() are out of bounds
// Special case: when lower_bound.verse() == sura's last verse, include entire sura
// - Upward: verses < lower_bound.verse() are out of bounds
// Special case: when navigating upward, include entire lower_bound sura as starting range
if (this.direction === direction_1.Direction.Downwards) {
// Check if lower_bound is at the last verse of the sura
const suraInfo = this.quranMetadata.getSuraInfo(verseSura);
if (suraInfo && lowerBound.getVerse() >= suraInfo.totalVerses) {
// When lower_bound is at or beyond last verse, include entire sura
return false;
}
return verseNumber > lowerBound.getVerse();
}
else {
// Upwards: For upward navigation, allow starting from anywhere in the lower_bound sura
// The actual navigation will start from the specified position and move upward
return false;
}
}
return false;
}
/**
* Resets the iteration counter to a specific value.
*
* This method allows you to manually control the iteration count, which is useful
* for scenarios where you want to start from a specific iteration state or
* reset the counter after modifying bounds.
*
* @param iterations - New iteration count value
*/
resetIterations(iterations) {
this.iterationCount = iterations;
}
/**
* Get the current verse
*
* @returns The current verse
*/
currentVerse() {
const currentPageVerses = this.mushaf.pages[this.currentPageIdx].verses();
return currentPageVerses[this.currentVerseIdx];
}
/**
* Get the current navigation settings
*
* @returns The current navigation settings
*/
getSettings() {
return this.settings;
}
/**
* Moves to the next verse based on direction and iteration bounds.
*
* This is the core navigation method that handles iteration limits and bounded navigation.
* It automatically manages cycling through bounded ranges and respects iteration limits.
*
* # Iteration Behavior
*
* The method implements the following iteration logic:
* 1. Check if current position equals the `endBound`
* 2. If at end position and iterations remain: increment count and reset to `startBound`
* 3. If at end position and no iterations remain: return `undefined` (stop navigation)
* 4. Otherwise: move to next verse in the specified direction
* 5. In excluding mode: skip over excluded verses
*
* @returns The next verse, or undefined if reached end of bounds with no remaining iterations
*/
nextVerse() {
this.crossedBoundaries = false;
const startBound = this.getStartBound();
const endBound = this.getEndBound();
const remainingIterations = Math.max(0, this.settings.getBounds().getIterationLimit() - this.iterationCount);
// Before we move to the next verse, check if we have reached the lower bound
if (endBound.equalsVerse(this.currentVerse())) {
if (remainingIterations > 0) {
this.iterationCount += 1;
this.crossedBoundaries = true;
this.resetPosition(startBound);
return this.currentVerse();
}
else {
return undefined;
}
}
// Try to get the next verse
const previousPosition = versePosition_1.VersePosition.fromVerse(this.currentVerse());
const hasVerse = this.direction === direction_1.Direction.Downwards
? this.nextVerseDownward()
: this.nextVerseUpward();
if (hasVerse) {
// Check bounds after moving
if (!this.settings.getBounds().isExcludingMode()) {
// Inclusive mode: check if we moved out of bounds
if (this.isOutOfBounds(versePosition_1.VersePosition.fromVerse(this.currentVerse()))) {
// Roll back to previous position and return undefined
this.resetPosition(previousPosition);
return undefined;
}
}
else {
// Excluding mode: skip over excluded verses
while (true) {
const current = this.currentVerse();
const currentPos = versePosition_1.VersePosition.fromVerse(current);
if (!this.isOutOfBounds(currentPos)) {
break;
}
const moved = this.direction === direction_1.Direction.Downwards
? this.nextVerseDownward()
: this.nextVerseUpward();
if (!moved) {
return undefined;
}
}
}
return this.currentVerse();
}
return undefined;
}
/**
* Moves to the previous verse based on direction and iteration bounds.
*
* This is the inverse of `nextVerse()`. It handles iteration limits and bounded
* navigation when moving backward. When at the start bound with remaining
* iterations, it resets to the end bound; otherwise returns undefined.
*
* @returns The previous verse, or undefined if at start of bounds with no remaining iterations
*/
previousVerse() {
this.crossedBoundaries = false;
const startBound = this.getStartBound();
const endBound = this.getEndBound();
const remainingIterations = Math.max(0, this.settings.getBounds().getIterationLimit() - this.iterationCount);
// Before moving, check if we're at the start bound
if (startBound.equalsVerse(this.currentVerse())) {
if (remainingIterations > 0) {
this.iterationCount += 1;
this.crossedBoundaries = true;
this.resetPosition(endBound);
return this.currentVerse();
}
else {
return undefined;
}
}
// Move to previous verse based on direction
const previousPosition = versePosition_1.VersePosition.fromVerse(this.currentVerse());
const hasVerse = this.direction === direction_1.Direction.Downwards
? this.prevVerseDownward()
: this.prevVerseUpward();
if (hasVerse) {
// Check bounds after moving
if (!this.settings.getBounds().isExcludingMode()) {
// Inclusive mode: check if we moved out of bounds
if (this.isOutOfBounds(versePosition_1.VersePosition.fromVerse(this.currentVerse()))) {
// Roll back to previous position and return undefined
this.resetPosition(previousPosition);
return undefined;
}
}
else {
// Excluding mode: skip over excluded verses
while (true) {
const current = this.currentVerse();
const currentPos = versePosition_1.VersePosition.fromVerse(current);
if (!this.isOutOfBounds(currentPos)) {
break;
}
const moved = this.direction === direction_1.Direction.Downwards
? this.prevVerseDownward()
: this.prevVerseUpward();
if (!moved) {
return undefined;
}
}
}
return this.currentVerse();
}
return undefined;
}
/**
* Attempts to retreat to the previous verse within the current sura.
*
* This method tries to move backward by one verse while ensuring the navigator
* stays within the same sura. If the backward movement would cross into a
* different sura, the operation is rolled back and returns false.
*
* @returns True if successfully moved to previous verse in the same sura, false otherwise
*/
tryRetreatWithinCurrentSura() {
const preCurrentVerse = this.currentVerse();
const backwardMovement = this.backwardIndex(1) !== undefined;
if (backwardMovement) {
if (this.currentVerse().sura === preCurrentVerse.sura) {
return true;
}
// Rollback
this.forwardIndex(1);
}
return false;
}
/**
* Move to the previous verse in downward direction
*
* @returns True if successfully moved, false otherwise
*/
prevVerseDownward() {
return this.backwardIndex(1) !== undefined;
}
/**
* Move to the next sura (in upward navigation, "previous" verse means higher sura number)
*
* When at sura 114, wraps to sura 1. Goes to the last verse of the target sura.
*
* @param currentSura - Current sura number
* @returns The last verse of the next sura, or undefined if not found
*/
moveNextSura(currentSura) {
const targetSura = currentSura === 114 ? 1 : currentSura + 1;
const nextSura = this.quranMetadata.getSuraInfo(targetSura);
if (!nextSura)
return undefined;
const lastVersePosition = new versePosition_1.VersePosition(nextSura.number, nextSura.totalVerses);
const versePos = this.findVerse(lastVersePosition);
if (versePos) {
const [_, page, idx] = versePos;
this.currentPageIdx = page - 1;
this.currentVerseIdx = idx;
return this.currentVerse();
}
return undefined;
}
/**
* Move to the previous verse in upward direction
*
* @returns True if successfully moved, false otherwise
*/
prevVerseUpward() {
if (this.tryRetreatWithinCurrentSura()) {
return true;
}
return this.moveNextSura(this.currentVerse().sura) !== undefined;
}
/**
* Attempts to advance to the next verse within the current sura.
*
* This method tries to move forward by one verse while ensuring the navigator
* stays within the same sura. If the forward movement would cross into a
* different sura, the operation is rolled back and returns `false`.
*
* @returns True if successfully moved to next verse in the same sura, false otherwise
*/
tryAdvanceWithinCurrentSura() {
const preCurrentVerse = this.currentVerse();
const forwardMovement = this.forwardIndex(1);
if (forwardMovement) {
if (this.currentVerse().sura === preCurrentVerse.sura) {
return true;
}
// Rollback
this.backwardIndex(1);
}
return false;
}
/**
* Move to the next verse in downward direction
*
* @returns True if successfully moved, false otherwise
*/
nextVerseDownward() {
return this.forwardIndex(1) !== undefined;
}
/**
* Move to the previous sura
*
* @param currentSura - Current sura number
* @returns The first verse of the previous sura, or undefined if not found
*/
movePreSura(currentSura) {
// When at sura 1 in upward navigation, wrap to sura 114
const targetSura = currentSura === 1 ? 114 : currentSura - 1;
const preSura = this.quranMetadata.getSuraInfo(targetSura);
if (!preSura)
return undefined;
const firstPageIdx = preSura.startPage - 1;
const firstPage = this.mushaf.pages[firstPageIdx];
// Find first verse index
for (let idx = 0; idx < firstPage.verses().length; idx++) {
const verse = firstPage.verses()[idx];
if (verse.sura === preSura.number) {
this.currentPageIdx = firstPageIdx;
this.currentVerseIdx = idx;
return verse;
}
}
throw new Error("Unreachable code: Should have found a verse");
}
/**
* Move to the next verse in upward direction
*
* @returns True if successfully moved, false otherwise
*/
nextVerseUpward() {
if (this.tryAdvanceWithinCurrentSura()) {
return true;
}
try {
this.movePreSura(this.currentVerse().sura);
return true;
}
catch (e) {
return false;
}
}
/**
* Find a verse in the mushaf and return its location (Verse, page, index_of_verse)
*
* @param verse - Verse position to find
* @returns The verse and its location, or undefined if not found
*/
findVerse(verse) {
const sura = this.quranMetadata.getSuraInfo(verse.getSura());
if (sura) {
const pages = this.mushaf.pages;
for (let i = sura.startPage; i <= sura.endPage; i++) {
const page = pages[i - 1];
for (let j = 0; j < page.verses().length; j++) {
const verseObj = page.verses()[j];
if (verseObj.sura === verse.getSura() &&
verseObj.number === verse.getVerse()) {
return [verseObj, page.number, j];
}
}
}
}
return undefined;
}
/**
* Calculate the lines taken by a verse including any sura headers
*
* @param verse - The verse to calculate lines for
* @returns The total number of lines taken by the verse (rounded to 1 decimal place)
*/
calculateVerseLines(verse) {
let totalLines = verse.lines;
if (this.settings.getIgnoreSuraHeader()) {
return Math.round(totalLines * 10) / 10;
}
// Add lines for sura headers if this is the first verse of a sura
if (verse.number === 1) {
// All suras except At-Tawbah (9) have bismillah
if (verse.sura !== 9) {
totalLines += 2.0; // Sura title + bismillah
}
else {
totalLines += 1.0; // Only sura title for At-Tawbah
}
}
return Math.round(totalLines * 10) / 10;
}
/**
* Index navigation - move by a specific number of verses
*/
moveByIndex(index, backward) {
if (this.currentPageIdx >= this.mushaf.pages.length) {
return undefined;
}
let pageVerses = this.mushaf.pages[this.currentPageIdx].verses();
let remaining = index +
(backward
? pageVerses.length - 1 - this.currentVerseIdx
: this.currentVerseIdx);
while (true) {
if (pageVerses.length > remaining) {
// Verse is in this page
this.currentVerseIdx = backward
? Math.max(0, pageVerses.length - remaining - 1)
: remaining;
return pageVerses[this.currentVerseIdx];
}
remaining -= pageVerses.length;
// Move to next page
if (backward) {
if (this.currentPageIdx === 0) {
return undefined;
}
this.currentPageIdx -= 1;
}
else {
if (this.currentPageIdx === this.mushaf.pages.length - 1) {
return undefined;
}
this.currentPageIdx += 1;
}
pageVerses = this.mushaf.pages[this.currentPageIdx].verses();
}
}
/**
* Move forward by a specific number of verses
*
* @param index - Number of verses to move forward
* @returns The verse at the new position, or undefined if out of bounds
*/
forwardIndex(index) {
return this.moveByIndex(index, false);
}
/**
* Move backward by a specific number of verses
*
* @param index - Number of verses to move backward
* @returns The verse at the new position, or undefined if out of bounds
*/
backwardIndex(index) {
return this.moveByIndex(index, true);
}
}
exports.VersesNavigator = VersesNavigator;