2026-04-06 15:21:18 -04:00
|
|
|
import json
|
|
|
|
|
from datetime import date, timedelta
|
2026-04-06 17:06:52 -04:00
|
|
|
from calendar import monthrange
|
|
|
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
2026-04-06 15:21:18 -04:00
|
|
|
|
|
|
|
|
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__)
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 17:06:52 -04:00
|
|
|
def _get_week_bounds(d: date) -> tuple[date, date]:
|
|
|
|
|
week_start = d - timedelta(days=d.weekday())
|
|
|
|
|
week_end = week_start + timedelta(days=6)
|
|
|
|
|
return week_start, week_end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _calendar_data(year: int, month: int) -> list[dict]:
|
|
|
|
|
"""Build calendar grid data for a given month."""
|
|
|
|
|
first_day = date(year, month, 1)
|
|
|
|
|
_, days_in_month = monthrange(year, month)
|
|
|
|
|
|
|
|
|
|
weeks = []
|
|
|
|
|
current = first_day - timedelta(days=first_day.weekday())
|
|
|
|
|
|
|
|
|
|
while current.month <= month or (current + timedelta(days=6)).month <= month or current < first_day:
|
|
|
|
|
week_start = current
|
|
|
|
|
week_end = current + timedelta(days=6)
|
|
|
|
|
iso_week = week_start.isocalendar()[1]
|
|
|
|
|
|
|
|
|
|
article_count = Article.query.filter(
|
|
|
|
|
Article.pub_date >= str(week_start),
|
|
|
|
|
Article.pub_date < str(week_end + timedelta(days=1)),
|
|
|
|
|
).count()
|
|
|
|
|
|
|
|
|
|
days = []
|
|
|
|
|
for i in range(7):
|
|
|
|
|
d = current + timedelta(days=i)
|
|
|
|
|
days.append({
|
|
|
|
|
"day": d.day,
|
|
|
|
|
"date": d.isoformat(),
|
|
|
|
|
"in_month": d.month == month,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
weeks.append({
|
|
|
|
|
"iso_week": iso_week,
|
|
|
|
|
"week_start": week_start.isoformat(),
|
|
|
|
|
"week_end": week_end.isoformat(),
|
|
|
|
|
"days": days,
|
|
|
|
|
"article_count": article_count,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
current += timedelta(days=7)
|
|
|
|
|
if current.month > month and current.year >= year:
|
|
|
|
|
if not any(d["in_month"] for d in days):
|
|
|
|
|
weeks.pop()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
return weeks
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
@publish_bp.route("/publish", methods=["GET"])
|
|
|
|
|
def index():
|
2026-04-06 17:06:52 -04:00
|
|
|
today = date.today()
|
|
|
|
|
cal_year = request.args.get("cal_year", today.year, type=int)
|
|
|
|
|
cal_month = request.args.get("cal_month", today.month, type=int)
|
2026-04-06 15:21:18 -04:00
|
|
|
|
2026-04-06 17:06:52 -04:00
|
|
|
week_start, week_end = _get_week_bounds(today)
|
|
|
|
|
calendar_weeks = _calendar_data(cal_year, cal_month)
|
2026-04-06 15:21:18 -04:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-06 17:06:52 -04:00
|
|
|
all_articles = (
|
|
|
|
|
Article.query
|
|
|
|
|
.order_by(Article.pub_date.desc())
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
return render_template(
|
|
|
|
|
"publish.html",
|
|
|
|
|
articles=articles,
|
2026-04-06 17:06:52 -04:00
|
|
|
all_articles=all_articles,
|
2026-04-06 15:21:18 -04:00
|
|
|
week_start=week_start,
|
|
|
|
|
week_end=week_end,
|
2026-04-06 17:06:52 -04:00
|
|
|
calendar_weeks=calendar_weeks,
|
|
|
|
|
cal_year=cal_year,
|
|
|
|
|
cal_month=cal_month,
|
|
|
|
|
cal_month_name=date(cal_year, cal_month, 1).strftime("%B %Y"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@publish_bp.route("/publish/articles", methods=["GET"])
|
|
|
|
|
def articles_api():
|
|
|
|
|
start = request.args.get("start")
|
|
|
|
|
end = request.args.get("end")
|
|
|
|
|
if not start or not end:
|
|
|
|
|
return jsonify([])
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
start_date = date.fromisoformat(start)
|
|
|
|
|
end_date = date.fromisoformat(end)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return jsonify([])
|
|
|
|
|
|
|
|
|
|
articles = (
|
|
|
|
|
Article.query
|
|
|
|
|
.filter(
|
|
|
|
|
Article.pub_date >= str(start_date),
|
|
|
|
|
Article.pub_date < str(end_date + timedelta(days=1)),
|
|
|
|
|
)
|
|
|
|
|
.order_by(Article.pub_date.asc())
|
|
|
|
|
.all()
|
2026-04-06 15:21:18 -04:00
|
|
|
)
|
|
|
|
|
|
2026-04-06 17:06:52 -04:00
|
|
|
return jsonify([
|
|
|
|
|
{
|
|
|
|
|
"id": a.id,
|
|
|
|
|
"title": a.title,
|
|
|
|
|
"author": a.author,
|
|
|
|
|
"pub_date": a.pub_date.strftime("%b %d, %Y"),
|
|
|
|
|
"categories": json.loads(a.categories),
|
|
|
|
|
}
|
|
|
|
|
for a in articles
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@publish_bp.route("/publish/calendar", methods=["GET"])
|
|
|
|
|
def calendar_api():
|
|
|
|
|
year = request.args.get("year", type=int)
|
|
|
|
|
month = request.args.get("month", type=int)
|
|
|
|
|
if not year or not month:
|
|
|
|
|
return jsonify([])
|
|
|
|
|
return jsonify(_calendar_data(year, month))
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
|
|
|
|
|
@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")
|
2026-04-06 17:06:52 -04:00
|
|
|
issue_type = request.form.get("issue_type", "weekly")
|
2026-04-06 15:21:18 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
headlines = [
|
|
|
|
|
a.title for a in Article.query.filter(Article.id.in_(included_ids))
|
|
|
|
|
.order_by(Article.pub_date.asc()).all()
|
|
|
|
|
]
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
categories_list = []
|
|
|
|
|
for a in Article.query.filter(Article.id.in_(included_ids)).all():
|
|
|
|
|
categories_list.extend(json.loads(a.categories))
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
try:
|
|
|
|
|
cover_path = generate_cover(
|
2026-04-06 17:02:36 -04:00
|
|
|
cover_method, config.ISSUES_DIR, week_start, week_end,
|
|
|
|
|
headlines, categories_list
|
2026-04-06 15:21:18 -04:00
|
|
|
)
|
|
|
|
|
epub_path = build_epub(
|
2026-04-06 17:06:52 -04:00
|
|
|
week_start, week_end, included_ids, cover_path,
|
|
|
|
|
config.ISSUES_DIR, issue_type=issue_type
|
2026-04-06 15:21:18 -04:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
flash(f"Error generating issue: {e}", "error")
|
|
|
|
|
return redirect(url_for("publish.index"))
|
|
|
|
|
|
2026-04-06 17:06:52 -04:00
|
|
|
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))
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
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",
|
2026-04-06 17:06:52 -04:00
|
|
|
issue_type=issue_type,
|
2026-04-06 15:21:18 -04:00
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
flash(f"Issue published! {len(included_ids)} articles included.")
|
|
|
|
|
return redirect(url_for("issues.index"))
|