Compare commits
10 Commits
856978f3cb
...
0abfe17914
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0abfe17914
|
||
|
|
b0ac44152f
|
||
|
|
c5e5836ac1
|
||
|
|
0d1a898caa
|
||
|
|
767285119b
|
||
|
|
2cf44fe642
|
||
|
|
e40023b9f9
|
||
|
|
f7b424b692
|
||
|
|
7c9c32bd0e
|
||
|
|
775df4b79e
|
1
.gitignore
vendored
@@ -2,6 +2,7 @@ data/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
.env
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
21
LICENSE
Normal 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.
|
||||||
23
README.md
@@ -1,7 +1,17 @@
|
|||||||
# PI Weekly Newspaper
|
# PI Weekly Newspaper
|
||||||
|
|
||||||
|
[](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).
|
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](docs/gallery.html)
|
||||||
|
|
||||||
|
## Screenshots & Gallery
|
||||||
|
|
||||||
|
- **[Interactive HTML Gallery](docs/gallery.html)** - A slide deck view of the app's features.
|
||||||
|
- **[Markdown Screenshots List](docs/screenshots.md)** - A full page showing all screenshots with descriptions.
|
||||||
|
- **[Large Mosaic](docs/screenshots/mosaic_large.png)** - A high-resolution 4x2 grid of all screenshots (great for sharing).
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -37,6 +47,19 @@ Settings are editable via the web UI at `/settings`, or in `config.py`:
|
|||||||
- `FETCH_INTERVAL_HOURS` — how often to check for new articles
|
- `FETCH_INTERVAL_HOURS` — how often to check for new articles
|
||||||
- `IMAGE_MAX_LANDSCAPE` / `IMAGE_MAX_PORTRAIT` — image bounding box dimensions
|
- `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
|
## 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 your Mac's IP address (e.g., `http://192.168.1.x:5000`).
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
@@ -14,3 +17,9 @@ FETCH_INTERVAL_HOURS = 1
|
|||||||
IMAGE_MAX_LANDSCAPE = (800, 480)
|
IMAGE_MAX_LANDSCAPE = (800, 480)
|
||||||
IMAGE_MAX_PORTRAIT = (480, 800)
|
IMAGE_MAX_PORTRAIT = (480, 800)
|
||||||
COVER_SIZE = (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
@@ -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)">❮</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)">❯</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="../README.md" class="back-link">← 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
@@ -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.
|
||||||
|

|
||||||
|
|
||||||
|
## 2. Articles
|
||||||
|
The articles view lists all fetched articles and allows filtering by week and category.
|
||||||
|

|
||||||
|
|
||||||
|
## 3. Publish - Single Week
|
||||||
|
The publish screen features a combined week selector. Here, a single week is selected.
|
||||||
|

|
||||||
|
|
||||||
|
## 4. Publish - Multi-Week Range
|
||||||
|
The combined week selector also allows selecting a range of weeks to compile into a single issue.
|
||||||
|

|
||||||
|
|
||||||
|
## 5. Publish - Single Article
|
||||||
|
You can also choose to publish a single article as a standalone issue.
|
||||||
|

|
||||||
|
|
||||||
|
## 6. Issues Archive
|
||||||
|
The issues archive lists all generated issues, allowing you to read, download, push to a library, or regenerate them.
|
||||||
|

|
||||||
|
|
||||||
|
## 7. Web Reader
|
||||||
|
The built-in web reader provides a clean, distraction-free reading experience for generated issues.
|
||||||
|

|
||||||
|
|
||||||
|
## 8. Settings
|
||||||
|
The settings screen allows you to configure the RSS feed, auto-publish schedule, and ePub cover dimensions.
|
||||||
|

