#!/usr/bin/env python3 """ Love Letters — Display random historic love letters from Project Gutenberg. Reads pre-downloaded letter collections from the letters/ directory. Run download_letters.py first to populate the data. """ import json import os import random import re import sys import textwrap SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) LETTERS_DIR = os.path.join(SCRIPT_DIR, "letters") def load_all_letters(source_filter: str | None = None) -> list[dict]: """Load all letters from JSON files in the letters/ directory.""" if not os.path.isdir(LETTERS_DIR): return [] all_letters: list[dict] = [] for filename in sorted(os.listdir(LETTERS_DIR)): if not filename.endswith(".json"): continue source_id = filename[:-5] if source_filter and source_id != source_filter: continue path = os.path.join(LETTERS_DIR, filename) with open(path, "r", encoding="utf-8") as f: all_letters.extend(json.load(f)) return all_letters def available_sources() -> list[str]: """Return list of source IDs from the letters/ directory.""" if not os.path.isdir(LETTERS_DIR): return [] return sorted( f[:-5] for f in os.listdir(LETTERS_DIR) if f.endswith(".json") ) def wrap_text(text: str, width: int = 78) -> str: """Word-wrap text while preserving paragraph breaks.""" paragraphs = re.split(r"\n\s*\n", text) wrapped = [] for para in paragraphs: para = " ".join(para.split()) wrapped.append(textwrap.fill(para, width=width)) return "\n\n".join(wrapped) def truncate_letter(body: str, max_chars: int = 3000) -> str: """Truncate very long letters with an ellipsis note.""" if len(body) <= max_chars: return body truncated = body[:max_chars] last_period = truncated.rfind(".") if last_period > max_chars // 2: truncated = truncated[: last_period + 1] return truncated + "\n\n […letter continues…]" def display_letter(letter: dict) -> None: """Pretty-print a single love letter to the terminal.""" print() print(SEPARATOR) print(f" ✉ {letter['author']} → {letter['recipient']}") if letter.get("heading"): print(f" {letter['heading']}") print(f" ({letter['period']})") print(SEPARATOR) print() body = truncate_letter(letter["body"]) print(wrap_text(body)) print() print(SEPARATOR) print(f" Source: {letter['source']}") print(f" Via Project Gutenberg • gutenberg.org") print(SEPARATOR) print() SEPARATOR = "─" * 60 def list_sources() -> None: """Print available letter collections with counts.""" sources = available_sources() if not sources: print("\n No letter collections found. Run download_letters.py first.\n") return print("\n Available collections:\n") for i, source_id in enumerate(sources, 1): path = os.path.join(LETTERS_DIR, f"{source_id}.json") with open(path, "r", encoding="utf-8") as f: letters = json.load(f) if not letters: continue first = letters[0] print(f" {i:2}. [{source_id}]") print(f" {first['author']} → {first['recipient']} ({first['period']})") print(f" {first['source']} ({len(letters)} letters)") print() def main() -> None: import argparse sources = available_sources() parser = argparse.ArgumentParser( description="Display random historic love letters from Project Gutenberg.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ examples: %(prog)s Show a random love letter %(prog)s -n 3 Show 3 random love letters %(prog)s --list List available collections %(prog)s --source keats_brawne Show only Keats letters """), ) parser.add_argument( "-n", "--count", type=int, default=1, metavar="N", help="number of letters to display (default: 1)", ) parser.add_argument( "--list", action="store_true", help="list available letter collections", ) parser.add_argument( "--source", type=str, metavar="ID", choices=sources if sources else None, help="only show letters from a specific source", ) args = parser.parse_args() if args.list: list_sources() return all_letters = load_all_letters(source_filter=args.source) if not all_letters: if not sources: print(" No letter data found. Run download_letters.py first.") else: print(f" No letters found for source '{args.source}'.") sys.exit(1) count = min(args.count, len(all_letters)) chosen = random.sample(all_letters, count) for letter in chosen: display_letter(letter) if __name__ == "__main__": main()