UNPKG

@sahabaplus/mushaf-engine

Version:

TypeScript implementation of a Quran Mushaf navigation engine

675 lines (674 loc) 27.8 kB
"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;