You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

154 lines
4.5 KiB
Python

#!/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 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()
print(wrap_text(letter["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()