|
|
|
|
#!/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()
|