|
||||||
BIN
docs/screenshots/01-dashboard.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
docs/screenshots/02-articles.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
docs/screenshots/03-publish.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/screenshots/04-publish_multi_week.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/screenshots/05-publish_single_article.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/screenshots/06-issues.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/screenshots/07-reader.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/screenshots/08-settings.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/screenshots/mosaic_large.png
Normal file
|
After Width: | Height: | Size: 980 KiB |
BIN
docs/screenshots/mosaic_small.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
@@ -7,3 +7,4 @@ beautifulsoup4==4.12.*
|
|||||||
Pillow==11.*
|
Pillow==11.*
|
||||||
requests==2.32.*
|
requests==2.32.*
|
||||||
pytest==8.*
|
pytest==8.*
|
||||||
|
python-dotenv==1.0.*
|
||||||
|
|||||||
61
scripts/make_mosaic.py
Normal 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)
|
||||||
29
scripts/migrate_timezones.py
Normal 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()
|
||||||
10
src/cover.py
@@ -17,15 +17,11 @@ def _get_font(size: int) -> ImageFont.FreeTypeFont:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
try:
|
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:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
return ImageFont.truetype("/Library/Fonts/Arial Bold.ttf", size)
|
return ImageFont.truetype("/System/Library/Fonts/HelveticaNeue.ttc", size, index=1)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
@@ -84,7 +80,7 @@ def _draw_text_overlays(
|
|||||||
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
|
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
|
||||||
)
|
)
|
||||||
max_width = width - 32
|
max_width = width - 32
|
||||||
y = strip_top + 8
|
y = strip_top + 14
|
||||||
for headline in headlines[:max_headlines]:
|
for headline in headlines[:max_headlines]:
|
||||||
text = f"\u2022 {headline}"
|
text = f"\u2022 {headline}"
|
||||||
text = _truncate_to_width(text, headline_font, max_width)
|
text = _truncate_to_width(text, headline_font, max_width)
|
||||||
|
|||||||
@@ -179,6 +179,6 @@ def build_epub(
|
|||||||
else:
|
else:
|
||||||
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}-{ts}.epub"
|
filename = f"plymouth-independent-{week_start.year}-W{iso_week:02d}-{ts}.epub"
|
||||||
epub_path = os.path.join(output_dir, filename)
|
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
|
return epub_path
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
import requests
|
import requests
|
||||||
@@ -44,6 +45,9 @@ def fetch_and_cache_articles() -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pub_date = datetime.now(timezone.utc)
|
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", [])]
|
categories = [t.term for t in entry.get("tags", [])]
|
||||||
|
|
||||||
content_html = ""
|
content_html = ""
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_now():
|
||||||
|
return datetime.now(ZoneInfo("America/New_York")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
class Article(db.Model):
|
class Article(db.Model):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
@@ -15,7 +20,7 @@ class Article(db.Model):
|
|||||||
link = db.Column(db.Text, nullable=False)
|
link = db.Column(db.Text, nullable=False)
|
||||||
content_html = db.Column(db.Text, nullable=False, default="")
|
content_html = db.Column(db.Text, nullable=False, default="")
|
||||||
fetched_at = db.Column(
|
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,
|
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="[]")
|
article_ids = db.Column(db.Text, nullable=False, default="[]")
|
||||||
excluded_article_ids = db.Column(db.Text, nullable=False, default="[]")
|
excluded_article_ids = db.Column(db.Text, nullable=False, default="[]")
|
||||||
created_at = db.Column(
|
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")
|
status = db.Column(db.Text, nullable=False, default="draft")
|
||||||
issue_type = db.Column(db.Text, nullable=False, default="weekly")
|
issue_type = db.Column(db.Text, nullable=False, default="weekly")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
from flask import Blueprint, render_template, send_file, redirect, url_for, flash
|
from flask import Blueprint, render_template, send_file, redirect, url_for, flash
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
@@ -26,7 +27,7 @@ def index():
|
|||||||
|
|
||||||
@issues_bp.route("/issues/<int:issue_id>/download")
|
@issues_bp.route("/issues/<int:issue_id>/download")
|
||||||
def download(issue_id):
|
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):
|
if not os.path.exists(issue.epub_path):
|
||||||
flash("ePub file not found.", "error")
|
flash("ePub file not found.", "error")
|
||||||
return redirect(url_for("issues.index"))
|
return redirect(url_for("issues.index"))
|
||||||
@@ -39,7 +40,7 @@ def download(issue_id):
|
|||||||
|
|
||||||
@issues_bp.route("/issues/<int:issue_id>/epub")
|
@issues_bp.route("/issues/<int:issue_id>/epub")
|
||||||
def epub_file(issue_id):
|
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):
|
if not os.path.exists(issue.epub_path):
|
||||||
flash("ePub file not found.", "error")
|
flash("ePub file not found.", "error")
|
||||||
return redirect(url_for("issues.index"))
|
return redirect(url_for("issues.index"))
|
||||||
@@ -48,7 +49,7 @@ def epub_file(issue_id):
|
|||||||
|
|
||||||
@issues_bp.route("/issues/<int:issue_id>/cover")
|
@issues_bp.route("/issues/<int:issue_id>/cover")
|
||||||
def cover_image(issue_id):
|
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):
|
if not issue.cover_path or not os.path.exists(issue.cover_path):
|
||||||
flash("Cover image not found.", "error")
|
flash("Cover image not found.", "error")
|
||||||
return redirect(url_for("issues.index"))
|
return redirect(url_for("issues.index"))
|
||||||
@@ -57,7 +58,7 @@ def cover_image(issue_id):
|
|||||||
|
|
||||||
@issues_bp.route("/issues/<int:issue_id>/read")
|
@issues_bp.route("/issues/<int:issue_id>/read")
|
||||||
def read(issue_id):
|
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):
|
if not os.path.exists(issue.epub_path):
|
||||||
flash("ePub file not found.", "error")
|
flash("ePub file not found.", "error")
|
||||||
return redirect(url_for("issues.index"))
|
return redirect(url_for("issues.index"))
|
||||||
@@ -65,7 +66,7 @@ def read(issue_id):
|
|||||||
article_count = len(json.loads(issue.article_ids))
|
article_count = len(json.loads(issue.article_ids))
|
||||||
if issue.issue_type == "single_article":
|
if issue.issue_type == "single_article":
|
||||||
article_ids = json.loads(issue.article_ids)
|
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"
|
title = article.title if article else "Single Article"
|
||||||
elif issue.issue_type == "multi_week":
|
elif issue.issue_type == "multi_week":
|
||||||
w1 = issue.week_start.isocalendar()[1]
|
w1 = issue.week_start.isocalendar()[1]
|
||||||
@@ -84,7 +85,7 @@ def read(issue_id):
|
|||||||
|
|
||||||
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
|
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
|
||||||
def delete(issue_id):
|
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):
|
if issue.epub_path and os.path.exists(issue.epub_path):
|
||||||
os.remove(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"])
|
@issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"])
|
||||||
def regenerate(issue_id):
|
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)
|
article_ids = json.loads(issue.article_ids)
|
||||||
|
|
||||||
articles_for_issue = (
|
articles_for_issue = (
|
||||||
@@ -145,3 +146,102 @@ def regenerate(issue_id):
|
|||||||
flash(f"Regeneration failed: {e}", "error")
|
flash(f"Regeneration failed: {e}", "error")
|
||||||
|
|
||||||
return redirect(url_for("issues.index"))
|
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"))
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ def _calendar_data(year: int, month: int) -> list[dict]:
|
|||||||
week_end = current + timedelta(days=6)
|
week_end = current + timedelta(days=6)
|
||||||
iso_week = week_start.isocalendar()[1]
|
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_start),
|
||||||
Article.pub_date < str(week_end + timedelta(days=1)),
|
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 = []
|
days = []
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
@@ -43,6 +45,7 @@ def _calendar_data(year: int, month: int) -> list[dict]:
|
|||||||
"day": d.day,
|
"day": d.day,
|
||||||
"date": d.isoformat(),
|
"date": d.isoformat(),
|
||||||
"in_month": d.month == month,
|
"in_month": d.month == month,
|
||||||
|
"has_articles": d in article_dates,
|
||||||
})
|
})
|
||||||
|
|
||||||
weeks.append({
|
weeks.append({
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ class SchedulerManager:
|
|||||||
interval = Setting.get(
|
interval = Setting.get(
|
||||||
"fetch_interval_hours", default=config.FETCH_INTERVAL_HOURS
|
"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.scheduler.add_job(
|
||||||
self._run_fetch,
|
self._run_fetch,
|
||||||
IntervalTrigger(hours=interval),
|
IntervalTrigger(hours=interval, start_date=next_run),
|
||||||
id="rss_fetch",
|
id="rss_fetch",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
@@ -153,14 +161,14 @@ class SchedulerManager:
|
|||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
status = {"running": self.scheduler.running}
|
status = {"running": self.scheduler.running}
|
||||||
fetch_job = self.scheduler.get_job("rss_fetch")
|
fetch_job = self.scheduler.get_job("rss_fetch")
|
||||||
if fetch_job:
|
if fetch_job and fetch_job.next_run_time:
|
||||||
status["rss_fetch"] = {
|
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,
|
"interval_hours": fetch_job.trigger.interval.total_seconds() / 3600,
|
||||||
}
|
}
|
||||||
pub_job = self.scheduler.get_job("auto_publish")
|
pub_job = self.scheduler.get_job("auto_publish")
|
||||||
if pub_job:
|
if pub_job and pub_job.next_run_time:
|
||||||
status["auto_publish"] = {
|
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
|
return status
|
||||||
|
|||||||
@@ -62,17 +62,25 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
|
|||||||
padding: 0.5rem 1.2rem;
|
padding: 0.5rem 1.2rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--pico-muted-color);
|
color: var(--pico-muted-color);
|
||||||
}
|
}
|
||||||
|
.tab:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--pico-primary);
|
color: var(--pico-primary-inverse);
|
||||||
|
background: var(--pico-primary);
|
||||||
border-bottom-color: var(--pico-primary);
|
border-bottom-color: var(--pico-primary);
|
||||||
|
border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--pico-primary); }
|
.tab:hover { color: var(--pico-primary); }
|
||||||
|
.tab.active:hover { color: var(--pico-primary-inverse); }
|
||||||
.tab-content { display: none; }
|
.tab-content { display: none; }
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
|
|
||||||
@@ -103,17 +111,49 @@ nav .brand { font-weight: bold; font-size: 1.1rem; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cal-week, .cal-week-multi { cursor: pointer; }
|
.cal-week, .cal-week-multi { cursor: pointer; }
|
||||||
.cal-week:hover, .cal-week-multi:hover {
|
.cal-week:hover > td, .cal-week-multi:hover > td {
|
||||||
background: var(--pico-secondary-hover-background);
|
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);
|
background: var(--pico-primary-background);
|
||||||
color: var(--pico-primary-inverse);
|
color: var(--pico-primary-inverse);
|
||||||
}
|
}
|
||||||
.cal-week-multi.in-range {
|
.cal-week-multi.in-range > td {
|
||||||
background: var(--pico-primary-focus);
|
background: var(--pico-primary-focus);
|
||||||
color: var(--pico-primary-inverse);
|
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 page */
|
||||||
.publish-summary {
|
.publish-summary {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
<hgroup>
|
<hgroup>
|
||||||
<h3>Scheduler</h3>
|
<h3>Scheduler</h3>
|
||||||
<p>
|
<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 %}
|
{% if scheduler_status.rss_fetch %}
|
||||||
· Next fetch: {{ scheduler_status.rss_fetch.next_run }}
|
· Next fetch: {{ scheduler_status.rss_fetch.next_run }}
|
||||||
· Interval: {{ scheduler_status.rss_fetch.interval_hours }}h
|
· Interval: {{ scheduler_status.rss_fetch.interval_hours }}h
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
<td class="issue-actions">
|
<td class="issue-actions">
|
||||||
<a href="/issues/{{ item.issue.id }}/read" role="button" class="outline">Read</a>
|
<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>
|
<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">
|
<form method="post" action="/issues/{{ item.issue.id }}/regenerate">
|
||||||
<button type="submit" class="outline contrast">Regenerate</button>
|
<button type="submit" class="outline contrast">Regenerate</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
<h1>Publish Issue</h1>
|
<h1>Publish Issue</h1>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab active" data-tab="weekly" onclick="switchTab('weekly')">Weekly Issue</button>
|
<button class="tab active" data-tab="weekly" onclick="switchTab('weekly')">By Week</button>
|
||||||
<button class="tab" data-tab="multi-week" onclick="switchTab('multi-week')">Multi-Week</button>
|
|
||||||
<button class="tab" data-tab="single-article" onclick="switchTab('single-article')">Single Article</button>
|
<button class="tab" data-tab="single-article" onclick="switchTab('single-article')">Single Article</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WEEKLY TAB -->
|
<!-- WEEKLY TAB -->
|
||||||
<div id="tab-weekly" class="tab-content active">
|
<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-widget">
|
||||||
<div class="calendar-nav">
|
<div class="calendar-nav">
|
||||||
<button class="outline" onclick="changeMonth(-1)">◀</button>
|
<button class="outline" onclick="changeMonth(-1)">◀</button>
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
<tbody id="cal-body">
|
<tbody id="cal-body">
|
||||||
{% for week in calendar_weeks %}
|
{% for week in calendar_weeks %}
|
||||||
<tr class="cal-week" data-start="{{ week.week_start }}" data-end="{{ week.week_end }}"
|
<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>
|
<td class="cal-wk">{{ week.iso_week }}{% if week.article_count %} <small>({{ week.article_count }})</small>{% endif %}</td>
|
||||||
{% for day in week.days %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<form method="post" action="/publish" id="weekly-form">
|
<form method="post" action="/publish" id="weekly-form">
|
||||||
<input type="hidden" name="week_start" id="weekly-start">
|
<input type="hidden" name="week_start" id="weekly-start">
|
||||||
<input type="hidden" name="week_end" id="weekly-end">
|
<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 id="weekly-checkboxes"></div>
|
||||||
<div class="publish-actions">
|
<div class="publish-actions">
|
||||||
<select name="cover_method">
|
<select name="cover_method">
|
||||||
@@ -53,48 +53,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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)">◀</button>
|
|
||||||
<strong id="cal-month-label-multi">{{ cal_month_name }}</strong>
|
|
||||||
<button class="outline" onclick="changeMonthMulti(1)">▶</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 -->
|
<!-- SINGLE ARTICLE TAB -->
|
||||||
<div id="tab-single-article" class="tab-content">
|
<div id="tab-single-article" class="tab-content">
|
||||||
@@ -132,10 +90,7 @@
|
|||||||
<script>
|
<script>
|
||||||
let calYear = {{ cal_year }};
|
let calYear = {{ cal_year }};
|
||||||
let calMonth = {{ cal_month }};
|
let calMonth = {{ cal_month }};
|
||||||
let calYearMulti = {{ cal_year }};
|
let selectedWeeks = [];
|
||||||
let calMonthMulti = {{ cal_month }};
|
|
||||||
let multiStart = null;
|
|
||||||
let multiEnd = null;
|
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
@@ -157,57 +112,58 @@ async function loadArticles(start, end, targetId) {
|
|||||||
return articles.length;
|
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)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectWeekMulti(row) {
|
async function selectWeekMulti(row) {
|
||||||
const start = row.dataset.start;
|
const start = row.dataset.start;
|
||||||
const end = row.dataset.end;
|
const end = row.dataset.end;
|
||||||
|
|
||||||
if (!multiStart || (multiStart && multiEnd)) {
|
// Toggle selection
|
||||||
multiStart = { start, end, row };
|
const existingIndex = selectedWeeks.findIndex(w => w.start === start);
|
||||||
multiEnd = null;
|
if (existingIndex >= 0) {
|
||||||
document.querySelectorAll('.cal-week-multi').forEach(r => r.classList.remove('selected', 'in-range'));
|
selectedWeeks.splice(existingIndex, 1);
|
||||||
row.classList.add('selected');
|
row.classList.remove('selected');
|
||||||
} else {
|
} else {
|
||||||
multiEnd = { start, end, row };
|
selectedWeeks.push({ start, end, row });
|
||||||
if (multiEnd.start < multiStart.start) {
|
row.classList.add('selected');
|
||||||
[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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeStart = multiStart.start;
|
if (selectedWeeks.length === 0) {
|
||||||
const rangeEnd = (multiEnd || multiStart).end;
|
document.getElementById('weekly-start').value = '';
|
||||||
document.getElementById('multi-start').value = rangeStart;
|
document.getElementById('weekly-end').value = '';
|
||||||
document.getElementById('multi-end').value = rangeEnd;
|
document.getElementById('weekly-checkboxes').innerHTML = '';
|
||||||
const count = await loadArticles(rangeStart, rangeEnd, 'multi-checkboxes');
|
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 count = await loadArticles(rangeStart, rangeEnd, 'weekly-checkboxes');
|
||||||
const ws = new Date(rangeStart + 'T00:00:00');
|
const ws = new Date(rangeStart + 'T00:00:00');
|
||||||
const we = new Date(rangeEnd + 'T00:00:00');
|
const we = new Date(rangeEnd + 'T00:00:00');
|
||||||
const opts = { month: 'short', day: 'numeric' };
|
const opts = { month: 'short', day: 'numeric' };
|
||||||
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
|
const optsYear = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||||
document.getElementById('multi-summary').innerHTML =
|
|
||||||
`<strong>Range:</strong> ${ws.toLocaleDateString('en-US', opts)} – ${we.toLocaleDateString('en-US', optsYear)} · <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)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
||||||
|
} else {
|
||||||
|
summaryHtml = `<strong>Range:</strong> ${ws.toLocaleDateString('en-US', opts)} – ${we.toLocaleDateString('en-US', optsYear)} · <strong>${count} articles</strong>`;
|
||||||
|
}
|
||||||
|
document.getElementById('weekly-summary').innerHTML = summaryHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeMonth(delta) {
|
async function changeMonth(delta) {
|
||||||
@@ -216,32 +172,29 @@ async function changeMonth(delta) {
|
|||||||
if (calMonth < 1) { calMonth = 12; calYear--; }
|
if (calMonth < 1) { calMonth = 12; calYear--; }
|
||||||
const resp = await fetch(`/publish/calendar?year=${calYear}&month=${calMonth}`);
|
const resp = await fetch(`/publish/calendar?year=${calYear}&month=${calMonth}`);
|
||||||
const weeks = await resp.json();
|
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 =
|
document.getElementById('cal-month-label').textContent =
|
||||||
new Date(calYear, calMonth - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
new Date(calYear, calMonth - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
}
|
|
||||||
|
|
||||||
async function changeMonthMulti(delta) {
|
// Re-apply selected classes based on tracked selectedWeeks
|
||||||
calMonthMulti += delta;
|
document.querySelectorAll('.cal-week').forEach(row => {
|
||||||
if (calMonthMulti > 12) { calMonthMulti = 1; calYearMulti++; }
|
if (selectedWeeks.some(w => w.start === row.dataset.start)) {
|
||||||
if (calMonthMulti < 1) { calMonthMulti = 12; calYearMulti--; }
|
row.classList.add('selected');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCalendar(weeks, tbodyId, rowClass, onclickFn) {
|
function renderCalendar(weeks, tbodyId, rowClass, onclickFn) {
|
||||||
const tbody = document.getElementById(tbodyId);
|
const tbody = document.getElementById(tbodyId);
|
||||||
tbody.innerHTML = weeks.map(w => {
|
tbody.innerHTML = weeks.map(w => {
|
||||||
const days = w.days.map(d =>
|
const days = w.days.map(d => {
|
||||||
`<td class="${d.in_month ? '' : 'cal-dim'}">${d.day}</td>`
|
let classes = [];
|
||||||
).join('');
|
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>` : '';
|
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}
|
<td class="cal-wk">${w.iso_week}${count}</td>${days}
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ def test_build_epub_respects_article_order(app, db, tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ebooklib import epub as epublib
|
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)
|
spine_items = [book.get_item_with_id(item_id)
|
||||||
for item_id, _ in book.spine if item_id != "nav"]
|
for item_id, _ in book.spine if item_id != "nav"]
|
||||||
titles = []
|
titles = []
|
||||||
|
|||||||
@@ -81,3 +81,19 @@ def test_fetch_handles_feed_error(app, db):
|
|||||||
|
|
||||||
assert result["error"] is not None
|
assert result["error"] is not None
|
||||||
assert Article.query.count() == 0
|
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
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import os
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from src.models import Article, Image, Issue
|
from src.models import Article, Image, Issue
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
def test_regenerate_passes_ordered_image_paths_to_generate_cover(app, client, db, tmp_path):
|
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():
|
with app.app_context():
|
||||||
updated = db.session.get(Issue, issue_id)
|
updated = db.session.get(Issue, issue_id)
|
||||||
assert updated.cover_method == "mosaic"
|
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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from src.models import Article, Image, Issue, Setting
|
from src.models import Article, Image, Issue, Setting
|
||||||
|
|
||||||
|
|
||||||
@@ -84,3 +85,45 @@ def test_setting_crud(db):
|
|||||||
|
|
||||||
Setting.set("fetch_interval", 4)
|
Setting.set("fetch_interval", 4)
|
||||||
assert Setting.get("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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
@@ -63,6 +64,48 @@ def test_scheduler_get_status(app):
|
|||||||
mgr.shutdown()
|
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):
|
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."""
|
"""First image per article in pub_date order passed to generate_cover."""
|
||||||
os.makedirs(tmp_path, exist_ok=True)
|
os.makedirs(tmp_path, exist_ok=True)
|
||||||
|
|||||||