2026-04-06 15:21:18 -04:00
|
|
|
import os
|
|
|
|
|
import json
|
2026-04-07 02:22:35 -04:00
|
|
|
import requests
|
2026-04-06 15:21:18 -04:00
|
|
|
from flask import Blueprint, render_template, send_file, redirect, url_for, flash
|
|
|
|
|
|
|
|
|
|
from app import db
|
2026-04-06 19:33:09 -04:00
|
|
|
from src.models import Issue, Article, Image
|
2026-04-06 15:21:18 -04:00
|
|
|
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):
|
2026-04-07 02:22:35 -04:00
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
2026-04-06 15:21:18 -04:00
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 18:40:04 -04:00
|
|
|
@issues_bp.route("/issues/<int:issue_id>/epub")
|
|
|
|
|
def epub_file(issue_id):
|
2026-04-07 02:22:35 -04:00
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
2026-04-06 18:40:04 -04:00
|
|
|
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, mimetype="application/epub+zip")
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
@issues_bp.route("/issues/<int:issue_id>/cover")
|
|
|
|
|
def cover_image(issue_id):
|
2026-04-07 02:22:35 -04:00
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
2026-04-06 15:21:18 -04:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 17:39:37 -04:00
|
|
|
@issues_bp.route("/issues/<int:issue_id>/read")
|
|
|
|
|
def read(issue_id):
|
2026-04-07 02:22:35 -04:00
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
2026-04-06 17:39:37 -04:00
|
|
|
if not os.path.exists(issue.epub_path):
|
|
|
|
|
flash("ePub file not found.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
article_count = len(json.loads(issue.article_ids))
|
|
|
|
|
if issue.issue_type == "single_article":
|
|
|
|
|
article_ids = json.loads(issue.article_ids)
|
2026-04-07 02:22:35 -04:00
|
|
|
article = db.session.get(Article, article_ids[0]) if article_ids else None
|
2026-04-06 17:39:37 -04:00
|
|
|
title = article.title if article else "Single Article"
|
|
|
|
|
elif issue.issue_type == "multi_week":
|
|
|
|
|
w1 = issue.week_start.isocalendar()[1]
|
|
|
|
|
w2 = issue.week_end.isocalendar()[1]
|
|
|
|
|
title = f"Weeks {w1}\u2013{w2}"
|
|
|
|
|
else:
|
|
|
|
|
title = f"Week {issue.week_start.isocalendar()[1]}"
|
|
|
|
|
|
|
|
|
|
return render_template(
|
|
|
|
|
"reader.html",
|
|
|
|
|
issue=issue,
|
|
|
|
|
title=title,
|
|
|
|
|
article_count=article_count,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 18:40:04 -04:00
|
|
|
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
|
|
|
|
|
def delete(issue_id):
|
2026-04-07 02:22:35 -04:00
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
2026-04-06 18:40:04 -04:00
|
|
|
|
|
|
|
|
if issue.epub_path and os.path.exists(issue.epub_path):
|
|
|
|
|
os.remove(issue.epub_path)
|
|
|
|
|
if issue.cover_path and os.path.exists(issue.cover_path):
|
|
|
|
|
os.remove(issue.cover_path)
|
|
|
|
|
|
|
|
|
|
db.session.delete(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
flash("Issue deleted.")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
@issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"])
|
|
|
|
|
def regenerate(issue_id):
|
2026-04-07 02:22:35 -04:00
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
2026-04-06 15:21:18 -04:00
|
|
|
article_ids = json.loads(issue.article_ids)
|
|
|
|
|
|
2026-04-06 18:59:51 -04:00
|
|
|
articles_for_issue = (
|
|
|
|
|
Article.query.filter(Article.id.in_(article_ids))
|
|
|
|
|
.order_by(Article.pub_date.asc())
|
|
|
|
|
.all()
|
|
|
|
|
)
|
2026-04-06 15:21:18 -04:00
|
|
|
headlines = [
|
2026-04-06 18:59:51 -04:00
|
|
|
a.title for a in articles_for_issue
|
|
|
|
|
if "Obituaries" not in json.loads(a.categories)
|
2026-04-06 15:21:18 -04:00
|
|
|
]
|
2026-04-06 19:33:09 -04:00
|
|
|
|
|
|
|
|
image_paths = []
|
2026-04-06 18:59:51 -04:00
|
|
|
for a in articles_for_issue:
|
2026-04-06 20:28:32 -04:00
|
|
|
if "Obituaries" in json.loads(a.categories):
|
|
|
|
|
continue
|
2026-04-06 19:33:09 -04:00
|
|
|
first_image = Image.query.filter_by(article_id=a.id).first()
|
|
|
|
|
if first_image:
|
|
|
|
|
image_paths.append(first_image.local_path)
|
|
|
|
|
|
|
|
|
|
cover_method = issue.cover_method
|
|
|
|
|
if cover_method == "ai":
|
|
|
|
|
issue.cover_method = "mosaic"
|
|
|
|
|
cover_method = "mosaic"
|
2026-04-06 17:02:36 -04:00
|
|
|
|
2026-04-06 15:21:18 -04:00
|
|
|
try:
|
|
|
|
|
cover_path = generate_cover(
|
2026-04-06 19:33:09 -04:00
|
|
|
cover_method, config.ISSUES_DIR,
|
|
|
|
|
issue.week_start, issue.week_end, headlines, image_paths
|
2026-04-06 15:21:18 -04:00
|
|
|
)
|
|
|
|
|
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"))
|
2026-04-07 02:22:35 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@issues_bp.route("/issues/<int:issue_id>/push", methods=["POST"])
|
|
|
|
|
def push(issue_id):
|
|
|
|
|
issue = db.get_or_404(Issue, issue_id)
|
|
|
|
|
|
|
|
|
|
if not config.GRIMMORY_URL or not config.GRIMMORY_USERNAME or not config.GRIMMORY_PASSWORD:
|
|
|
|
|
flash("Grimmory/Booklore integration is not configured.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
if not issue.epub_path or not os.path.exists(issue.epub_path):
|
|
|
|
|
flash("ePub file not found.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
base_url = config.GRIMMORY_URL.rstrip('/')
|
|
|
|
|
|
|
|
|
|
# 1. Authenticate to get a token
|
|
|
|
|
login_resp = requests.post(
|
|
|
|
|
f"{base_url}/api/v1/auth/login",
|
|
|
|
|
json={"username": config.GRIMMORY_USERNAME, "password": config.GRIMMORY_PASSWORD},
|
|
|
|
|
timeout=10
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if login_resp.status_code != 200:
|
|
|
|
|
flash("Failed to authenticate with library. Check username/password.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
login_data = login_resp.json()
|
|
|
|
|
token = login_data.get("token") or login_data.get("accessToken") or login_data.get("accessToken_Internal")
|
|
|
|
|
|
|
|
|
|
if not token and "data" in login_data and isinstance(login_data["data"], dict):
|
|
|
|
|
token = login_data["data"].get("token") or login_data["data"].get("accessToken")
|
|
|
|
|
|
|
|
|
|
if not token:
|
|
|
|
|
flash("Failed to extract authentication token from library response.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
# 2. Resolve Library ID and Path ID if configured
|
|
|
|
|
data = {}
|
|
|
|
|
if config.GRIMMORY_LIBRARY_ID:
|
|
|
|
|
headers = {'Authorization': f'Bearer {token}'}
|
|
|
|
|
lib_resp = requests.get(f"{base_url}/api/v1/libraries", headers=headers, timeout=10)
|
|
|
|
|
if lib_resp.status_code == 200:
|
|
|
|
|
libraries = lib_resp.json()
|
|
|
|
|
target_lib = None
|
|
|
|
|
|
|
|
|
|
# Try to find by ID first, then by name
|
|
|
|
|
for lib in libraries:
|
|
|
|
|
if str(lib.get('id')) == str(config.GRIMMORY_LIBRARY_ID) or lib.get('name', '').lower() == str(config.GRIMMORY_LIBRARY_ID).lower():
|
|
|
|
|
target_lib = lib
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if target_lib:
|
|
|
|
|
data['libraryId'] = target_lib['id']
|
|
|
|
|
if target_lib.get('paths') and len(target_lib['paths']) > 0:
|
|
|
|
|
data['pathId'] = target_lib['paths'][0]['id']
|
|
|
|
|
else:
|
|
|
|
|
flash(f"Library '{target_lib['name']}' has no paths configured.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
else:
|
|
|
|
|
flash(f"Could not find library matching '{config.GRIMMORY_LIBRARY_ID}'.", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
else:
|
|
|
|
|
flash(f"Failed to fetch libraries from server. Status: {lib_resp.status_code}", "error")
|
|
|
|
|
return redirect(url_for("issues.index"))
|
|
|
|
|
|
|
|
|
|
# 3. Upload the file to the library
|
|
|
|
|
files = {
|
|
|
|
|
'file': (
|
|
|
|
|
os.path.basename(issue.epub_path),
|
|
|
|
|
open(issue.epub_path, 'rb'),
|
|
|
|
|
'application/epub+zip'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
headers = {'Authorization': f'Bearer {token}'}
|
|
|
|
|
|
|
|
|
|
response = requests.post(
|
|
|
|
|
f"{base_url}/api/v1/files/upload",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=headers,
|
|
|
|
|
timeout=30
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if response.status_code in (200, 201, 204):
|
|
|
|
|
flash("Issue successfully pushed to library.")
|
|
|
|
|
elif response.status_code == 409:
|
|
|
|
|
flash("Issue was already pushed to the library previously.")
|
|
|
|
|
else:
|
|
|
|
|
flash(f"Failed to push issue. Status: {response.status_code}. {response.text}", "error")
|
|
|
|
|
|
|
|
|
|
except requests.RequestException as e:
|
|
|
|
|
flash(f"Error connecting to library server: {e}", "error")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
flash(f"An unexpected error occurred: {e}", "error")
|
|
|
|
|
|
|
|
|
|
return redirect(url_for("issues.index"))
|