Compare commits

..

14 Commits

Author SHA1 Message Date
cottongin
a605024024 chore: add AI warning to README 2026-04-07 15:44:24 -04:00
cottongin
3d36517511 docs: fix gallery and repo URLs
- Updated README.md to use the correct htmlpreview URL for Gitea
- Updated docs/gallery.html 'Back to README' link to point to the main repo URL

Made-with: Cursor
2026-04-07 15:37:25 -04:00
cottongin
7339e8c0d6 docs: use htmlpreview for gallery links in README
- Replaced local links to docs/gallery.html with htmlpreview.github.io links
- Allows the interactive gallery to be viewed directly from the GitHub repository

Made-with: Cursor
2026-04-07 15:32:22 -04:00
cottongin
202b2c8b35 chore: tweak README 2026-04-07 15:24:37 -04:00
cottongin
0abfe17914 chore: add MIT license
- Added MIT license file
- Attributed copyright to cottongin

Made-with: Cursor
2026-04-07 15:10:08 -04:00
cottongin
b0ac44152f docs: add application screenshots and interactive gallery
- Captured clean, full-page screenshots of all major application views
- Added an interactive HTML slide deck gallery for previewing features
- Created a high-resolution mosaic and a smaller preview mosaic
- Generated a Python script to automate mosaic creation
- Updated README.md with prominent links to the gallery and screenshots
- Added accessibility attributes (role="button", tabindex="0") to calendar rows

Made-with: Cursor
2026-04-07 15:03:21 -04:00
cottongin
c5e5836ac1 feat: streamline publish tab and calendar UI
- Merged Weekly and Multi-Week tabs into a single "By Week" tab
- Updated calendar to visually indicate days with articles via a dot indicator
- Improved calendar week selection to allow toggling multiple individual weeks
- Enhanced calendar hover states to invert foreground text for readability
- Fixed active tab styling to remove clashing bottom borders and focus outlines

Made-with: Cursor
2026-04-07 12:16:11 -04:00
cottongin
0d1a898caa feat: add Grimmory/Booklore push integration
- Added "Push to Library" button to issues archive
- Implemented direct API upload to Grimmory/Booklore
- Added support for `.env` files via `python-dotenv`
- Handled 409 Conflict for duplicate files gracefully
- Resolved library name to numeric ID for direct uploads
- Fixed SQLAlchemy and ebooklib warnings in tests
- Added comprehensive tests for push functionality

