- Wire blueprints and scheduler into create_app() - Add start_scheduler param to skip scheduler in tests - Fix Setting.get/set to use modern db.session.get() - Remove unused imports from conftest and models - Add README with quick start and usage guide Made-with: Cursor
77 KiB
Plymouth Independent Weekly Newspaper — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a self-hosted Python web app that periodically fetches articles from the Plymouth Independent RSS feed and compiles them into weekly ePub newspapers optimized for the Xtreink X4 e-reader.
Architecture: Flask web app with SQLite (via SQLAlchemy) for persistence, APScheduler for background jobs, ebooklib for ePub generation, Pillow for image processing, and Pollinations.ai for AI cover generation. Single-process deployment — run with python app.py.
Tech Stack: Python 3.11+, Flask, Flask-SQLAlchemy, APScheduler, feedparser, ebooklib, beautifulsoup4, Pillow, requests, Pico CSS
File Structure
pi-weekly-newspaper/
├── app.py # Flask app factory, scheduler init, route registration
├── config.py # Default config constants
├── requirements.txt # Python dependencies
├── src/
│ ├── __init__.py
│ ├── models.py # SQLAlchemy models: Article, Image, Issue, Setting
│ ├── images.py # Download + resize images to e-reader constraints
│ ├── fetcher.py # RSS fetch, parse, cache articles + images
│ ├── cover.py # Cover generation (Pollinations.ai + text fallback)
│ ├── epub_builder.py # Assemble ePub from articles + images + cover
│ ├── scheduler.py # APScheduler job management
│ └── routes/
│ ├── __init__.py # Blueprint registration helper
│ ├── dashboard.py # GET / — dashboard
│ ├── articles.py # GET /articles — article browser
│ ├── publish.py # GET/POST /publish — issue creation
│ ├── settings.py # GET/POST /settings — app config
│ └── issues.py # GET /issues, GET /issues/<id>/download
├── templates/
│ ├── base.html # Layout: nav, Pico CSS, common JS
│ ├── dashboard.html
│ ├── articles.html
│ ├── publish.html
│ ├── settings.html
│ └── issues.html
├── static/
│ └── style.css # Minimal overrides on top of Pico CSS
├── tests/
│ ├── conftest.py # Fixtures: test app, test DB, sample RSS XML
│ ├── test_models.py
│ ├── test_images.py
│ ├── test_fetcher.py
│ ├── test_cover.py
│ ├── test_epub_builder.py
│ ├── test_scheduler.py
│ └── test_routes.py
├── data/ # Runtime data (gitignored)
│ ├── newspaper.db
│ ├── images/
│ └── issues/
└── README.md
Task 1: Project Scaffold
Files:
-
Create:
requirements.txt -
Create:
config.py -
Create:
src/__init__.py -
Create:
app.py(skeleton) -
Create:
.gitignore -
Create:
tests/conftest.py -
Step 1: Create
requirements.txt
Flask==3.1.*
Flask-SQLAlchemy==3.1.*
APScheduler==3.10.*
feedparser==6.0.*
ebooklib==0.18.*
beautifulsoup4==4.12.*
Pillow==11.*
requests==2.32.*
pytest==8.*
- Step 2: Create
.gitignore
data/
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
- Step 3: Create
config.py
import os
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
IMAGES_DIR = os.path.join(DATA_DIR, "images")
ISSUES_DIR = os.path.join(DATA_DIR, "issues")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(DATA_DIR, 'newspaper.db')}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
FEED_URL = "https://www.plymouthindependent.org/feed/"
FETCH_INTERVAL_HOURS = 1
IMAGE_MAX_LANDSCAPE = (800, 480)
IMAGE_MAX_PORTRAIT = (480, 800)
POLLINATIONS_URL = "https://image.pollinations.ai/prompt/{prompt}?width=800&height=480&nologo=true"
- Step 4: Create
src/__init__.py
(Empty file, makes src a package.)
- Step 5: Create skeleton
app.py
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import config
db = SQLAlchemy()
def create_app():
app = Flask(__name__)
app.config.from_object(config)
app.config["SECRET_KEY"] = os.urandom(24)
os.makedirs(config.DATA_DIR, exist_ok=True)
os.makedirs(config.IMAGES_DIR, exist_ok=True)
os.makedirs(config.ISSUES_DIR, exist_ok=True)
db.init_app(app)
with app.app_context():
from src import models # noqa: F401
db.create_all()
return app
if __name__ == "__main__":
app = create_app()
app.run(host="0.0.0.0", port=5000, debug=True)
- Step 6: Create
tests/conftest.py
import os
import tempfile
import pytest
from app import create_app, db as _db
import config
@pytest.fixture
def app(tmp_path):
config.DATA_DIR = str(tmp_path / "data")
config.IMAGES_DIR = str(tmp_path / "data" / "images")
config.ISSUES_DIR = str(tmp_path / "data" / "issues")
config.SQLALCHEMY_DATABASE_URI = f"sqlite:///{tmp_path / 'test.db'}"
app = create_app()
app.config["TESTING"] = True
with app.app_context():
_db.create_all()
yield app
_db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def db(app):
with app.app_context():
yield _db
SAMPLE_RSS_XML = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Plymouth Independent</title>
<item>
<title>Test Article One</title>
<link>https://example.com/article-1</link>
<dc:creator><![CDATA[Test Author]]></dc:creator>
<pubDate>Mon, 06 Apr 2026 12:00:00 +0000</pubDate>
<category><![CDATA[Government]]></category>
<guid isPermaLink="false">https://example.com/?p=1001</guid>
<content:encoded><![CDATA[
<p>First article content.</p>
<img src="https://example.com/image1.jpg" width="1024" height="768" />
]]></content:encoded>
</item>
<item>
<title>Test Article Two</title>
<link>https://example.com/article-2</link>
<dc:creator><![CDATA[Another Author]]></dc:creator>
<pubDate>Tue, 07 Apr 2026 09:00:00 +0000</pubDate>
<category><![CDATA[Culture Calendar]]></category>
<category><![CDATA[Feature]]></category>
<guid isPermaLink="false">https://example.com/?p=1002</guid>
<content:encoded><![CDATA[
<p>Second article content.</p>
]]></content:encoded>
</item>
</channel>
</rss>"""
- Step 7: Install dependencies and verify skeleton runs
cd /Users/erikfredericks/dev-ai/one-offs/pi-weekly-newspaper
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python -c "from app import create_app; app = create_app(); print('OK')"
Expected: prints OK, creates data/ directory with empty newspaper.db.
- Step 8: Run empty test suite
pytest tests/ -v
Expected: no tests collected, exit 0.
- Step 9: Initialize git and commit
git init
git add -A
git commit -m "scaffold: project structure, config, Flask app factory, test fixtures"
Task 2: Data Models
Files:
-
Create:
src/models.py -
Create:
tests/test_models.py -
Step 1: Write model tests
# tests/test_models.py
import json
from datetime import datetime, date
from src.models import Article, Image, Issue, Setting
def test_create_article(db):
article = Article(
guid="https://example.com/?p=100",
title="Test Article",
author="Test Author",
pub_date=datetime(2026, 4, 6, 12, 0, 0),
categories=json.dumps(["Government"]),
link="https://example.com/test",
content_html="<p>Test content</p>",
)
db.session.add(article)
db.session.commit()
saved = Article.query.filter_by(guid="https://example.com/?p=100").first()
assert saved is not None
assert saved.title == "Test Article"
assert saved.author == "Test Author"
assert json.loads(saved.categories) == ["Government"]
assert saved.fetched_at is not None
def test_article_guid_unique(db):
a1 = Article(guid="dup", title="A", author="X", pub_date=datetime.now(),
categories="[]", link="http://a", content_html="")
a2 = Article(guid="dup", title="B", author="Y", pub_date=datetime.now(),
categories="[]", link="http://b", content_html="")
db.session.add(a1)
db.session.commit()
db.session.add(a2)
try:
db.session.commit()
assert False, "Should have raised IntegrityError"
except Exception:
db.session.rollback()
def test_create_image(db):
article = Article(guid="img-test", title="A", author="X",
pub_date=datetime.now(), categories="[]",
link="http://a", content_html="")
db.session.add(article)
db.session.commit()
img = Image(
article_id=article.id,
original_url="https://example.com/photo.jpg",
local_path="data/images/abc123.jpg",
width=800,
height=450,
)
db.session.add(img)
db.session.commit()
assert img.id is not None
assert img.article.guid == "img-test"
def test_create_issue(db):
issue = Issue(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
cover_method="text",
cover_path="data/issues/cover.jpg",
epub_path="data/issues/test.epub",
article_ids=json.dumps([1, 2, 3]),
excluded_article_ids=json.dumps([]),
status="published",
)
db.session.add(issue)
db.session.commit()
assert issue.id is not None
assert issue.created_at is not None
def test_setting_crud(db):
Setting.set("fetch_interval", 2)
assert Setting.get("fetch_interval") == 2
assert Setting.get("nonexistent", default="fallback") == "fallback"
Setting.set("fetch_interval", 4)
assert Setting.get("fetch_interval") == 4
- Step 2: Run tests to verify they fail
pytest tests/test_models.py -v
Expected: ImportError — src.models does not exist yet.
- Step 3: Implement
src/models.py
# src/models.py
import json
from datetime import datetime, date, timezone
from app import db
class Article(db.Model):
__tablename__ = "articles"
id = db.Column(db.Integer, primary_key=True)
guid = db.Column(db.Text, unique=True, nullable=False)
title = db.Column(db.Text, nullable=False)
author = db.Column(db.Text, nullable=False)
pub_date = db.Column(db.DateTime, nullable=False)
categories = db.Column(db.Text, nullable=False, default="[]")
link = db.Column(db.Text, nullable=False)
content_html = db.Column(db.Text, nullable=False, default="")
fetched_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
)
images = db.relationship("Image", backref="article", lazy=True,
cascade="all, delete-orphan")
class Image(db.Model):
__tablename__ = "images"
id = db.Column(db.Integer, primary_key=True)
article_id = db.Column(db.Integer, db.ForeignKey("articles.id"), nullable=False)
original_url = db.Column(db.Text, nullable=False)
local_path = db.Column(db.Text, nullable=False)
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
class Issue(db.Model):
__tablename__ = "issues"
id = db.Column(db.Integer, primary_key=True)
week_start = db.Column(db.Date, nullable=False)
week_end = db.Column(db.Date, nullable=False)
cover_method = db.Column(db.Text, nullable=False)
cover_path = db.Column(db.Text, nullable=False)
epub_path = db.Column(db.Text, nullable=False)
article_ids = db.Column(db.Text, nullable=False, default="[]")
excluded_article_ids = db.Column(db.Text, nullable=False, default="[]")
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
)
status = db.Column(db.Text, nullable=False, default="draft")
class Setting(db.Model):
__tablename__ = "settings"
key = db.Column(db.Text, primary_key=True)
value = db.Column(db.Text, nullable=False)
@staticmethod
def get(key, default=None):
row = Setting.query.get(key)
if row is None:
return default
return json.loads(row.value)
@staticmethod
def set(key, value):
row = Setting.query.get(key)
if row is None:
row = Setting(key=key, value=json.dumps(value))
db.session.add(row)
else:
row.value = json.dumps(value)
db.session.commit()
- Step 4: Run tests to verify they pass
pytest tests/test_models.py -v
Expected: all 5 tests PASS.
- Step 5: Commit
git add -A
git commit -m "feat: SQLAlchemy models for Article, Image, Issue, Setting"
Task 3: Image Processing
Files:
-
Create:
src/images.py -
Create:
tests/test_images.py -
Step 1: Write image processing tests
# tests/test_images.py
import os
from io import BytesIO
from unittest.mock import patch, MagicMock
from PIL import Image as PILImage
from src.images import process_image, _resize_to_fit
def _make_test_image(width, height, fmt="JPEG"):
img = PILImage.new("RGB", (width, height), color="red")
buf = BytesIO()
img.save(buf, format=fmt)
buf.seek(0)
return buf.read()
def test_resize_landscape_downscale():
img = PILImage.new("RGB", (1600, 900))
result = _resize_to_fit(img)
assert result.width <= 800
assert result.height <= 480
assert result.width / result.height == pytest.approx(1600 / 900, rel=0.02)
def test_resize_portrait_downscale():
img = PILImage.new("RGB", (600, 1200))
result = _resize_to_fit(img)
assert result.width <= 480
assert result.height <= 800
assert result.width / result.height == pytest.approx(600 / 1200, rel=0.02)
def test_resize_small_image_upscales():
img = PILImage.new("RGB", (200, 100))
result = _resize_to_fit(img)
assert result.width > 200
assert result.width <= 800
assert result.height <= 480
def test_resize_already_fits():
img = PILImage.new("RGB", (800, 480))
result = _resize_to_fit(img)
assert result.size == (800, 480)
def test_process_image_downloads_and_saves(tmp_path):
image_bytes = _make_test_image(1024, 768)
mock_response = MagicMock()
mock_response.content = image_bytes
mock_response.raise_for_status = MagicMock()
with patch("src.images.requests.get", return_value=mock_response):
path, w, h = process_image(
"https://example.com/photo.jpg", str(tmp_path)
)
assert os.path.exists(path)
assert path.endswith(".jpg")
assert w <= 800
assert h <= 480
saved = PILImage.open(path)
assert saved.format == "JPEG"
assert getattr(saved, "progressive", False) is False
def test_process_image_dedup(tmp_path):
image_bytes = _make_test_image(500, 300)
mock_response = MagicMock()
mock_response.content = image_bytes
mock_response.raise_for_status = MagicMock()
with patch("src.images.requests.get", return_value=mock_response) as mock_get:
path1, _, _ = process_image("https://example.com/same.jpg", str(tmp_path))
path2, _, _ = process_image("https://example.com/same.jpg", str(tmp_path))
assert path1 == path2
assert mock_get.call_count == 1
import pytest
- Step 2: Run tests to verify they fail
pytest tests/test_images.py -v
Expected: ImportError — src.images does not exist yet.
- Step 3: Implement
src/images.py
# src/images.py
import hashlib
import os
import logging
import requests
from PIL import Image as PILImage
import config
logger = logging.getLogger(__name__)
def _url_hash(url: str) -> str:
return hashlib.sha256(url.encode()).hexdigest()[:16]
def _resize_to_fit(img: PILImage.Image) -> PILImage.Image:
w, h = img.size
if w >= h:
max_w, max_h = config.IMAGE_MAX_LANDSCAPE
else:
max_w, max_h = config.IMAGE_MAX_PORTRAIT
scale = min(max_w / w, max_h / h)
new_w = int(w * scale)
new_h = int(h * scale)
if new_w == w and new_h == h:
return img
return img.resize((new_w, new_h), PILImage.Resampling.LANCZOS)
def process_image(url: str, output_dir: str) -> tuple[str, int, int]:
"""Download an image, resize it, save as baseline JPEG.
Returns (local_path, width, height). Deduplicates by URL hash.
"""
os.makedirs(output_dir, exist_ok=True)
filename = f"{_url_hash(url)}.jpg"
local_path = os.path.join(output_dir, filename)
if os.path.exists(local_path):
img = PILImage.open(local_path)
return local_path, img.width, img.height
response = requests.get(url, timeout=30)
response.raise_for_status()
from io import BytesIO
img = PILImage.open(BytesIO(response.content))
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
img = _resize_to_fit(img)
img.save(local_path, format="JPEG", progressive=False, quality=85)
return local_path, img.width, img.height
- Step 4: Run tests to verify they pass
pytest tests/test_images.py -v
Expected: all 7 tests PASS.
- Step 5: Commit
git add -A
git commit -m "feat: image download, resize-to-fit, baseline JPEG conversion"
Task 4: RSS Fetcher
Files:
-
Create:
src/fetcher.py -
Create:
tests/test_fetcher.py -
Step 1: Write fetcher tests
# tests/test_fetcher.py
import json
from unittest.mock import patch, MagicMock
from src.fetcher import fetch_and_cache_articles
from src.models import Article, Image
from tests.conftest import SAMPLE_RSS_XML
def _mock_feed_response(xml_content):
mock = MagicMock()
mock.content = xml_content.encode("utf-8")
mock.text = xml_content
mock.status_code = 200
mock.raise_for_status = MagicMock()
return mock
def test_fetch_creates_articles(app, db):
with app.app_context():
with patch("src.fetcher.requests.get") as mock_get:
mock_get.return_value = _mock_feed_response(SAMPLE_RSS_XML)
with patch("src.fetcher.process_image") as mock_img:
mock_img.return_value = ("/fake/path.jpg", 800, 450)
result = fetch_and_cache_articles()
assert result["new"] == 2
assert result["skipped"] == 0
articles = Article.query.order_by(Article.pub_date).all()
assert len(articles) == 2
assert articles[0].title == "Test Article One"
assert articles[1].title == "Test Article Two"
def test_fetch_deduplicates(app, db):
with app.app_context():
with patch("src.fetcher.requests.get") as mock_get:
mock_get.return_value = _mock_feed_response(SAMPLE_RSS_XML)
with patch("src.fetcher.process_image") as mock_img:
mock_img.return_value = ("/fake/path.jpg", 800, 450)
fetch_and_cache_articles()
result = fetch_and_cache_articles()
assert result["new"] == 0
assert result["skipped"] == 2
assert Article.query.count() == 2
def test_fetch_downloads_images(app, db):
with app.app_context():
with patch("src.fetcher.requests.get") as mock_get:
mock_get.return_value = _mock_feed_response(SAMPLE_RSS_XML)
with patch("src.fetcher.process_image") as mock_img:
mock_img.return_value = ("/fake/path.jpg", 800, 450)
fetch_and_cache_articles()
images = Image.query.all()
assert len(images) == 1
assert images[0].original_url == "https://example.com/image1.jpg"
def test_fetch_rewrites_image_src(app, db):
with app.app_context():
with patch("src.fetcher.requests.get") as mock_get:
mock_get.return_value = _mock_feed_response(SAMPLE_RSS_XML)
with patch("src.fetcher.process_image") as mock_img:
mock_img.return_value = ("/fake/path.jpg", 800, 450)
fetch_and_cache_articles()
article = Article.query.filter_by(
guid="https://example.com/?p=1001"
).first()
assert "https://example.com/image1.jpg" not in article.content_html
assert "/fake/path.jpg" in article.content_html
def test_fetch_handles_feed_error(app, db):
with app.app_context():
with patch("src.fetcher.requests.get") as mock_get:
mock_get.side_effect = Exception("Network error")
result = fetch_and_cache_articles()
assert result["error"] is not None
assert Article.query.count() == 0
- Step 2: Run tests to verify they fail
pytest tests/test_fetcher.py -v
Expected: ImportError — src.fetcher does not exist yet.
- Step 3: Implement
src/fetcher.py
# src/fetcher.py
import json
import logging
from datetime import datetime, timezone
import feedparser
import requests
from bs4 import BeautifulSoup
from email.utils import parsedate_to_datetime
import config
from app import db
from src.models import Article, Image
from src.images import process_image
logger = logging.getLogger(__name__)
def fetch_and_cache_articles() -> dict:
"""Fetch RSS feed and cache new articles. Returns stats dict."""
stats = {"new": 0, "skipped": 0, "errors": 0, "error": None}
try:
response = requests.get(config.FEED_URL, timeout=30)
response.raise_for_status()
except Exception as e:
logger.error("Failed to fetch RSS feed: %s", e)
stats["error"] = str(e)
return stats
feed = feedparser.parse(response.text)
for entry in feed.entries:
guid = entry.get("id", entry.get("link", ""))
if not guid:
continue
existing = Article.query.filter_by(guid=guid).first()
if existing:
stats["skipped"] += 1
continue
try:
pub_date = parsedate_to_datetime(entry.get("published", ""))
except Exception:
pub_date = datetime.now(timezone.utc)
categories = [t.term for t in entry.get("tags", [])]
content_html = ""
if entry.get("content"):
content_html = entry.content[0].get("value", "")
elif entry.get("summary"):
content_html = entry.summary
article = Article(
guid=guid,
title=entry.get("title", "Untitled"),
author=entry.get("author", "Unknown"),
pub_date=pub_date,
categories=json.dumps(categories),
link=entry.get("link", ""),
content_html=content_html,
)
db.session.add(article)
db.session.flush()
soup = BeautifulSoup(content_html, "html.parser")
for img_tag in soup.find_all("img"):
src = img_tag.get("src")
if not src or not src.startswith("http"):
continue
try:
local_path, w, h = process_image(src, config.IMAGES_DIR)
image_record = Image(
article_id=article.id,
original_url=src,
local_path=local_path,
width=w,
height=h,
)
db.session.add(image_record)
img_tag["src"] = local_path
except Exception as e:
logger.warning("Failed to process image %s: %s", src, e)
stats["errors"] += 1
article.content_html = str(soup)
db.session.commit()
stats["new"] += 1
return stats
- Step 4: Run tests to verify they pass
pytest tests/test_fetcher.py -v
Expected: all 5 tests PASS.
- Step 5: Commit
git add -A
git commit -m "feat: RSS fetcher with dedup, image download, HTML rewriting"
Task 5: Cover Generation
Files:
-
Create:
src/cover.py -
Create:
tests/test_cover.py -
Step 1: Write cover generation tests
# tests/test_cover.py
import os
from datetime import date
from unittest.mock import patch, MagicMock
from io import BytesIO
from PIL import Image as PILImage
from src.cover import generate_text_cover, generate_ai_cover, generate_cover
def test_text_cover_creates_jpeg(tmp_path):
path = generate_text_cover(
output_dir=str(tmp_path),
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
headlines=["Article One", "Article Two", "Article Three"],
)
assert os.path.exists(path)
img = PILImage.open(path)
assert img.format == "JPEG"
assert img.width <= 800
assert img.height <= 480
def test_ai_cover_creates_jpeg(tmp_path):
fake_img = PILImage.new("RGB", (800, 480), color="blue")
buf = BytesIO()
fake_img.save(buf, format="JPEG")
fake_bytes = buf.getvalue()
mock_response = MagicMock()
mock_response.content = fake_bytes
mock_response.raise_for_status = MagicMock()
with patch("src.cover.requests.get", return_value=mock_response):
path = generate_ai_cover(
output_dir=str(tmp_path),
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
headlines=["Test Headline"],
)
assert os.path.exists(path)
img = PILImage.open(path)
assert img.format == "JPEG"
assert img.width <= 800
assert img.height <= 480
def test_ai_cover_falls_back_on_failure(tmp_path):
with patch("src.cover.requests.get", side_effect=Exception("API down")):
path = generate_ai_cover(
output_dir=str(tmp_path),
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
headlines=["Test"],
)
assert os.path.exists(path)
img = PILImage.open(path)
assert img.format == "JPEG"
def test_generate_cover_dispatches(tmp_path):
with patch("src.cover.generate_ai_cover") as mock_ai:
mock_ai.return_value = "/fake/ai.jpg"
result = generate_cover("ai", str(tmp_path), date(2026, 4, 6),
date(2026, 4, 12), ["A"])
assert result == "/fake/ai.jpg"
mock_ai.assert_called_once()
with patch("src.cover.generate_text_cover") as mock_text:
mock_text.return_value = "/fake/text.jpg"
result = generate_cover("text", str(tmp_path), date(2026, 4, 6),
date(2026, 4, 12), ["A"])
assert result == "/fake/text.jpg"
mock_text.assert_called_once()
- Step 2: Run tests to verify they fail
pytest tests/test_cover.py -v
Expected: ImportError — src.cover does not exist yet.
- Step 3: Implement
src/cover.py
# src/cover.py
import logging
import os
from datetime import date
from io import BytesIO
from urllib.parse import quote
import requests
from PIL import Image as PILImage, ImageDraw, ImageFont
import config
from src.images import _resize_to_fit
logger = logging.getLogger(__name__)
def _get_font(size: int) -> ImageFont.FreeTypeFont:
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size)
except OSError:
pass
try:
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
except OSError:
pass
return ImageFont.load_default()
def generate_text_cover(
output_dir: str,
week_start: date,
week_end: date,
headlines: list[str],
) -> str:
os.makedirs(output_dir, exist_ok=True)
img = PILImage.new("RGB", (800, 480), color="white")
draw = ImageDraw.Draw(img)
title_font = _get_font(36)
date_font = _get_font(20)
headline_font = _get_font(16)
draw.text((400, 30), "Plymouth Independent", fill="black",
font=title_font, anchor="mt")
date_str = f"Week of {week_start.strftime('%b %d')} – {week_end.strftime('%b %d, %Y')}"
draw.text((400, 80), date_str, fill="gray", font=date_font, anchor="mt")
draw.line([(50, 110), (750, 110)], fill="black", width=2)
y = 130
for i, headline in enumerate(headlines[:8]):
if y > 440:
break
prefix = f"• {headline}"
if len(prefix) > 70:
prefix = prefix[:67] + "..."
draw.text((60, y), prefix, fill="black", font=headline_font)
y += 35
filename = f"cover-{week_start.isoformat()}-text.jpg"
path = os.path.join(output_dir, filename)
img.save(path, format="JPEG", progressive=False, quality=90)
return path
def generate_ai_cover(
output_dir: str,
week_start: date,
week_end: date,
headlines: list[str],
) -> str:
os.makedirs(output_dir, exist_ok=True)
top_headlines = ", ".join(headlines[:3])
prompt = (
f"Newspaper front page illustration for Plymouth Massachusetts local news. "
f"Headlines: {top_headlines}. "
f"Classic broadsheet newspaper style, black and white ink drawing, editorial illustration."
)
url = config.POLLINATIONS_URL.format(prompt=quote(prompt))
try:
response = requests.get(url, timeout=60)
response.raise_for_status()
img = PILImage.open(BytesIO(response.content))
if img.mode != "RGB":
img = img.convert("RGB")
img = _resize_to_fit(img)
draw = ImageDraw.Draw(img)
title_font = _get_font(28)
date_font = _get_font(16)
draw.text((img.width // 2, 15), "Plymouth Independent",
fill="white", font=title_font, anchor="mt",
stroke_width=2, stroke_fill="black")
date_str = f"Week of {week_start.strftime('%b %d')} – {week_end.strftime('%b %d, %Y')}"
draw.text((img.width // 2, 50), date_str,
fill="white", font=date_font, anchor="mt",
stroke_width=1, stroke_fill="black")
filename = f"cover-{week_start.isoformat()}-ai.jpg"
path = os.path.join(output_dir, filename)
img.save(path, format="JPEG", progressive=False, quality=90)
return path
except Exception as e:
logger.error("AI cover generation failed, falling back to text: %s", e)
return generate_text_cover(output_dir, week_start, week_end, headlines)
def generate_cover(
method: str,
output_dir: str,
week_start: date,
week_end: date,
headlines: list[str],
) -> str:
if method == "ai":
return generate_ai_cover(output_dir, week_start, week_end, headlines)
return generate_text_cover(output_dir, week_start, week_end, headlines)
- Step 4: Run tests to verify they pass
pytest tests/test_cover.py -v
Expected: all 4 tests PASS.
- Step 5: Commit
git add -A
git commit -m "feat: cover generation with Pollinations.ai and text fallback"
Task 6: ePub Builder
Files:
-
Create:
src/epub_builder.py -
Create:
tests/test_epub_builder.py -
Step 1: Write ePub builder tests
# tests/test_epub_builder.py
import json
import os
from datetime import datetime, date
from unittest.mock import patch
from PIL import Image as PILImage
from src.models import Article, Image
from src.epub_builder import build_epub
def _create_test_image(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
img = PILImage.new("RGB", (800, 450), color="green")
img.save(path, format="JPEG")
def test_build_epub_creates_file(app, db, tmp_path):
with app.app_context():
img_path = str(tmp_path / "images" / "abc123.jpg")
_create_test_image(img_path)
a1 = Article(
guid="g1", title="First Article", author="Author A",
pub_date=datetime(2026, 4, 6, 10, 0),
categories=json.dumps(["Government"]),
link="http://example.com/1",
content_html=f'<p>Content one.</p><img src="{img_path}" />',
)
a2 = Article(
guid="g2", title="Second Article", author="Author B",
pub_date=datetime(2026, 4, 7, 10, 0),
categories=json.dumps(["Culture Calendar"]),
link="http://example.com/2",
content_html="<p>Content two.</p>",
)
db.session.add_all([a1, a2])
db.session.flush()
img_record = Image(
article_id=a1.id, original_url="https://example.com/photo.jpg",
local_path=img_path, width=800, height=450,
)
db.session.add(img_record)
db.session.commit()
cover_img = PILImage.new("RGB", (800, 480), color="white")
cover_path = str(tmp_path / "cover.jpg")
cover_img.save(cover_path, format="JPEG")
output_dir = str(tmp_path / "issues")
epub_path = build_epub(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
article_ids=[a1.id, a2.id],
cover_path=cover_path,
output_dir=output_dir,
)
assert os.path.exists(epub_path)
assert epub_path.endswith(".epub")
assert os.path.getsize(epub_path) > 0
def test_build_epub_respects_article_order(app, db, tmp_path):
with app.app_context():
a1 = Article(
guid="g1", title="Later Article", author="A",
pub_date=datetime(2026, 4, 8, 10, 0),
categories="[]", link="http://a", content_html="<p>Later</p>",
)
a2 = Article(
guid="g2", title="Earlier Article", author="B",
pub_date=datetime(2026, 4, 6, 10, 0),
categories="[]", link="http://b", content_html="<p>Earlier</p>",
)
db.session.add_all([a1, a2])
db.session.commit()
cover_path = str(tmp_path / "cover.jpg")
PILImage.new("RGB", (800, 480)).save(cover_path, format="JPEG")
epub_path = build_epub(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
article_ids=[a1.id, a2.id],
cover_path=cover_path,
output_dir=str(tmp_path / "issues"),
)
import ebooklib
from ebooklib import epub as epublib
book = epublib.read_epub(epub_path)
spine_items = [book.get_item_with_id(item_id)
for item_id, _ in book.spine if item_id != "nav"]
titles = []
for item in spine_items:
if item and b"<h1>" in item.get_content():
content = item.get_content().decode("utf-8")
start = content.index("<h1>") + 4
end = content.index("</h1>")
titles.append(content[start:end])
assert titles[0] == "Earlier Article"
assert titles[1] == "Later Article"
- Step 2: Run tests to verify they fail
pytest tests/test_epub_builder.py -v
Expected: ImportError — src.epub_builder does not exist yet.
- Step 3: Implement
src/epub_builder.py
# src/epub_builder.py
import json
import os
import logging
from datetime import date
from ebooklib import epub
from PIL import Image as PILImage
from src.models import Article, Image
logger = logging.getLogger(__name__)
EPUB_CSS = """
body { font-family: serif; margin: 1em; line-height: 1.5; }
h1 { font-size: 1.4em; margin-bottom: 0.3em; }
.byline { font-size: 0.85em; color: #555; margin-bottom: 0.5em; }
.categories { font-size: 0.8em; color: #777; margin-bottom: 1em; }
img { max-width: 100%; display: block; margin: 0.5em auto; }
figcaption { font-size: 0.8em; text-align: center; color: #555; }
"""
def build_epub(
week_start: date,
week_end: date,
article_ids: list[int],
cover_path: str,
output_dir: str,
) -> str:
os.makedirs(output_dir, exist_ok=True)
articles = (
Article.query
.filter(Article.id.in_(article_ids))
.order_by(Article.pub_date.asc())
.all()
)
title = (
f"Plymouth Independent — "
f"Week of {week_start.strftime('%b %d')}–{week_end.strftime('%b %d, %Y')}"
)
book = epub.EpubBook()
book.set_identifier(f"pi-{week_start.isoformat()}")
book.set_title(title)
book.set_language("en")
book.add_author("Plymouth Independent")
with open(cover_path, "rb") as f:
book.set_cover("cover.jpg", f.read())
style = epub.EpubItem(
uid="style", file_name="style/default.css",
media_type="text/css", content=EPUB_CSS.encode("utf-8"),
)
book.add_item(style)
chapters = []
image_counter = 0
for article in articles:
categories = json.loads(article.categories)
cat_str = ", ".join(categories) if categories else ""
chapter_html = f"<h1>{article.title}</h1>\n"
chapter_html += (
f'<p class="byline">{article.author} · '
f'{article.pub_date.strftime("%B %d, %Y")}</p>\n'
)
if cat_str:
chapter_html += f'<p class="categories">{cat_str}</p>\n'
content = article.content_html
article_images = Image.query.filter_by(article_id=article.id).all()
for img_record in article_images:
if not os.path.exists(img_record.local_path):
continue
image_counter += 1
epub_img_name = f"images/img_{image_counter}.jpg"
with open(img_record.local_path, "rb") as f:
img_data = f.read()
epub_img = epub.EpubItem(
uid=f"img_{image_counter}",
file_name=epub_img_name,
media_type="image/jpeg",
content=img_data,
)
book.add_item(epub_img)
content = content.replace(img_record.local_path, epub_img_name)
chapter_html += content
chapter = epub.EpubHtml(
title=article.title,
file_name=f"chapter_{article.id}.xhtml",
lang="en",
)
chapter.set_content(
f'<html><head><link rel="stylesheet" href="style/default.css"/>'
f"</head><body>{chapter_html}</body></html>"
)
chapter.add_item(style)
chapters.append(chapter)
book.add_item(chapter)
book.toc = [(c, []) for c in chapters]
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
book.spine = ["nav"] + chapters
iso_week = week_start.isocalendar()[1]
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}.epub"
epub_path = os.path.join(output_dir, filename)
epub.write_epub(epub_path, book)
return epub_path
- Step 4: Run tests to verify they pass
pytest tests/test_epub_builder.py -v
Expected: all 2 tests PASS.
- Step 5: Commit
git add -A
git commit -m "feat: ePub builder with chapters, images, TOC, cover"
Task 7: Scheduler
Files:
-
Create:
src/scheduler.py -
Create:
tests/test_scheduler.py -
Step 1: Write scheduler tests
# tests/test_scheduler.py
from unittest.mock import patch, MagicMock
from src.scheduler import SchedulerManager
def test_scheduler_starts_fetch_job(app):
with app.app_context():
mgr = SchedulerManager(app)
mgr.start()
jobs = mgr.scheduler.get_jobs()
job_ids = [j.id for j in jobs]
assert "rss_fetch" in job_ids
mgr.shutdown()
def test_scheduler_update_fetch_interval(app):
with app.app_context():
mgr = SchedulerManager(app)
mgr.start()
mgr.update_fetch_interval(2)
job = mgr.scheduler.get_job("rss_fetch")
assert job is not None
assert job.trigger.interval.total_seconds() == 7200
mgr.shutdown()
def test_scheduler_enable_auto_publish(app):
with app.app_context():
mgr = SchedulerManager(app)
mgr.start()
mgr.enable_auto_publish(day_of_week="sun", hour=6, minute=0,
cover_method="text")
job = mgr.scheduler.get_job("auto_publish")
assert job is not None
mgr.shutdown()
def test_scheduler_disable_auto_publish(app):
with app.app_context():
mgr = SchedulerManager(app)
mgr.start()
mgr.enable_auto_publish(day_of_week="sun", hour=6, minute=0,
cover_method="text")
mgr.disable_auto_publish()
job = mgr.scheduler.get_job("auto_publish")
assert job is None
mgr.shutdown()
def test_scheduler_get_status(app):
with app.app_context():
mgr = SchedulerManager(app)
mgr.start()
status = mgr.get_status()
assert status["running"] is True
assert "rss_fetch" in status
mgr.shutdown()
- Step 2: Run tests to verify they fail
pytest tests/test_scheduler.py -v
Expected: ImportError — src.scheduler does not exist yet.
- Step 3: Implement
src/scheduler.py
# src/scheduler.py
import logging
from datetime import timedelta, date
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
import config
from src.models import Setting
logger = logging.getLogger(__name__)
class SchedulerManager:
def __init__(self, app):
self.app = app
self.scheduler = BackgroundScheduler()
def start(self):
interval = Setting.get("fetch_interval_hours", default=config.FETCH_INTERVAL_HOURS)
self.scheduler.add_job(
self._run_fetch,
IntervalTrigger(hours=interval),
id="rss_fetch",
replace_existing=True,
)
auto_pub = Setting.get("auto_publish", default=None)
if auto_pub:
self.enable_auto_publish(
day_of_week=auto_pub["day_of_week"],
hour=auto_pub["hour"],
minute=auto_pub["minute"],
cover_method=auto_pub["cover_method"],
)
self.scheduler.start()
logger.info("Scheduler started")
def shutdown(self):
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
def _run_fetch(self):
with self.app.app_context():
from src.fetcher import fetch_and_cache_articles
result = fetch_and_cache_articles()
logger.info("Fetch completed: %s", result)
def _run_auto_publish(self):
with self.app.app_context():
from src.epub_builder import build_epub
from src.cover import generate_cover
from src.models import Article, Issue
import json
today = date.today()
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
articles = (
Article.query
.filter(Article.pub_date >= str(week_start))
.filter(Article.pub_date < str(week_end + timedelta(days=1)))
.order_by(Article.pub_date.asc())
.all()
)
if not articles:
logger.info("No articles for auto-publish, skipping")
return
article_ids = [a.id for a in articles]
headlines = [a.title for a in articles]
auto_pub = Setting.get("auto_publish", {})
method = auto_pub.get("cover_method", "text")
cover_path = generate_cover(
method, config.ISSUES_DIR, week_start, week_end, headlines
)
epub_path = build_epub(
week_start, week_end, article_ids, cover_path, config.ISSUES_DIR
)
issue = Issue(
week_start=week_start,
week_end=week_end,
cover_method=method,
cover_path=cover_path,
epub_path=epub_path,
article_ids=json.dumps(article_ids),
excluded_article_ids=json.dumps([]),
status="published",
)
from app import db
db.session.add(issue)
db.session.commit()
logger.info("Auto-published issue: %s", epub_path)
def update_fetch_interval(self, hours: int):
Setting.set("fetch_interval_hours", hours)
self.scheduler.reschedule_job(
"rss_fetch", trigger=IntervalTrigger(hours=hours)
)
def enable_auto_publish(self, day_of_week: str, hour: int, minute: int,
cover_method: str):
Setting.set("auto_publish", {
"day_of_week": day_of_week,
"hour": hour,
"minute": minute,
"cover_method": cover_method,
})
self.scheduler.add_job(
self._run_auto_publish,
CronTrigger(day_of_week=day_of_week, hour=hour, minute=minute),
id="auto_publish",
replace_existing=True,
)
def disable_auto_publish(self):
Setting.set("auto_publish", None)
try:
self.scheduler.remove_job("auto_publish")
except Exception:
pass
def get_status(self) -> dict:
status = {"running": self.scheduler.running}
fetch_job = self.scheduler.get_job("rss_fetch")
if fetch_job:
status["rss_fetch"] = {
"next_run": str(fetch_job.next_run_time),
"interval_hours": fetch_job.trigger.interval.total_seconds() / 3600,
}
pub_job = self.scheduler.get_job("auto_publish")
if pub_job:
status["auto_publish"] = {
"next_run": str(pub_job.next_run_time),
}
return status
- Step 4: Run tests to verify they pass
pytest tests/test_scheduler.py -v
Expected: all 5 tests PASS.
- Step 5: Commit
git add -A
git commit -m "feat: APScheduler manager with fetch interval and auto-publish"
Task 8: Web UI — Base Layout & Dashboard
Files:
-
Create:
templates/base.html -
Create:
templates/dashboard.html -
Create:
static/style.css -
Create:
src/routes/__init__.py -
Create:
src/routes/dashboard.py -
Step 1: Create
static/style.css
:root {
--pico-font-size: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
padding: 1rem;
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
text-align: center;
}
.stat-card .number {
font-size: 2rem;
font-weight: bold;
display: block;
}
.stat-card .label {
font-size: 0.85rem;
color: var(--pico-muted-color);
}
.action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.spinner {
display: none;
border: 3px solid var(--pico-muted-border-color);
border-top-color: var(--pico-primary);
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
animation: spin 0.8s linear infinite;
display: inline-block;
vertical-align: middle;
margin-left: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden { display: none !important; }
nav .brand { font-weight: bold; font-size: 1.1rem; }
- Step 2: Create
templates/base.html
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}PI Weekly{% endblock %} — Plymouth Independent</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav class="container">
<ul>
<li><a href="/" class="brand">PI Weekly</a></li>
</ul>
<ul>
<li><a href="/articles">Articles</a></li>
<li><a href="/publish">Publish</a></li>
<li><a href="/issues">Issues</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" {% if category == 'error' %}class="pico-background-red-500"{% endif %}>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="container">
<small>PI Weekly Newspaper Generator</small>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
- Step 3: Create
templates/dashboard.html
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<span class="number">{{ articles_this_week }}</span>
<span class="label">Articles This Week</span>
</div>
<div class="stat-card">
<span class="number">{{ total_articles }}</span>
<span class="label">Total Cached</span>
</div>
<div class="stat-card">
<span class="number">{{ total_issues }}</span>
<span class="label">Issues Published</span>
</div>
</div>
<hgroup>
<h3>Scheduler</h3>
<p>
Status: <strong>{{ "Running" if scheduler_status.running else "Stopped" }}</strong>
{% if scheduler_status.rss_fetch %}
· Next fetch: {{ scheduler_status.rss_fetch.next_run }}
· Interval: {{ scheduler_status.rss_fetch.interval_hours }}h
{% endif %}
</p>
</hgroup>
<div class="action-buttons">
<form method="post" action="/fetch-now">
<button type="submit">Fetch Now</button>
</form>
<a href="/publish" role="button" class="outline">New Issue</a>
</div>
{% if latest_issue %}
<h3>Latest Issue</h3>
<p>
{{ latest_issue.week_start }} – {{ latest_issue.week_end }}
· <a href="/issues/{{ latest_issue.id }}/download">Download ePub</a>
</p>
{% endif %}
{% endblock %}
- Step 4: Create
src/routes/__init__.py
# src/routes/__init__.py
from src.routes.dashboard import dashboard_bp
from src.routes.articles import articles_bp
from src.routes.publish import publish_bp
from src.routes.settings import settings_bp
from src.routes.issues import issues_bp
def register_blueprints(app):
app.register_blueprint(dashboard_bp)
app.register_blueprint(articles_bp)
app.register_blueprint(publish_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(issues_bp)
- Step 5: Create
src/routes/dashboard.py
# src/routes/dashboard.py
from datetime import date, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash
from app import db
from src.models import Article, Issue
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.route("/")
def index():
today = date.today()
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
articles_this_week = Article.query.filter(
Article.pub_date >= str(week_start),
Article.pub_date < str(week_end + timedelta(days=1)),
).count()
total_articles = Article.query.count()
total_issues = Issue.query.count()
latest_issue = Issue.query.order_by(Issue.created_at.desc()).first()
from flask import current_app
scheduler_mgr = current_app.config.get("SCHEDULER_MANAGER")
scheduler_status = scheduler_mgr.get_status() if scheduler_mgr else {"running": False}
return render_template(
"dashboard.html",
articles_this_week=articles_this_week,
total_articles=total_articles,
total_issues=total_issues,
latest_issue=latest_issue,
scheduler_status=scheduler_status,
)
@dashboard_bp.route("/fetch-now", methods=["POST"])
def fetch_now():
from src.fetcher import fetch_and_cache_articles
result = fetch_and_cache_articles()
if result.get("error"):
flash(f"Fetch error: {result['error']}", "error")
else:
flash(f"Fetched {result['new']} new articles, {result['skipped']} skipped.")
return redirect(url_for("dashboard.index"))
- Step 6: Create stub route files for remaining blueprints
Create src/routes/articles.py:
from flask import Blueprint, render_template
from src.models import Article
articles_bp = Blueprint("articles", __name__)
@articles_bp.route("/articles")
def index():
articles = Article.query.order_by(Article.pub_date.desc()).all()
return render_template("articles.html", articles=articles)
Create src/routes/publish.py:
from flask import Blueprint
publish_bp = Blueprint("publish", __name__)
@publish_bp.route("/publish")
def index():
return "Publish page — implemented in Task 10"
Create src/routes/settings.py:
from flask import Blueprint
settings_bp = Blueprint("settings", __name__)
@settings_bp.route("/settings")
def index():
return "Settings page — implemented in Task 11"
Create src/routes/issues.py:
from flask import Blueprint
issues_bp = Blueprint("issues", __name__)
@issues_bp.route("/issues")
def index():
return "Issues page — implemented in Task 12"
- Step 7: Wire blueprints into
app.py
Update app.py — after db.create_all(), add:
from src.routes import register_blueprints
register_blueprints(app)
- Step 8: Create stub
templates/articles.html
{% extends "base.html" %}
{% block title %}Articles{% endblock %}
{% block content %}
<h1>Articles</h1>
<p>{{ articles|length }} articles cached.</p>
{% endblock %}
- Step 9: Verify the app starts and dashboard renders
python app.py &
sleep 2
curl -s http://localhost:5000/ | head -20
kill %1
Expected: HTML output containing "Dashboard" and "PI Weekly".
- Step 10: Commit
git add -A
git commit -m "feat: base layout, dashboard, route blueprints"
Task 9: Web UI — Articles View
Files:
-
Update:
src/routes/articles.py -
Create:
templates/articles.html(replace stub) -
Step 1: Update
src/routes/articles.py
# src/routes/articles.py
import json
from datetime import date, timedelta
from flask import Blueprint, render_template, request
from src.models import Article
articles_bp = Blueprint("articles", __name__)
@articles_bp.route("/articles")
def index():
week_filter = request.args.get("week")
category_filter = request.args.get("category")
query = Article.query
if week_filter:
try:
year, week_num = week_filter.split("-W")
week_start = date.fromisocalendar(int(year), int(week_num), 1)
week_end = week_start + timedelta(days=6)
query = query.filter(
Article.pub_date >= str(week_start),
Article.pub_date < str(week_end + timedelta(days=1)),
)
except (ValueError, TypeError):
pass
articles = query.order_by(Article.pub_date.desc()).all()
if category_filter:
articles = [
a for a in articles
if category_filter in json.loads(a.categories)
]
all_categories = set()
for a in Article.query.all():
for cat in json.loads(a.categories):
all_categories.add(cat)
return render_template(
"articles.html",
articles=articles,
categories=sorted(all_categories),
week_filter=week_filter or "",
category_filter=category_filter or "",
)
- Step 2: Create full
templates/articles.html
{% extends "base.html" %}
{% block title %}Articles{% endblock %}
{% block content %}
<h1>Articles</h1>
<form method="get" action="/articles" class="grid">
<label>
Week
<input type="week" name="week" value="{{ week_filter }}">
</label>
<label>
Category
<select name="category">
<option value="">All</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if cat == category_filter %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</label>
<label>
<button type="submit">Filter</button>
</label>
</form>
<p>{{ articles|length }} articles found.</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Author</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>{{ article.pub_date.strftime('%b %d, %Y') }}</td>
<td><a href="{{ article.link }}" target="_blank">{{ article.title }}</a></td>
<td>{{ article.author }}</td>
<td>{{ article.categories | replace('[', '') | replace(']', '') | replace('"', '') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
- Step 3: Commit
git add -A
git commit -m "feat: articles view with week and category filtering"
Task 10: Web UI — Publish View
Files:
-
Update:
src/routes/publish.py -
Create:
templates/publish.html -
Step 1: Implement
src/routes/publish.py
# src/routes/publish.py
import json
from datetime import date, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app import db
from src.models import Article, Issue
from src.cover import generate_cover
from src.epub_builder import build_epub
import config
publish_bp = Blueprint("publish", __name__)
@publish_bp.route("/publish", methods=["GET"])
def index():
week_str = request.args.get("week")
if week_str:
try:
year, week_num = week_str.split("-W")
week_start = date.fromisocalendar(int(year), int(week_num), 1)
except (ValueError, TypeError):
week_start = date.today() - timedelta(days=date.today().weekday())
else:
week_start = date.today() - timedelta(days=date.today().weekday())
week_end = week_start + timedelta(days=6)
articles = (
Article.query
.filter(
Article.pub_date >= str(week_start),
Article.pub_date < str(week_end + timedelta(days=1)),
)
.order_by(Article.pub_date.asc())
.all()
)
return render_template(
"publish.html",
articles=articles,
week_start=week_start,
week_end=week_end,
week_str=f"{week_start.year}-W{week_start.isocalendar()[1]:02d}",
)
@publish_bp.route("/publish", methods=["POST"])
def create_issue():
week_start_str = request.form.get("week_start")
week_end_str = request.form.get("week_end")
cover_method = request.form.get("cover_method", "text")
included_ids = request.form.getlist("article_ids", type=int)
if not included_ids:
flash("No articles selected.", "error")
return redirect(url_for("publish.index"))
week_start = date.fromisoformat(week_start_str)
week_end = date.fromisoformat(week_end_str)
all_week_articles = (
Article.query
.filter(
Article.pub_date >= str(week_start),
Article.pub_date < str(week_end + timedelta(days=1)),
)
.all()
)
all_ids = {a.id for a in all_week_articles}
excluded_ids = list(all_ids - set(included_ids))
headlines = [
a.title for a in Article.query.filter(Article.id.in_(included_ids))
.order_by(Article.pub_date.asc()).all()
]
try:
cover_path = generate_cover(
cover_method, config.ISSUES_DIR, week_start, week_end, headlines
)
epub_path = build_epub(
week_start, week_end, included_ids, cover_path, config.ISSUES_DIR
)
except Exception as e:
flash(f"Error generating issue: {e}", "error")
return redirect(url_for("publish.index"))
issue = Issue(
week_start=week_start,
week_end=week_end,
cover_method=cover_method,
cover_path=cover_path,
epub_path=epub_path,
article_ids=json.dumps(included_ids),
excluded_article_ids=json.dumps(excluded_ids),
status="published",
)
db.session.add(issue)
db.session.commit()
flash(f"Issue published! {len(included_ids)} articles included.")
return redirect(url_for("issues.index"))
- Step 2: Create
templates/publish.html
{% extends "base.html" %}
{% block title %}Publish{% endblock %}
{% block content %}
<h1>Publish Issue</h1>
<form method="get" action="/publish" style="margin-bottom: 1rem;">
<label>
Target Week
<input type="week" name="week" value="{{ week_str }}">
</label>
<button type="submit" class="outline">Load Week</button>
</form>
<p>{{ week_start.strftime('%b %d') }} – {{ week_end.strftime('%b %d, %Y') }} · {{ articles|length }} articles</p>
{% if articles %}
<form method="post" action="/publish" id="publish-form">
<input type="hidden" name="week_start" value="{{ week_start.isoformat() }}">
<input type="hidden" name="week_end" value="{{ week_end.isoformat() }}">
<table>
<thead>
<tr>
<th><input type="checkbox" id="select-all" checked></th>
<th>Date</th>
<th>Title</th>
<th>Author</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>
<input type="checkbox" name="article_ids" value="{{ article.id }}" checked
class="article-checkbox">
</td>
<td>{{ article.pub_date.strftime('%b %d') }}</td>
<td>{{ article.title }}</td>
<td>{{ article.author }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Cover</legend>
<label>
<input type="radio" name="cover_method" value="ai" checked>
AI Cover (Pollinations.ai)
</label>
<label>
<input type="radio" name="cover_method" value="text">
Text Cover (fallback)
</label>
</fieldset>
<button type="submit" id="publish-btn">Generate Issue</button>
<span class="spinner hidden" id="publish-spinner"></span>
</form>
{% else %}
<p>No articles found for this week. <a href="/fetch-now" onclick="event.preventDefault(); document.querySelector('form[action=\"/fetch-now\"]') ? document.querySelector('form[action=\"/fetch-now\"]').submit() : window.location='/fetch-now'">Fetch articles first?</a></p>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.getElementById('select-all')?.addEventListener('change', function() {
document.querySelectorAll('.article-checkbox').forEach(cb => cb.checked = this.checked);
});
document.getElementById('publish-form')?.addEventListener('submit', function() {
document.getElementById('publish-btn').setAttribute('aria-busy', 'true');
document.getElementById('publish-btn').textContent = 'Generating...';
});
</script>
{% endblock %}
- Step 3: Commit
git add -A
git commit -m "feat: publish view with article selection and cover method picker"
Task 11: Web UI — Settings View
Files:
-
Update:
src/routes/settings.py -
Create:
templates/settings.html -
Step 1: Implement
src/routes/settings.py
# src/routes/settings.py
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from src.models import Setting
import config
settings_bp = Blueprint("settings", __name__)
@settings_bp.route("/settings", methods=["GET"])
def index():
feed_url = Setting.get("feed_url", default=config.FEED_URL)
fetch_interval = Setting.get("fetch_interval_hours", default=config.FETCH_INTERVAL_HOURS)
auto_publish = Setting.get("auto_publish", default=None)
max_landscape = Setting.get("image_max_landscape", default=list(config.IMAGE_MAX_LANDSCAPE))
max_portrait = Setting.get("image_max_portrait", default=list(config.IMAGE_MAX_PORTRAIT))
return render_template(
"settings.html",
feed_url=feed_url,
fetch_interval=fetch_interval,
auto_publish=auto_publish,
max_landscape=max_landscape,
max_portrait=max_portrait,
)
@settings_bp.route("/settings", methods=["POST"])
def update():
feed_url = request.form.get("feed_url", config.FEED_URL)
fetch_interval = int(request.form.get("fetch_interval", config.FETCH_INTERVAL_HOURS))
Setting.set("feed_url", feed_url)
config.FEED_URL = feed_url
scheduler_mgr = current_app.config.get("SCHEDULER_MANAGER")
if scheduler_mgr:
scheduler_mgr.update_fetch_interval(fetch_interval)
auto_enabled = request.form.get("auto_publish_enabled") == "on"
if auto_enabled:
day = request.form.get("auto_publish_day", "sun")
hour = int(request.form.get("auto_publish_hour", 6))
minute = int(request.form.get("auto_publish_minute", 0))
method = request.form.get("auto_publish_cover", "text")
if scheduler_mgr:
scheduler_mgr.enable_auto_publish(day, hour, minute, method)
else:
if scheduler_mgr:
scheduler_mgr.disable_auto_publish()
lw = int(request.form.get("landscape_w", 800))
lh = int(request.form.get("landscape_h", 480))
pw = int(request.form.get("portrait_w", 480))
ph = int(request.form.get("portrait_h", 800))
Setting.set("image_max_landscape", [lw, lh])
Setting.set("image_max_portrait", [pw, ph])
config.IMAGE_MAX_LANDSCAPE = (lw, lh)
config.IMAGE_MAX_PORTRAIT = (pw, ph)
flash("Settings saved.")
return redirect(url_for("settings.index"))
- Step 2: Create
templates/settings.html
{% extends "base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<h1>Settings</h1>
<form method="post" action="/settings">
<label>
RSS Feed URL
<input type="url" name="feed_url" value="{{ feed_url }}" required>
</label>
<label>
Fetch Interval (hours)
<input type="number" name="fetch_interval" value="{{ fetch_interval }}" min="1" max="168" required>
</label>
<fieldset>
<legend>Auto-Publish</legend>
<label>
<input type="checkbox" name="auto_publish_enabled" role="switch"
{% if auto_publish %}checked{% endif %}>
Enable auto-publish
</label>
<div class="grid">
<label>
Day
<select name="auto_publish_day">
{% for d in ['mon','tue','wed','thu','fri','sat','sun'] %}
<option value="{{ d }}" {% if auto_publish and auto_publish.day_of_week == d %}selected{% endif %}>
{{ d|capitalize }}
</option>
{% endfor %}
</select>
</label>
<label>
Hour
<input type="number" name="auto_publish_hour"
value="{{ auto_publish.hour if auto_publish else 6 }}" min="0" max="23">
</label>
<label>
Minute
<input type="number" name="auto_publish_minute"
value="{{ auto_publish.minute if auto_publish else 0 }}" min="0" max="59">
</label>
<label>
Cover
<select name="auto_publish_cover">
<option value="ai" {% if auto_publish and auto_publish.cover_method == 'ai' %}selected{% endif %}>AI</option>
<option value="text" {% if not auto_publish or auto_publish.cover_method == 'text' %}selected{% endif %}>Text</option>
</select>
</label>
</div>
</fieldset>
<fieldset>
<legend>Image Constraints</legend>
<div class="grid">
<label>
Landscape Width
<input type="number" name="landscape_w" value="{{ max_landscape[0] }}">
</label>
<label>
Landscape Height
<input type="number" name="landscape_h" value="{{ max_landscape[1] }}">
</label>
<label>
Portrait Width
<input type="number" name="portrait_w" value="{{ max_portrait[0] }}">
</label>
<label>
Portrait Height
<input type="number" name="portrait_h" value="{{ max_portrait[1] }}">
</label>
</div>
</fieldset>
<button type="submit">Save Settings</button>
</form>
{% endblock %}
- Step 3: Commit
git add -A
git commit -m "feat: settings view with feed URL, scheduler, auto-publish, image config"
Task 12: Web UI — Issues Archive
Files:
-
Update:
src/routes/issues.py -
Create:
templates/issues.html -
Step 1: Implement
src/routes/issues.py
# src/routes/issues.py
import os
import json
from flask import Blueprint, render_template, send_file, redirect, url_for, flash
from app import db
from src.models import Issue, Article
from src.cover import generate_cover
from src.epub_builder import build_epub
import config
issues_bp = Blueprint("issues", __name__)
@issues_bp.route("/issues")
def index():
issues = Issue.query.order_by(Issue.created_at.desc()).all()
issue_data = []
for issue in issues:
article_count = len(json.loads(issue.article_ids))
issue_data.append({
"issue": issue,
"article_count": article_count,
})
return render_template("issues.html", issues=issue_data)
@issues_bp.route("/issues/<int:issue_id>/download")
def download(issue_id):
issue = Issue.query.get_or_404(issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
return send_file(
issue.epub_path,
as_attachment=True,
download_name=os.path.basename(issue.epub_path),
)
@issues_bp.route("/issues/<int:issue_id>/cover")
def cover_image(issue_id):
issue = Issue.query.get_or_404(issue_id)
if not issue.cover_path or not os.path.exists(issue.cover_path):
flash("Cover image not found.", "error")
return redirect(url_for("issues.index"))
return send_file(issue.cover_path, mimetype="image/jpeg")
@issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"])
def regenerate(issue_id):
issue = Issue.query.get_or_404(issue_id)
article_ids = json.loads(issue.article_ids)
headlines = [
a.title for a in Article.query.filter(Article.id.in_(article_ids))
.order_by(Article.pub_date.asc()).all()
]
try:
cover_path = generate_cover(
issue.cover_method, config.ISSUES_DIR,
issue.week_start, issue.week_end, headlines
)
epub_path = build_epub(
issue.week_start, issue.week_end, article_ids,
cover_path, config.ISSUES_DIR
)
issue.cover_path = cover_path
issue.epub_path = epub_path
db.session.commit()
flash("Issue regenerated successfully.")
except Exception as e:
flash(f"Regeneration failed: {e}", "error")
return redirect(url_for("issues.index"))
- Step 2: Create
templates/issues.html
{% extends "base.html" %}
{% block title %}Issues{% endblock %}
{% block content %}
<h1>Issues Archive</h1>
{% if issues %}
<table>
<thead>
<tr>
<th>Cover</th>
<th>Week</th>
<th>Articles</th>
<th>Cover Method</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in issues %}
<tr>
<td>
<img src="/issues/{{ item.issue.id }}/cover" alt="Cover"
style="max-width: 100px; max-height: 60px;">
</td>
<td>{{ item.issue.week_start.strftime('%b %d') }} – {{ item.issue.week_end.strftime('%b %d, %Y') }}</td>
<td>{{ item.article_count }}</td>
<td>{{ item.issue.cover_method }}</td>
<td>{{ item.issue.created_at.strftime('%b %d, %Y %H:%M') }}</td>
<td>
<a href="/issues/{{ item.issue.id }}/download" role="button" class="outline secondary">
Download
</a>
<form method="post" action="/issues/{{ item.issue.id }}/regenerate" style="display: inline;">
<button type="submit" class="outline contrast">Regenerate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No issues published yet. <a href="/publish">Create one?</a></p>
{% endif %}
{% endblock %}
- Step 3: Commit
git add -A
git commit -m "feat: issues archive with download, cover preview, regenerate"
Task 13: Integration — Wire Everything Into app.py
Files:
-
Update:
app.py -
Step 1: Update
app.pywith full integration
Replace the skeleton app.py with:
# app.py
import logging
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import config
db = SQLAlchemy()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
def create_app(start_scheduler=True):
app = Flask(__name__)
app.config.from_object(config)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", os.urandom(24))
os.makedirs(config.DATA_DIR, exist_ok=True)
os.makedirs(config.IMAGES_DIR, exist_ok=True)
os.makedirs(config.ISSUES_DIR, exist_ok=True)
db.init_app(app)
with app.app_context():
from src import models # noqa: F401
db.create_all()
from src.routes import register_blueprints
register_blueprints(app)
if start_scheduler:
from src.scheduler import SchedulerManager
scheduler_mgr = SchedulerManager(app)
scheduler_mgr.start()
app.config["SCHEDULER_MANAGER"] = scheduler_mgr
return app
if __name__ == "__main__":
app = create_app()
app.run(host="0.0.0.0", port=5000, debug=False)
Note: debug=False because Flask's reloader would start the scheduler twice. For development, use FLASK_DEBUG=1 flask run with the reloader pin, or just restart manually.
- Step 2: Update
tests/conftest.pyto passstart_scheduler=False
Update the create_app() call in the fixture:
app = create_app(start_scheduler=False)
- Step 3: Run the full test suite
pytest tests/ -v
Expected: all tests PASS.
- Step 4: Manual smoke test
source .venv/bin/activate
python app.py &
sleep 3
curl -s http://localhost:5000/ | grep "Dashboard"
curl -s http://localhost:5000/articles | grep "Articles"
curl -s http://localhost:5000/publish | grep "Publish"
curl -s http://localhost:5000/settings | grep "Settings"
curl -s http://localhost:5000/issues | grep "Issues"
kill %1
Expected: each curl returns HTML containing the page title.
- Step 5: Commit
git add -A
git commit -m "feat: full integration — app.py wiring, scheduler startup, route registration"
Task 14: README
Files:
-
Create:
README.md -
Step 1: Write
README.md
# PI Weekly Newspaper
Generates weekly ePub "newspapers" from the [Plymouth Independent](https://www.plymouthindependent.org/) RSS feed, optimized for the Xtreink X4 e-reader (800x480 screen).
## Quick Start
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
Open http://localhost:5000 in your browser.
Features
- Periodic RSS fetching with configurable interval
- Automatic image processing — downloads, resizes to e-reader constraints, converts to baseline JPEG
- ePub generation with articles as chapters, table of contents, and embedded images
- AI-generated covers via Pollinations.ai (free, no API key) with text fallback
- Web UI accessible from any device on your network
- Scheduled or manual publishing
Usage
- Click Fetch Now on the dashboard to pull articles
- Go to Publish, select the target week, toggle articles on/off
- Choose a cover method (AI or Text) and click Generate Issue
- Download the
.epubfrom the Issues archive
Configuration
Settings are editable via the web UI at /settings, or in config.py:
FEED_URL— RSS feed URLFETCH_INTERVAL_HOURS— how often to check for new articlesIMAGE_MAX_LANDSCAPE/IMAGE_MAX_PORTRAIT— image bounding box dimensions
Access from Other Devices
The app binds to 0.0.0.0:5000, so access it from any device on your network using your Mac's IP address (e.g., http://192.168.1.x:5000).
- [ ] **Step 2: Commit**
```bash
git add -A
git commit -m "docs: README with quick start, features, usage guide"
Self-Review Checklist
1. Spec coverage:
- ePub with chapters in chronological order: Task 6
- Offline images downloaded/embedded: Tasks 3, 4
- Image resize to e-reader constraints, baseline JPEG: Task 3
- Web UI with schedule control: Tasks 8–12
- MacBook + Android accessibility: Task 13 (binds 0.0.0.0)
- Periodic fetch + manual publish: Tasks 4, 7, 10
- Article include/exclude: Task 10
- AI cover + text fallback, selectable: Task 5, 10
- RSS
content:encodedas source: Task 4
2. Placeholder scan: No TBDs, TODOs, or vague steps found.
3. Type consistency:
fetch_and_cache_articles()→ returnsdictwithnew,skipped,errors,errorkeys — consistent across fetcher.py and dashboard.pyprocess_image(url, output_dir)→ returns(path, width, height)— consistent across images.py and fetcher.pygenerate_cover(method, output_dir, week_start, week_end, headlines)→ returnsstrpath — consistent across cover.py, publish.py, issues.py, scheduler.pybuild_epub(week_start, week_end, article_ids, cover_path, output_dir)→ returnsstrpath — consistent everywhereSchedulerManagermethods match between scheduler.py, dashboard.py, and settings.py