"""Input validation utilities for Bible references.""" from __future__ import annotations import re from pathlib import Path from typing import TYPE_CHECKING from src.exceptions import InvalidReferenceError, VerseNotFoundError if TYPE_CHECKING: from src.data.loader import DataLoader # Valid book names (canonical list) # New Testament books NT_BOOKS = [ "matthew", "mark", "luke", "john", "acts", "romans", "1corinthians", "2corinthians", "galatians", "ephesians", "philippians", "colossians", "1thessalonians", "2thessalonians", "1timothy", "2timothy", "titus", "philemon", "hebrews", "james", "1peter", "2peter", "1john", "2john", "3john", "jude", "revelation", ] # Old Testament books OT_BOOKS = [ "genesis", "exodus", "leviticus", "numbers", "deuteronomy", "joshua", "judges", "ruth", "1samuel", "2samuel", "1kings", "2kings", "1chronicles", "2chronicles", "ezra", "nehemiah", "esther", "job", "psalms", "proverbs", "ecclesiastes", "songofsolomon", "isaiah", "jeremiah", "lamentations", "ezekiel", "daniel", "hosea", "joel", "amos", "obadiah", "jonah", "micah", "nahum", "habakkuk", "zephaniah", "haggai", "zechariah", "malachi", ] ALL_BOOKS = OT_BOOKS + NT_BOOKS # Book name aliases for common variations BOOK_ALIASES: dict[str, str] = { "gen": "genesis", "ex": "exodus", "exod": "exodus", "lev": "leviticus", "num": "numbers", "deut": "deuteronomy", "josh": "joshua", "judg": "judges", "1sam": "1samuel", "2sam": "2samuel", "1kgs": "1kings", "2kgs": "2kings", "1chr": "1chronicles", "2chr": "2chronicles", "neh": "nehemiah", "est": "esther", "ps": "psalms", "psa": "psalms", "psalm": "psalms", "prov": "proverbs", "eccl": "ecclesiastes", "song": "songofsolomon", "sos": "songofsolomon", "isa": "isaiah", "jer": "jeremiah", "lam": "lamentations", "ezek": "ezekiel", "dan": "daniel", "hos": "hosea", "ob": "obadiah", "mic": "micah", "nah": "nahum", "hab": "habakkuk", "zeph": "zephaniah", "hag": "haggai", "zech": "zechariah", "mal": "malachi", "matt": "matthew", "mat": "matthew", "mk": "mark", "lk": "luke", "jn": "john", "jhn": "john", "rom": "romans", "1cor": "1corinthians", "2cor": "2corinthians", "gal": "galatians", "eph": "ephesians", "phil": "philippians", "col": "colossians", "1thess": "1thessalonians", "2thess": "2thessalonians", "1tim": "1timothy", "2tim": "2timothy", "tit": "titus", "phm": "philemon", "phlm": "philemon", "heb": "hebrews", "jas": "james", "1pet": "1peter", "2pet": "2peter", "1jn": "1john", "2jn": "2john", "3jn": "3john", "rev": "revelation", } def normalize_book_name(book: str) -> str: """Normalize a book name to its canonical form. Args: book: Book name (may be alias or variant). Returns: Canonical lowercase book name. Raises: InvalidReferenceError: If book name is not recognized. """ book_lower = book.lower().strip() # Remove common separators book_lower = book_lower.replace(" ", "").replace("-", "").replace("_", "") # Check if it's already canonical if book_lower in ALL_BOOKS: return book_lower # Check aliases if book_lower in BOOK_ALIASES: return BOOK_ALIASES[book_lower] raise InvalidReferenceError(book, f"Unknown book name: {book}") def validate_chapter(book: str, chapter: int) -> None: """Validate that a chapter number is valid for a book. Args: book: Canonical book name. chapter: Chapter number. Raises: InvalidReferenceError: If chapter is invalid. """ if chapter < 1: raise InvalidReferenceError( f"{book} {chapter}", f"Chapter must be positive, got {chapter}", ) # Maximum chapters per book (approximate, will be validated against data) max_chapters = { "genesis": 50, "exodus": 40, "leviticus": 27, "numbers": 36, "deuteronomy": 34, "joshua": 24, "judges": 21, "ruth": 4, "1samuel": 31, "2samuel": 24, "1kings": 22, "2kings": 25, "1chronicles": 29, "2chronicles": 36, "ezra": 10, "nehemiah": 13, "esther": 10, "job": 42, "psalms": 150, "proverbs": 31, "ecclesiastes": 12, "songofsolomon": 8, "isaiah": 66, "jeremiah": 52, "lamentations": 5, "ezekiel": 48, "daniel": 12, "hosea": 14, "joel": 3, "amos": 9, "obadiah": 1, "jonah": 4, "micah": 7, "nahum": 3, "habakkuk": 3, "zephaniah": 3, "haggai": 2, "zechariah": 14, "malachi": 4, "matthew": 28, "mark": 16, "luke": 24, "john": 21, "acts": 28, "romans": 16, "1corinthians": 16, "2corinthians": 13, "galatians": 6, "ephesians": 6, "philippians": 4, "colossians": 4, "1thessalonians": 5, "2thessalonians": 3, "1timothy": 6, "2timothy": 4, "titus": 3, "philemon": 1, "hebrews": 13, "james": 5, "1peter": 5, "2peter": 3, "1john": 5, "2john": 1, "3john": 1, "jude": 1, "revelation": 22, } book_lower = book.lower() if book_lower in max_chapters and chapter > max_chapters[book_lower]: raise InvalidReferenceError( f"{book} {chapter}", f"{book} only has {max_chapters[book_lower]} chapters", ) def validate_verse(book: str, chapter: int, verse: int) -> None: """Validate that a verse number is plausible. Args: book: Canonical book name. chapter: Chapter number. verse: Verse number. Raises: InvalidReferenceError: If verse is obviously invalid. """ if verse < 1: raise InvalidReferenceError( f"{book} {chapter}:{verse}", f"Verse must be positive, got {verse}", ) # Most chapters have fewer than 200 verses (Psalm 119 has 176) if verse > 200: raise InvalidReferenceError( f"{book} {chapter}:{verse}", f"Verse number {verse} is implausibly high", ) def validate_reference(book: str, chapter: int, verse: int) -> str: """Validate a complete verse reference. Args: book: Book name (may be alias). chapter: Chapter number. verse: Verse number. Returns: Canonical book name. Raises: InvalidReferenceError: If reference is invalid. """ canonical_book = normalize_book_name(book) validate_chapter(canonical_book, chapter) validate_verse(canonical_book, chapter, verse) return canonical_book def validate_reference_exists( loader: DataLoader, book: str, chapter: int, verse: int, ) -> None: """Validate that a verse actually exists in the data. Args: loader: Data loader to check against. book: Canonical book name. chapter: Chapter number. verse: Verse number. Raises: VerseNotFoundError: If verse doesn't exist in data. """ # Load the verse and check if it has any content from src.models.verse import VerseReference ref = VerseReference(book=book, chapter=chapter, verse=verse) loaded = loader.load_verse(ref) # Check if any source text is available has_content = ( loaded.greek_text or loaded.hebrew_text or loaded.english_kjv or loaded.english_asv or loaded.japanese_meiji or loaded.japanese_taisho ) if not has_content: raise VerseNotFoundError(book, chapter, verse) def parse_reference_string(ref_str: str) -> tuple[str, int, int]: """Parse a reference string like 'John 3:16' into components. Supports formats: - 'John 3:16' - 'john 3:16' - 'john:3:16' - 'jn 3:16' - '1john 1:1' - '1 john 1:1' Args: ref_str: Reference string to parse. Returns: Tuple of (book, chapter, verse). Raises: InvalidReferenceError: If format is invalid. """ ref_str = ref_str.strip() # Pattern for references like "John 3:16" or "1 John 1:1" pattern = r"^(\d?\s*[a-zA-Z]+)\s+(\d+):(\d+)$" match = re.match(pattern, ref_str) if match: book_part = match.group(1).strip() chapter = int(match.group(2)) verse = int(match.group(3)) book = normalize_book_name(book_part) return book, chapter, verse # Try alternate pattern "book:chapter:verse" pattern2 = r"^(\d?\s*[a-zA-Z]+):(\d+):(\d+)$" match2 = re.match(pattern2, ref_str) if match2: book_part = match2.group(1).strip() chapter = int(match2.group(2)) verse = int(match2.group(3)) book = normalize_book_name(book_part) return book, chapter, verse raise InvalidReferenceError( ref_str, "Expected format like 'John 3:16' or 'john:3:16'", ) def sanitize_book_name_for_path(book: str) -> str: """Sanitize a book name for safe use in file paths. Prevents directory traversal attacks. Args: book: Book name to sanitize. Returns: Safe book name for paths. Raises: InvalidReferenceError: If book name contains unsafe characters. """ canonical = normalize_book_name(book) # Additional safety check: ensure no path separators if "/" in canonical or "\\" in canonical or ".." in canonical: raise InvalidReferenceError(book, "Book name contains invalid path characters") return canonical def is_valid_book(book: str) -> bool: """Check if a book name is valid without raising an exception. Args: book: Book name to check. Returns: True if valid, False otherwise. """ try: normalize_book_name(book) return True except InvalidReferenceError: return False