Made-with: Cursor
2026-04-07 02:22:35 -04:00
cottongin
767285119b feat: improve scheduler UI and align fetch to half-hours with tests
Made-with: Cursor
2026-04-06 21:42:49 -04:00
cottongin
2cf44fe642 chore: add timezone migration script
Made-with: Cursor
2026-04-06 21:40:32 -04:00
cottongin
e40023b9f9 fix: convert feed timestamps to US/Eastern and add test
Made-with: Cursor
2026-04-06 21:39:00 -04:00
cottongin
f7b424b692 fix: use US/Eastern for model datetime defaults and add tests
Made-with: Cursor
2026-04-06 21:36:55 -04:00
cottongin
7c9c32bd0e style: vertically center headlines in the bottom box
Made-with: Cursor
2026-04-06 20:50:02 -04:00
cottongin
775df4b79e fix: use Helvetica Bold explicitly instead of Arial
Made-with: Cursor
2026-04-06 20:47:06 -04:00
35 changed files with 978 additions and 148 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ data/
__pycache__/
*.pyc
.venv/
.env
*.egg-info/
dist/
build/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,14 @@
> [!IMPORTANT]
> This project was developed entirely with AI coding assistance (Google Gemini 3.1 Pro, via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
# PI Weekly Newspaper
[![Application Preview](docs/screenshots/mosaic_small.png)](https://htmlpreview.github.io/?https://code.cottongin.xyz/cottongin/pi-weekly-newspaper/raw/branch/master/docs/gallery.html)
Generates weekly ePub "newspapers" from the [Plymouth Independent](https://www.plymouthindependent.org/) RSS feed, optimized for the Xtreink X4 e-reader (800x480 screen).
[📸 View all screenshots](docs/screenshots.md) | [🖼️ View Interactive Gallery](https://htmlpreview.github.io/?https://code.cottongin.xyz/cottongin/pi-weekly-newspaper/raw/branch/master/docs/gallery.html) | [🪟 Large Mosaic](docs/screenshots/mosaic_large.png)
## Quick Start
```bash
@@ -18,7 +25,7 @@ Open http://localhost:5000 in your browser.
- **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
- **Auto-generated covers** Mosaic art formed with each week's article's featured images, with text fallback
- **Web UI** accessible from any device on your network
- **Scheduled or manual publishing**
@@ -26,7 +33,7 @@ Open http://localhost:5000 in your browser.
1. Click **Fetch Now** on the dashboard to pull articles
2. Go to **Publish**, select the target week, toggle articles on/off
3. Choose a cover method (AI or Text) and click **Generate Issue**
3. Choose a cover method (Mosaic or Text) and click **Generate Issue**
4. Download the `.epub` from the **Issues** archive
## Configuration
@@ -37,6 +44,19 @@ Settings are editable via the web UI at `/settings`, or in `config.py`:
- `FETCH_INTERVAL_HOURS` — how often to check for new articles
- `IMAGE_MAX_LANDSCAPE` / `IMAGE_MAX_PORTRAIT` — image bounding box dimensions
### Integration with Grimmory / Booklore
You can automatically push generated issues to a [Grimmory](https://github.com/grimmory-tools/grimmory) or Booklore instance. Create a `.env` file in the root directory (or set these as system environment variables):
```env
GRIMMORY_URL=http://your-grimmory-instance:6060
GRIMMORY_USERNAME=your_username
GRIMMORY_PASSWORD=your_password
GRIMMORY_LIBRARY_ID=optional_library_id
```
*Note: `BOOKLORE_URL`, `BOOKLORE_USERNAME`, `BOOKLORE_PASSWORD`, and `BOOKLORE_LIBRARY_ID` are also supported as deprecated fallbacks for older Booklore instances.*
## 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`).
The app binds to `0.0.0.0:5000`, so access it from any device on your network using the server's IP address (e.g., `http://192.168.1.x:5000`).

View File

@@ -1,4 +1,7 @@
import os
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
@@ -14,3 +17,9 @@ FETCH_INTERVAL_HOURS = 1
IMAGE_MAX_LANDSCAPE = (800, 480)
IMAGE_MAX_PORTRAIT = (480, 800)
COVER_SIZE = (480, 800)
# Grimmory config (preferred) with Booklore fallbacks (deprecated)
GRIMMORY_URL = os.environ.get("GRIMMORY_URL") or os.environ.get("BOOKLORE_URL")
GRIMMORY_USERNAME = os.environ.get("GRIMMORY_USERNAME") or os.environ.get("BOOKLORE_USERNAME")
GRIMMORY_PASSWORD = os.environ.get("GRIMMORY_PASSWORD") or os.environ.get("BOOKLORE_PASSWORD")
GRIMMORY_LIBRARY_ID = os.environ.get("GRIMMORY_LIBRARY_ID") or os.environ.get("BOOKLORE_LIBRARY_ID")

215
docs/gallery.html Normal file
View File

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plymouth Independent Weekly - Gallery</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #1a1a1a;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.header {
text-align: center;
padding: 20px;
margin-bottom: 20px;
}
.header h1 { margin: 0 0 10px 0; font-weight: 500; }
.header p { margin: 0; color: #aaa; }
.gallery-container {
position: relative;
width: 90%;
max-width: 1200px;
background: #2a2a2a;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.slides {
display: flex;
transition: transform 0.4s ease-in-out;
width: 100%;
}
.slide {
min-width: 100%;
box-sizing: border-box;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.slide img {
max-width: 100%;
max-height: 70vh;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
object-fit: contain;
}
.slide-caption {
margin-top: 15px;
font-size: 1.2em;
color: #ddd;
text-align: center;
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.5);
color: white;
border: none;
width: 50px;
height: 50px;
border-radius: 50%;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 10;
}
.nav-btn:hover { background: rgba(0,0,0,0.8); }
.prev-btn { left: 20px; }
.next-btn { right: 20px; }
.thumbnails {
display: flex;
gap: 10px;
margin-top: 20px;
padding: 10px;
overflow-x: auto;
max-width: 90%;
}
.thumb {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s, transform 0.2s;
border: 2px solid transparent;
}
.thumb:hover { opacity: 0.8; }
.thumb.active {
opacity: 1;
border-color: #007bff;
transform: scale(1.05);
}
.back-link {
margin-top: 30px;
color: #007bff;
text-decoration: none;
}
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="header">
<h1>Plymouth Independent Weekly</h1>
<p>Application Gallery</p>
</div>
<div class="gallery-container">
<button class="nav-btn prev-btn" onclick="moveSlide(-1)">&#10094;</button>
<div class="slides" id="slides">
<div class="slide">
<img src="screenshots/01-dashboard.png" alt="Dashboard">
<div class="slide-caption">1. Dashboard Overview</div>
</div>
<div class="slide">
<img src="screenshots/02-articles.png" alt="Articles">
<div class="slide-caption">2. Articles List & Filtering</div>
</div>
<div class="slide">
<img src="screenshots/03-publish.png" alt="Publish Single Week">
<div class="slide-caption">3. Publish - Single Week Selection</div>
</div>
<div class="slide">
<img src="screenshots/04-publish_multi_week.png" alt="Publish Multi-Week">
<div class="slide-caption">4. Publish - Multi-Week Range</div>
</div>
<div class="slide">
<img src="screenshots/05-publish_single_article.png" alt="Publish Single Article">
<div class="slide-caption">5. Publish - Single Article</div>
</div>
<div class="slide">
<img src="screenshots/06-issues.png" alt="Issues Archive">
<div class="slide-caption">6. Issues Archive</div>
</div>
<div class="slide">
<img src="screenshots/07-reader.png" alt="Web Reader">
<div class="slide-caption">7. Built-in Web Reader</div>
</div>
<div class="slide">
<img src="screenshots/08-settings.png" alt="Settings">
<div class="slide-caption">8. Application Settings</div>
</div>
</div>
<button class="nav-btn next-btn" onclick="moveSlide(1)">&#10095;</button>
</div>
<div class="thumbnails" id="thumbnails">
<img src="screenshots/01-dashboard.png" class="thumb active" onclick="setSlide(0)">
<img src="screenshots/02-articles.png" class="thumb" onclick="setSlide(1)">
<img src="screenshots/03-publish.png" class="thumb" onclick="setSlide(2)">
<img src="screenshots/04-publish_multi_week.png" class="thumb" onclick="setSlide(3)">
<img src="screenshots/05-publish_single_article.png" class="thumb" onclick="setSlide(4)">
<img src="screenshots/06-issues.png" class="thumb" onclick="setSlide(5)">
<img src="screenshots/07-reader.png" class="thumb" onclick="setSlide(6)">
<img src="screenshots/08-settings.png" class="thumb" onclick="setSlide(7)">
</div>
<a href="https://code.cottongin.xyz/cottongin/pi-weekly-newspaper" class="back-link">&larr; Back to README</a>
<script>
let currentSlide = 0;
const slides = document.getElementById('slides');
const totalSlides = document.querySelectorAll('.slide').length;
const thumbs = document.querySelectorAll('.thumb');
function updateGallery() {
slides.style.transform = `translateX(-${currentSlide * 100}%)`;
thumbs.forEach((t, i) => {
t.classList.toggle('active', i === currentSlide);
});
}
function moveSlide(direction) {
currentSlide = (currentSlide + direction + totalSlides) % totalSlides;
updateGallery();
}
function setSlide(index) {
currentSlide = index;
updateGallery();
}
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') moveSlide(-1);
if (e.key === 'ArrowRight') moveSlide(1);
});
</script>
</body>
</html>

35
docs/screenshots.md Normal file
View File

@@ -0,0 +1,35 @@
# Plymouth Independent Weekly - Screenshots
Here is a detailed look at the various screens and features of the application.
## 1. Dashboard
The main dashboard provides an overview of the scheduler status and the latest generated issue.
![Dashboard](screenshots/01-dashboard.png)
## 2. Articles
The articles view lists all fetched articles and allows filtering by week and category.
![Articles](screenshots/02-articles.png)
## 3. Publish - Single Week
The publish screen features a combined week selector. Here, a single week is selected.
![Publish Single Week](screenshots/03-publish.png)
## 4. Publish - Multi-Week Range
The combined week selector also allows selecting a range of weeks to compile into a single issue.
![Publish Multi-Week](screenshots/04-publish_multi_week.png)
## 5. Publish - Single Article
You can also choose to publish a single article as a standalone issue.
![Publish Single Article](screenshots/05-publish_single_article.png)
## 6. Issues Archive
The issues archive lists all generated issues, allowing you to read, download, push to a library, or regenerate them.
![Issues](screenshots/06-issues.png)
## 7. Web Reader
The built-in web reader provides a clean, distraction-free reading experience for generated issues.
![Reader](screenshots/07-reader.png)
## 8. Settings
The settings screen allows you to configure the RSS feed, auto-publish schedule, and ePub cover dimensions.
![Settings](screenshots/08-settings.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

@@ -7,3 +7,4 @@ beautifulsoup4==4.12.*
Pillow==11.*
requests==2.32.*
pytest==8.*
python-dotenv==1.0.*

61
scripts/make_mosaic.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from PIL import Image, ImageDraw, ImageFont
def create_mosaic(image_paths, output_path, columns, rows, target_width, padding=20, bg_color=(245, 245, 245)):
images = []
for path in image_paths:
img = Image.open(path)
# Calculate new height to maintain aspect ratio
aspect_ratio = img.height / img.width
target_height = int(target_width * aspect_ratio)
img = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
images.append(img)
if not images:
return
# All images should be same size now
img_w, img_h = images[0].size
mosaic_w = (img_w * columns) + (padding * (columns + 1))
mosaic_h = (img_h * rows) + (padding * (rows + 1))
mosaic = Image.new('RGB', (mosaic_w, mosaic_h), bg_color)
for i, img in enumerate(images):
if i >= columns * rows:
break
col = i % columns
row = i // columns
x = padding + col * (img_w + padding)
y = padding + row * (img_h + padding)
mosaic.paste(img, (x, y))
# Add a subtle shadow/border
draw = ImageDraw.Draw(mosaic)
draw.rectangle([x-1, y-1, x+img_w, y+img_h], outline=(200, 200, 200), width=1)
mosaic.save(output_path, quality=95)
print(f"Saved {output_path}")
if __name__ == '__main__':
src_dir = 'temp_screenshots'
out_dir = 'docs/screenshots'
os.makedirs(out_dir, exist_ok=True)
# Get the 8 numbered screenshots
files = sorted([f for f in os.listdir(src_dir) if f.startswith('0') and f.endswith('.png')])
paths = [os.path.join(src_dir, f) for f in files]
# Copy them to docs/screenshots
import shutil
for f in files:
shutil.copy(os.path.join(src_dir, f), os.path.join(out_dir, f))
# Create large mosaic (4x2 grid, each image 800px wide)
create_mosaic(paths, os.path.join(out_dir, 'mosaic_large.png'), columns=4, rows=2, target_width=800)
# Create small mosaic (4x2 grid, each image 250px wide)
create_mosaic(paths, os.path.join(out_dir, 'mosaic_small.png'), columns=4, rows=2, target_width=250)

View File

@@ -0,0 +1,29 @@
from datetime import timezone
from zoneinfo import ZoneInfo
from app import create_app, db
from src.models import Article, Issue
def migrate():
app = create_app(start_scheduler=False)
local_tz = ZoneInfo("America/New_York")
with app.app_context():
# Migrate Articles
for article in Article.query.all():
# Assume existing naive datetime is UTC
utc_dt = article.pub_date.replace(tzinfo=timezone.utc)
article.pub_date = utc_dt.astimezone(local_tz).replace(tzinfo=None)
utc_fetched = article.fetched_at.replace(tzinfo=timezone.utc)
article.fetched_at = utc_fetched.astimezone(local_tz).replace(tzinfo=None)
# Migrate Issues
for issue in Issue.query.all():
utc_created = issue.created_at.replace(tzinfo=timezone.utc)
issue.created_at = utc_created.astimezone(local_tz).replace(tzinfo=None)
db.session.commit()
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -17,15 +17,11 @@ def _get_font(size: int) -> ImageFont.FreeTypeFont:
except OSError:
pass
try:
return ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", size)
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size, index=1)
except OSError:
pass
try:
return ImageFont.truetype("/Library/Fonts/Arial Bold.ttf", size)
except OSError:
pass
try:
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
return ImageFont.truetype("/System/Library/Fonts/HelveticaNeue.ttc", size, index=1)
except OSError:
pass
return ImageFont.load_default()
@@ -84,7 +80,7 @@ def _draw_text_overlays(
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
)
max_width = width - 32
y = strip_top + 8
y = strip_top + 14
for headline in headlines[:max_headlines]:
text = f"\u2022 {headline}"
text = _truncate_to_width(text, headline_font, max_width)

View File

@@ -179,6 +179,6 @@ def build_epub(
else:
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}-{ts}.epub"
epub_path = os.path.join(output_dir, filename)
epub.write_epub(epub_path, book)
epub.write_epub(epub_path, book, options={'ignore_ncx': True})
return epub_path

View File

@@ -1,6 +1,7 @@
import json
import logging
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import feedparser
import requests
@@ -44,6 +45,9 @@ def fetch_and_cache_articles() -> dict:
except Exception:
pub_date = datetime.now(timezone.utc)
# Convert to US/Eastern and make naive for SQLite
pub_date = pub_date.astimezone(ZoneInfo("America/New_York")).replace(tzinfo=None)
categories = [t.term for t in entry.get("tags", [])]
content_html = ""

View File

@@ -1,8 +1,13 @@
import json
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app import db
def get_local_now():
return datetime.now(ZoneInfo("America/New_York")).replace(tzinfo=None)
class Article(db.Model):
__tablename__ = "articles"
@@ -15,7 +20,7 @@ class Article(db.Model):
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)
db.DateTime, nullable=False, default=get_local_now
)
images = db.relationship("Image", backref="article", lazy=True,
@@ -45,7 +50,7 @@ class Issue(db.Model):
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)
db.DateTime, nullable=False, default=get_local_now
)
status = db.Column(db.Text, nullable=False, default="draft")
issue_type = db.Column(db.Text, nullable=False, default="weekly")

View File

@@ -1,5 +1,6 @@
import os
import json
import requests
from flask import Blueprint, render_template, send_file, redirect, url_for, flash
from app import db
@@ -26,7 +27,7 @@ def index():
@issues_bp.route("/issues/<int:issue_id>/download")
def download(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
@@ -39,7 +40,7 @@ def download(issue_id):
@issues_bp.route("/issues/<int:issue_id>/epub")
def epub_file(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
@@ -48,7 +49,7 @@ def epub_file(issue_id):
@issues_bp.route("/issues/<int:issue_id>/cover")
def cover_image(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, 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"))
@@ -57,7 +58,7 @@ def cover_image(issue_id):
@issues_bp.route("/issues/<int:issue_id>/read")
def read(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
@@ -65,7 +66,7 @@ def read(issue_id):
article_count = len(json.loads(issue.article_ids))
if issue.issue_type == "single_article":
article_ids = json.loads(issue.article_ids)
article = Article.query.get(article_ids[0]) if article_ids else None
article = db.session.get(Article, article_ids[0]) if article_ids else None
title = article.title if article else "Single Article"
elif issue.issue_type == "multi_week":
w1 = issue.week_start.isocalendar()[1]
@@ -84,7 +85,7 @@ def read(issue_id):
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
def delete(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if issue.epub_path and os.path.exists(issue.epub_path):
os.remove(issue.epub_path)
@@ -100,7 +101,7 @@ def delete(issue_id):
@issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"])
def regenerate(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
article_ids = json.loads(issue.article_ids)
articles_for_issue = (
@@ -145,3 +146,102 @@ def regenerate(issue_id):
flash(f"Regeneration failed: {e}", "error")
return redirect(url_for("issues.index"))
@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"))

View File

@@ -31,10 +31,12 @@ def _calendar_data(year: int, month: int) -> list[dict]:
week_end = current + timedelta(days=6)
iso_week = week_start.isocalendar()[1]
article_count = Article.query.filter(
week_articles = Article.query.filter(
Article.pub_date >= str(week_start),
Article.pub_date < str(week_end + timedelta(days=1)),
).count()
).all()
article_count = len(week_articles)
article_dates = {a.pub_date.date() for a in week_articles}
days = []
for i in range(7):
@@ -43,6 +45,7 @@ def _calendar_data(year: int, month: int) -> list[dict]:
"day": d.day,
"date": d.isoformat(),
"in_month": d.month == month,
"has_articles": d in article_dates,
})
weeks.append({

View File

@@ -22,9 +22,17 @@ class SchedulerManager:
interval = Setting.get(
"fetch_interval_hours", default=config.FETCH_INTERVAL_HOURS
)
# Calculate the next half-hour mark (XX:00 or XX:30)
now = datetime.now()
if now.minute < 30:
next_run = now.replace(minute=30, second=0, microsecond=0)
else:
next_run = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
self.scheduler.add_job(
self._run_fetch,
IntervalTrigger(hours=interval),
IntervalTrigger(hours=interval, start_date=next_run),
id="rss_fetch",
replace_existing=True,
)
@@ -153,14 +161,14 @@ class SchedulerManager:
def get_status(self) -> dict:
status = {"running": self.scheduler.running}
fetch_job = self.scheduler.get_job("rss_fetch")
if fetch_job:
if fetch_job and fetch_job.next_run_time:
status["rss_fetch"] = {
"next_run": str(fetch_job.next_run_time),
"next_run": fetch_job.next_run_time.strftime("%b %d, %Y %I:%M %p"),
"interval_hours": fetch_job.trigger.interval.total_seconds() / 3600,
}
pub_job = self.scheduler.get_job("auto_publish")
if pub_job:
if pub_job and pub_job.next_run_time:
status["auto_publish"] = {
"next_run": str(pub_job.next_run_time),
"next_run": pub_job.next_run_time.strftime("%b %d, %Y %I:%M %p"),
}
return status

View File

@@ -62,17 +62,25 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
padding: 0.5rem 1.2rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-size: 0.95rem;
color: var(--pico-muted-color);
}
.tab:focus {
outline: none;
box-shadow: none;
}
.tab.active {
color: var(--pico-primary);
color: var(--pico-primary-inverse);
background: var(--pico-primary);
border-bottom-color: var(--pico-primary);
border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0;
font-weight: 600;
}
.tab:hover { color: var(--pico-primary); }
.tab.active:hover { color: var(--pico-primary-inverse); }
.tab-content { display: none; }
.tab-content.active { display: block; }
@@ -103,17 +111,49 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
cursor: pointer;
}
.cal-week, .cal-week-multi { cursor: pointer; }
.cal-week:hover, .cal-week-multi:hover {
background: var(--pico-secondary-hover-background);
.cal-week:hover > td, .cal-week-multi:hover > td {
background: var(--pico-primary-hover-background);
color: var(--pico-primary-inverse);
}
.cal-week.selected, .cal-week-multi.selected {
.cal-week:hover .cal-wk, .cal-week-multi:hover .cal-wk {
color: var(--pico-primary-inverse);
}
.cal-week:hover .has-articles::after, .cal-week-multi:hover .has-articles::after {
background-color: var(--pico-primary-inverse);
}
.cal-week.selected > td, .cal-week-multi.selected > td {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.cal-week-multi.in-range {
.cal-week-multi.in-range > td {
background: var(--pico-primary-focus);
color: var(--pico-primary-inverse);
}
.cal-week.selected .cal-wk, .cal-week-multi.selected .cal-wk,
.cal-week-multi.in-range .cal-wk {
color: var(--pico-primary-inverse);
}
.has-articles {
font-weight: bold;
position: relative;
}
.has-articles::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--pico-primary);
}
.cal-week.selected .has-articles::after,
.cal-week-multi.selected .has-articles::after,
.cal-week-multi.in-range .has-articles::after {
background-color: var(--pico-primary-inverse);
}
/* Publish page */
.publish-summary {

View File

@@ -21,10 +21,10 @@
<hgroup>
<h3>Scheduler</h3>
<p>
Status: <strong>{{ "Running" if scheduler_status.running else "Stopped" }}</strong>
Status: <strong>{{ "Enabled" if scheduler_status.running else "Disabled" }}</strong>
{% if scheduler_status.rss_fetch %}
· Next fetch: {{ scheduler_status.rss_fetch.next_run }}
· Interval: {{ scheduler_status.rss_fetch.interval_hours }}h
&middot; Next fetch: {{ scheduler_status.rss_fetch.next_run }}
&middot; Interval: {{ scheduler_status.rss_fetch.interval_hours }}h
{% endif %}
</p>
</hgroup>

View File

@@ -29,6 +29,11 @@
<td class="issue-actions">
<a href="/issues/{{ item.issue.id }}/read" role="button" class="outline">Read</a>
<a href="/issues/{{ item.issue.id }}/download" role="button" class="outline">Download</a>
{% if config.GRIMMORY_URL and config.GRIMMORY_USERNAME and config.GRIMMORY_PASSWORD %}
<form method="post" action="/issues/{{ item.issue.id }}/push">
<button type="submit" class="outline">Push to Library</button>
</form>
{% endif %}
<form method="post" action="/issues/{{ item.issue.id }}/regenerate">
<button type="submit" class="outline contrast">Regenerate</button>
</form>

View File

@@ -4,13 +4,13 @@
<h1>Publish Issue</h1>
<div class="tab-bar">
<button class="tab active" data-tab="weekly" onclick="switchTab('weekly')">Weekly Issue</button>
<button class="tab" data-tab="multi-week" onclick="switchTab('multi-week')">Multi-Week</button>
<button class="tab active" data-tab="weekly" onclick="switchTab('weekly')">By Week</button>
<button class="tab" data-tab="single-article" onclick="switchTab('single-article')">Single Article</button>
</div>
<!-- WEEKLY TAB -->
<div id="tab-weekly" class="tab-content active">
<p><small><em>Select a single week, or click two different weeks to select a range.</em></small></p>
<div class="calendar-widget">
<div class="calendar-nav">
<button class="outline" onclick="changeMonth(-1)">&#9664;</button>
@@ -26,10 +26,10 @@
<tbody id="cal-body">
{% for week in calendar_weeks %}
<tr class="cal-week" data-start="{{ week.week_start }}" data-end="{{ week.week_end }}"
onclick="selectWeek(this)">
onclick="selectWeekMulti(this)" role="button" tabindex="0">
<td class="cal-wk">{{ week.iso_week }}{% if week.article_count %} <small>({{ week.article_count }})</small>{% endif %}</td>
{% for day in week.days %}
<td class="{% if not day.in_month %}cal-dim{% endif %}">{{ day.day }}</td>
<td class="{% if not day.in_month %}cal-dim{% endif %} {% if day.has_articles %}has-articles{% endif %}">{{ day.day }}</td>
{% endfor %}
</tr>
{% endfor %}
@@ -41,7 +41,7 @@
<form method="post" action="/publish" id="weekly-form">
<input type="hidden" name="week_start" id="weekly-start">
<input type="hidden" name="week_end" id="weekly-end">
<input type="hidden" name="issue_type" value="weekly">
<input type="hidden" name="issue_type" id="weekly-issue-type" value="weekly">
<div id="weekly-checkboxes"></div>
<div class="publish-actions">
<select name="cover_method">
@@ -53,48 +53,6 @@
</form>
</div>
<!-- MULTI-WEEK TAB -->
<div id="tab-multi-week" class="tab-content">
<div class="calendar-widget">
<div class="calendar-nav">
<button class="outline" onclick="changeMonthMulti(-1)">&#9664;</button>
<strong id="cal-month-label-multi">{{ cal_month_name }}</strong>
<button class="outline" onclick="changeMonthMulti(1)">&#9654;</button>
</div>
<table class="calendar-grid">
<thead>
<tr>
<th>Wk</th><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th>
</tr>
</thead>
<tbody id="cal-body-multi">
{% for week in calendar_weeks %}
<tr class="cal-week-multi" data-start="{{ week.week_start }}" data-end="{{ week.week_end }}"
onclick="selectWeekMulti(this)">
<td class="cal-wk">{{ week.iso_week }}{% if week.article_count %} <small>({{ week.article_count }})</small>{% endif %}</td>
{% for day in week.days %}
<td class="{% if not day.in_month %}cal-dim{% endif %}">{{ day.day }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="multi-summary" class="publish-summary"></div>
<form method="post" action="/publish" id="multi-form">
<input type="hidden" name="week_start" id="multi-start">
<input type="hidden" name="week_end" id="multi-end">
<input type="hidden" name="issue_type" value="multi_week">
<div id="multi-checkboxes"></div>
<div class="publish-actions">
<select name="cover_method">
<option value="mosaic">Mosaic Cover</option>
<option value="text">Text Cover</option>
</select>
<button type="submit">Generate Issue</button>
</div>
</form>
</div>
<!-- SINGLE ARTICLE TAB -->
<div id="tab-single-article" class="tab-content">
@@ -132,10 +90,7 @@
<script>
let calYear = {{ cal_year }};
let calMonth = {{ cal_month }};
let calYearMulti = {{ cal_year }};
let calMonthMulti = {{ cal_month }};
let multiStart = null;
let multiEnd = null;
let selectedWeeks = [];
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -157,57 +112,58 @@ async function loadArticles(start, end, targetId) {
return articles.length;
}
async function selectWeek(row) {
document.querySelectorAll('.cal-week').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
const start = row.dataset.start;
const end = row.dataset.end;
document.getElementById('weekly-start').value = start;
document.getElementById('weekly-end').value = end;
const count = await loadArticles(start, end, 'weekly-checkboxes');
const ws = new Date(start + 'T00:00:00');
const we = new Date(end + 'T00:00:00');
const opts = { month: 'short', day: 'numeric' };
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
document.getElementById('weekly-summary').innerHTML =
`<strong>Week ${row.cells[0].textContent.trim().split(' ')[0]}:</strong> ${ws.toLocaleDateString('en-US', opts)} &ndash; ${we.toLocaleDateString('en-US', optsYear)} &middot; <strong>${count} articles</strong>`;
}
async function selectWeekMulti(row) {
const start = row.dataset.start;
const end = row.dataset.end;
if (!multiStart || (multiStart && multiEnd)) {
multiStart = { start, end, row };
multiEnd = null;
document.querySelectorAll('.cal-week-multi').forEach(r => r.classList.remove('selected', 'in-range'));
row.classList.add('selected');
// Toggle selection
const existingIndex = selectedWeeks.findIndex(w => w.start === start);
if (existingIndex >= 0) {
selectedWeeks.splice(existingIndex, 1);
row.classList.remove('selected');
} else {
multiEnd = { start, end, row };
if (multiEnd.start < multiStart.start) {
[multiStart, multiEnd] = [multiEnd, multiStart];
}
document.querySelectorAll('.cal-week-multi').forEach(r => {
r.classList.remove('selected', 'in-range');
if (r.dataset.start >= multiStart.start && r.dataset.end <= multiEnd.end) {
r.classList.add('in-range');
}
});
multiStart.row.classList.add('selected');
multiEnd.row.classList.add('selected');
selectedWeeks.push({ start, end, row });
row.classList.add('selected');
}
if (selectedWeeks.length === 0) {
document.getElementById('weekly-start').value = '';
document.getElementById('weekly-end').value = '';
document.getElementById('weekly-checkboxes').innerHTML = '';
document.getElementById('weekly-summary').innerHTML = '';
return;
}
// Sort selected weeks by start date
selectedWeeks.sort((a, b) => a.start.localeCompare(b.start));
const rangeStart = selectedWeeks[0].start;
const rangeEnd = selectedWeeks[selectedWeeks.length - 1].end;
document.getElementById('weekly-start').value = rangeStart;
document.getElementById('weekly-end').value = rangeEnd;
// Update issue_type based on selection
const issueTypeInput = document.getElementById('weekly-issue-type');
if (rangeStart === rangeEnd) {
issueTypeInput.value = 'weekly';
} else {
issueTypeInput.value = 'multi_week';
}
const rangeStart = multiStart.start;
const rangeEnd = (multiEnd || multiStart).end;
document.getElementById('multi-start').value = rangeStart;
document.getElementById('multi-end').value = rangeEnd;
const count = await loadArticles(rangeStart, rangeEnd, 'multi-checkboxes');
const count = await loadArticles(rangeStart, rangeEnd, 'weekly-checkboxes');
const ws = new Date(rangeStart + 'T00:00:00');
const we = new Date(rangeEnd + 'T00:00:00');
const opts = { month: 'short', day: 'numeric' };
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
document.getElementById('multi-summary').innerHTML =
`<strong>Range:</strong> ${ws.toLocaleDateString('en-US', opts)} &ndash; ${we.toLocaleDateString('en-US', optsYear)} &middot; <strong>${count} articles</strong>`;
let summaryHtml = '';
if (rangeStart === rangeEnd) {
summaryHtml = `<strong>Week ${selectedWeeks[0].row.cells[0].textContent.trim().split(' ')[0]}:</strong> ${ws.toLocaleDateString('en-US', opts)} &ndash; ${we.toLocaleDateString('en-US', optsYear)} &middot; <strong>${count} articles</strong>`;
} else {
summaryHtml = `<strong>Range:</strong> ${ws.toLocaleDateString('en-US', opts)} &ndash; ${we.toLocaleDateString('en-US', optsYear)} &middot; <strong>${count} articles</strong>`;
}
document.getElementById('weekly-summary').innerHTML = summaryHtml;
}
async function changeMonth(delta) {
@@ -216,32 +172,29 @@ async function changeMonth(delta) {
if (calMonth < 1) { calMonth = 12; calYear--; }
const resp = await fetch(`/publish/calendar?year=${calYear}&month=${calMonth}`);
const weeks = await resp.json();
renderCalendar(weeks, 'cal-body', 'cal-week', 'selectWeek(this)');
renderCalendar(weeks, 'cal-body', 'cal-week', 'selectWeekMulti(this)');
document.getElementById('cal-month-label').textContent =
new Date(calYear, calMonth - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
async function changeMonthMulti(delta) {
calMonthMulti += delta;
if (calMonthMulti > 12) { calMonthMulti = 1; calYearMulti++; }
if (calMonthMulti < 1) { calMonthMulti = 12; calYearMulti--; }
const resp = await fetch(`/publish/calendar?year=${calYearMulti}&month=${calMonthMulti}`);
const weeks = await resp.json();
renderCalendar(weeks, 'cal-body-multi', 'cal-week-multi', 'selectWeekMulti(this)');
document.getElementById('cal-month-label-multi').textContent =
new Date(calYearMulti, calMonthMulti - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
multiStart = null;
multiEnd = null;
// Re-apply selected classes based on tracked selectedWeeks
document.querySelectorAll('.cal-week').forEach(row => {
if (selectedWeeks.some(w => w.start === row.dataset.start)) {
row.classList.add('selected');
}
});
}
function renderCalendar(weeks, tbodyId, rowClass, onclickFn) {
const tbody = document.getElementById(tbodyId);
tbody.innerHTML = weeks.map(w => {
const days = w.days.map(d =>
`<td class="${d.in_month ? '' : 'cal-dim'}">${d.day}</td>`
).join('');
const days = w.days.map(d => {
let classes = [];
if (!d.in_month) classes.push('cal-dim');
if (d.has_articles) classes.push('has-articles');
return `<td class="${classes.join(' ')}">${d.day}</td>`;
}).join('');
const count = w.article_count ? ` <small>(${w.article_count})</small>` : '';
return `<tr class="${rowClass}" data-start="${w.week_start}" data-end="${w.week_end}" onclick="${onclickFn}">
return `<tr class="${rowClass}" data-start="${w.week_start}" data-end="${w.week_end}" onclick="${onclickFn}" role="button" tabindex="0">
<td class="cal-wk">${w.iso_week}${count}</td>${days}
</tr>`;
}).join('');

View File

@@ -87,7 +87,10 @@ def test_build_epub_respects_article_order(app, db, tmp_path):
)
from ebooklib import epub as epublib
book = epublib.read_epub(epub_path)
import warnings
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=FutureWarning, module="ebooklib")
book = epublib.read_epub(epub_path, options={'ignore_ncx': True})
spine_items = [book.get_item_with_id(item_id)
for item_id, _ in book.spine if item_id != "nav"]
titles = []

View File

@@ -81,3 +81,19 @@ def test_fetch_handles_feed_error(app, db):
assert result["error"] is not None
assert Article.query.count() == 0
def test_fetch_converts_timezone_to_eastern(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(title="Test Article One").first()
# The XML has: Mon, 06 Apr 2026 12:00:00 +0000
# US/Eastern in April is EDT (UTC-4), so it should be 08:00:00
assert article.pub_date.tzinfo is None
assert article.pub_date.hour == 8
assert article.pub_date.minute == 0

View File

@@ -3,10 +3,12 @@ import os
from datetime import date, datetime
from unittest.mock import MagicMock, patch
import requests
from PIL import Image as PILImage
from app import db
from src.models import Article, Image, Issue
import config
def test_regenerate_passes_ordered_image_paths_to_generate_cover(app, client, db, tmp_path):
@@ -140,3 +142,220 @@ def test_regenerate_maps_ai_cover_method_to_mosaic(app, client, db, tmp_path):
with app.app_context():
updated = db.session.get(Issue, issue_id)
assert updated.cover_method == "mosaic"
def test_push_issue_success(app, client, db, tmp_path):
"""Test successful push to Grimmory API."""
os.makedirs(tmp_path, exist_ok=True)
epub_path = tmp_path / "test.epub"
epub_path.write_text("dummy epub content")
with app.app_context():
issue = Issue(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
cover_method="mosaic",
cover_path=str(tmp_path / "cover.jpg"),
epub_path=str(epub_path),
article_ids=json.dumps([1]),
status="published",
)
db.session.add(issue)
db.session.commit()
issue_id = issue.id
mock_login_resp = MagicMock()
mock_login_resp.status_code = 200
mock_login_resp.json.return_value = {"token": "test_token"}
mock_libraries_resp = MagicMock()
mock_libraries_resp.status_code = 200
mock_libraries_resp.json.return_value = [{"id": 123, "name": "lib123", "paths": [{"id": 456}]}]
mock_upload_resp = MagicMock()
mock_upload_resp.status_code = 200
def mock_post(url, *args, **kwargs):
if url.endswith("/api/v1/auth/login"):
return mock_login_resp
elif url.endswith("/api/v1/files/upload"):
return mock_upload_resp
return MagicMock(status_code=404)
def mock_get(url, *args, **kwargs):
if url.endswith("/api/v1/libraries"):
return mock_libraries_resp
return MagicMock(status_code=404)
with patch("src.routes.issues.requests.post", side_effect=mock_post) as mock_post_call, \
patch("src.routes.issues.requests.get", side_effect=mock_get) as mock_get_call:
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
patch.object(config, "GRIMMORY_PASSWORD", "password"), \
patch.object(config, "GRIMMORY_LIBRARY_ID", "lib123"):
resp = client.post(f"/issues/{issue_id}/push")
assert resp.status_code in (302, 303)
assert mock_post_call.call_count == 2
assert mock_get_call.call_count == 1
# Check login call
login_args, login_kwargs = mock_post_call.call_args_list[0]
assert login_args[0] == "http://grimmory.test/api/v1/auth/login"
assert login_kwargs["json"] == {"username": "admin", "password": "password"}
# Check libraries call
get_args, get_kwargs = mock_get_call.call_args_list[0]
assert get_args[0] == "http://grimmory.test/api/v1/libraries"
assert get_kwargs["headers"] == {"Authorization": "Bearer test_token"}
# Check upload call
upload_args, upload_kwargs = mock_post_call.call_args_list[1]
assert upload_args[0] == "http://grimmory.test/api/v1/files/upload"
assert upload_kwargs["headers"] == {"Authorization": "Bearer test_token"}
assert upload_kwargs["data"] == {"libraryId": 123, "pathId": 456}
assert "file" in upload_kwargs["files"]
def test_push_issue_missing_config(app, client, db, tmp_path):
"""Test push fails gracefully when config is missing."""
with app.app_context():
issue = Issue(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
cover_method="mosaic",
cover_path=str(tmp_path / "cover.jpg"),
epub_path=str(tmp_path / "test.epub"),
article_ids=json.dumps([1]),
status="published",
)
db.session.add(issue)
db.session.commit()
issue_id = issue.id
with patch("src.routes.issues.requests.post") as mock_post:
with patch.object(config, "GRIMMORY_URL", None), \
patch.object(config, "GRIMMORY_USERNAME", None), \
patch.object(config, "GRIMMORY_PASSWORD", None):
resp = client.post(f"/issues/{issue_id}/push")
assert resp.status_code in (302, 303)
mock_post.assert_not_called()
def test_push_issue_missing_file(app, client, db, tmp_path):
"""Test push fails gracefully when epub file is missing."""
with app.app_context():
issue = Issue(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
cover_method="mosaic",
cover_path=str(tmp_path / "cover.jpg"),
epub_path="/nonexistent/path/test.epub",
article_ids=json.dumps([1]),
status="published",
)
db.session.add(issue)
db.session.commit()
issue_id = issue.id
with patch("src.routes.issues.requests.post") as mock_post:
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
patch.object(config, "GRIMMORY_PASSWORD", "password"):
resp = client.post(f"/issues/{issue_id}/push")
assert resp.status_code in (302, 303)
mock_post.assert_not_called()
def test_push_issue_login_error(app, client, db, tmp_path):
"""Test push handles login errors gracefully."""
os.makedirs(tmp_path, exist_ok=True)
epub_path = tmp_path / "test.epub"
epub_path.write_text("dummy epub content")
with app.app_context():
issue = Issue(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
cover_method="mosaic",
cover_path=str(tmp_path / "cover.jpg"),
epub_path=str(epub_path),
article_ids=json.dumps([1]),
status="published",
)
db.session.add(issue)
db.session.commit()
issue_id = issue.id
mock_login_resp = MagicMock()
mock_login_resp.status_code = 401
mock_login_resp.text = "Unauthorized"
with patch("src.routes.issues.requests.post", return_value=mock_login_resp) as mock_post_call:
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
patch.object(config, "GRIMMORY_PASSWORD", "wrong"):
resp = client.post(f"/issues/{issue_id}/push")
assert resp.status_code in (302, 303)
assert mock_post_call.call_count == 1
login_args, _ = mock_post_call.call_args_list[0]
assert login_args[0] == "http://grimmory.test/api/v1/auth/login"
def test_push_issue_api_error(app, client, db, tmp_path):
"""Test push handles API errors gracefully."""
os.makedirs(tmp_path, exist_ok=True)
epub_path = tmp_path / "test.epub"
epub_path.write_text("dummy epub content")
with app.app_context():
issue = Issue(
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
cover_method="mosaic",
cover_path=str(tmp_path / "cover.jpg"),
epub_path=str(epub_path),
article_ids=json.dumps([1]),
status="published",
)
db.session.add(issue)
db.session.commit()
issue_id = issue.id
mock_login_resp = MagicMock()
mock_login_resp.status_code = 200
mock_login_resp.json.return_value = {"token": "test_token"}
mock_upload_resp = MagicMock()
mock_upload_resp.status_code = 500
mock_upload_resp.text = "Internal Server Error"
def mock_post(url, *args, **kwargs):
if url.endswith("/api/v1/auth/login"):
return mock_login_resp
elif url.endswith("/api/v1/files/upload"):
return mock_upload_resp
return MagicMock(status_code=404)
def mock_get(url, *args, **kwargs):
return MagicMock(status_code=200, json=lambda: [{"id": 123, "name": "lib123", "paths": [{"id": 456}]}])
with patch("src.routes.issues.requests.post", side_effect=mock_post) as mock_post_call, \
patch("src.routes.issues.requests.get", side_effect=mock_get) as mock_get_call:
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
patch.object(config, "GRIMMORY_PASSWORD", "password"), \
patch.object(config, "GRIMMORY_LIBRARY_ID", "lib123"):
resp = client.post(f"/issues/{issue_id}/push")
assert resp.status_code in (302, 303)
assert mock_post_call.call_count == 2

View File

@@ -1,5 +1,6 @@
import json
from datetime import datetime, date
from zoneinfo import ZoneInfo
from src.models import Article, Image, Issue, Setting
@@ -84,3 +85,45 @@ def test_setting_crud(db):
Setting.set("fetch_interval", 4)
assert Setting.get("fetch_interval") == 4
def test_article_default_fetched_at(db):
article = Article(
guid="https://example.com/?p=101",
title="Test Article 2",
author="Test Author",
pub_date=datetime(2026, 4, 6, 12, 0, 0),
categories="[]",
link="https://example.com/test2",
content_html="<p>Test content</p>",
)
db.session.add(article)
db.session.commit()
saved = Article.query.filter_by(guid="https://example.com/?p=101").first()
assert saved.fetched_at.tzinfo is None
# Should be close to current US/Eastern time
local_now = datetime.now(ZoneInfo("America/New_York")).replace(tzinfo=None)
diff = abs((local_now - saved.fetched_at).total_seconds())
assert diff < 10
def test_issue_default_created_at(db):
issue = Issue(
week_start=date(2026, 4, 13),
week_end=date(2026, 4, 19),
cover_method="text",
cover_path="data/issues/cover2.jpg",
epub_path="data/issues/test2.epub",
article_ids="[]",
excluded_article_ids="[]",
status="draft",
)
db.session.add(issue)
db.session.commit()
saved = Issue.query.filter_by(week_start=date(2026, 4, 13)).first()
assert saved.created_at.tzinfo is None
local_now = datetime.now(ZoneInfo("America/New_York")).replace(tzinfo=None)
diff = abs((local_now - saved.created_at).total_seconds())
assert diff < 10

View File

@@ -1,6 +1,7 @@
import json
import os
from datetime import date, datetime
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from unittest.mock import MagicMock, patch
from PIL import Image as PILImage
@@ -63,6 +64,48 @@ def test_scheduler_get_status(app):
mgr.shutdown()
def test_scheduler_starts_on_half_hour(app):
with app.app_context():
mgr = SchedulerManager(app)
# Test when current time is before :30
with patch('src.scheduler.datetime') as mock_dt:
# Use a date far in the future so APScheduler doesn't fast-forward it
mock_dt.now.return_value = datetime(2030, 4, 6, 10, 15, 0)
mgr.start()
job = mgr.scheduler.get_job("rss_fetch")
assert job.next_run_time.minute == 30
assert job.next_run_time.hour == 10
mgr.shutdown()
mgr = SchedulerManager(app)
# Test when current time is after :30
with patch('src.scheduler.datetime') as mock_dt:
mock_dt.now.return_value = datetime(2030, 4, 6, 10, 45, 0)
mgr.start()
job = mgr.scheduler.get_job("rss_fetch")
assert job.next_run_time.minute == 0
assert job.next_run_time.hour == 11
mgr.shutdown()
def test_scheduler_get_status_formatting(app):
with app.app_context():
mgr = SchedulerManager(app)
with patch('src.scheduler.datetime') as mock_dt:
# Force it to start at exactly 10:30 in the future
mock_dt.now.return_value = datetime(2030, 4, 6, 10, 15, 0)
mgr.start()
# The job's next_run_time will be timezone aware in APScheduler
status = mgr.get_status()
assert "rss_fetch" in status
# Should look like "Apr 06, 2030 10:30 AM"
assert "Apr 06, 2030 10:30 AM" in status["rss_fetch"]["next_run"]
mgr.shutdown()
def test_auto_publish_passes_ordered_image_paths_to_generate_cover(app, tmp_path, db):
"""First image per article in pub_date order passed to generate_cover."""
os.makedirs(tmp_path, exist_ok=True)