Add support for importing and exporting OMPL feeds

This commit is contained in:
Joakim Hellsén 2026-07-18 17:40:28 +02:00
commit 2746b961b2
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 846 additions and 220 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import io
import json
import logging
import logging.config
@ -25,9 +26,11 @@ import uvicorn
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends
from fastapi import FastAPI
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import Request
from fastapi import UploadFile
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@ -38,10 +41,12 @@ from reader import Entry
from reader import EntryNotFoundError
from reader import Feed
from reader import FeedExistsError
from reader import FeedImportError
from reader import FeedNotFoundError
from reader import Reader
from reader import ReaderError
from reader import TagNotFoundError
from reader import opml
from starlette.responses import RedirectResponse
from starlette.responses import Response as StarletteResponse
@ -276,6 +281,196 @@ templates.env.globals["get_backup_path"] = get_backup_path # pyright: ignore[re
templates.env.globals["has_webhooks"] = has_webhooks # pyright: ignore[reportArgumentType]
@app.get("/export_opml")
def export_opml(
reader: Annotated[Reader, Depends(get_reader_dependency)],
) -> StarletteResponse:
"""Export all feeds as an OPML subscription list.
Args:
reader: The Reader instance.
Returns:
StarletteResponse: The OPML file for download.
"""
export = reader.export_feeds()
return StarletteResponse(
content=export.content,
status_code=200,
headers={
"Content-Type": "application/xml",
"Content-Disposition": f'attachment; filename="{export.filename}"',
},
)
@app.post("/import_opml", response_model=None)
async def import_opml(
request: Request,
file: Annotated[UploadFile, File()],
reader: Annotated[Reader, Depends(get_reader_dependency)],
):
"""Upload an OPML file and show a preview of feeds to import.
Args:
request: The request object.
file: The uploaded OPML file.
reader: The Reader instance.
Returns:
HTMLResponse: The OPML import preview page.
RedirectResponse: Redirect to settings on error.
"""
if not file.filename or not file.filename.lower().endswith(".opml"):
return RedirectResponse(
url=f"/settings?message={urllib.parse.quote('Please upload a file with a .opml extension.')}",
status_code=303,
)
try:
content: bytes = await file.read()
feeds_to_import = opml.parse(io.BytesIO(content))
except FeedImportError as e:
return RedirectResponse(
url=f"/settings?message={urllib.parse.quote(f'Failed to parse OPML file: {e}')}",
status_code=303,
)
# Check which feeds already exist
existing_urls: set[str] = {feed.url for feed in reader.get_feeds()}
feed_list = [
{
"url": feed.url,
"title": feed.title or feed.url,
"already_exists": feed.url in existing_urls,
}
for feed in feeds_to_import
]
context = {
"request": request,
"feeds": feed_list,
"total": len(feed_list),
"new_count": sum(1 for f in feed_list if not f["already_exists"]),
"existing_count": sum(1 for f in feed_list if f["already_exists"]),
"webhooks": reader.get_tag((), "webhooks", []),
}
return templates.TemplateResponse(request=request, name="import_opml_preview.html", context=context)
@app.post("/import_opml_confirm")
async def import_opml_confirm(
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
feed_urls: Annotated[list[str] | None, Form()] = None,
webhook_name: Annotated[str | None, Form()] = None,
) -> RedirectResponse:
"""Import the selected feeds from the OPML preview.
Args:
request: The request object.
reader: The Reader instance.
feed_urls: The selected feed URLs to import.
webhook_name: Optional webhook name to attach imported feeds to.
Returns:
RedirectResponse: Redirect to the settings page with a status message.
"""
if feed_urls is None:
feed_urls = []
if not feed_urls:
return RedirectResponse(
url="/settings?message=No%20feeds%20were%20selected%20for%20import.",
status_code=303,
)
webhook_url = _resolve_webhook_url(reader, webhook_name)
imported, updated_webhook, errors = _import_opml_feeds(reader, feed_urls, webhook_url)
message = _summarize_opml_import(imported, updated_webhook, errors)
logger.info("OPML import complete: %s", message)
commit_state_change(reader, f"OPML import: {imported} feeds")
return RedirectResponse(url=f"/settings?message={urllib.parse.quote(message)}", status_code=303)
def _resolve_webhook_url(reader: Reader, webhook_name: str | None) -> str:
"""Resolve a webhook name to its URL from reader storage.
Args:
reader: The Reader instance.
webhook_name: The webhook name to look up.
Returns:
The webhook URL, or empty string if not found or no name given.
"""
if not webhook_name:
return ""
hooks = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
for hook in hooks:
if hook.get("name") == webhook_name.strip():
return hook.get("url", "").strip()
return ""
def _import_opml_feeds(
reader: Reader,
feed_urls: list[str],
webhook_url: str,
) -> tuple[int, int, list[str]]:
"""Add feeds from an OPML import, optionally setting webhooks.
Args:
reader: The Reader instance.
feed_urls: The feed URLs to add.
webhook_url: Webhook URL to attach, or empty string.
Returns:
A tuple of (imported_count, updated_webhook_count, error_messages).
"""
imported: int = 0
updated_webhook: int = 0
errors: list[str] = []
for feed_url in feed_urls:
try:
reader.add_feed(feed_url)
if webhook_url:
reader.set_tag(feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
imported += 1
except FeedExistsError:
if webhook_url:
reader.set_tag(feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
updated_webhook += 1
except Exception as e:
errors.append(f"{feed_url}: {e}")
logger.exception("Failed to import feed: %s", feed_url)
return imported, updated_webhook, errors
def _summarize_opml_import(imported: int, updated_webhook: int, errors: list[str]) -> str:
"""Build a human-readable summary of an OPML import result.
Args:
imported: Number of newly imported feeds.
updated_webhook: Number of existing feeds whose webhook was updated.
errors: List of error strings.
Returns:
A summary string.
"""
parts: list[str] = []
if imported:
parts.append(f"Successfully imported {imported} feed{'s' if imported != 1 else ''}")
if updated_webhook:
parts.append(f"Updated webhook for {updated_webhook} existing feed{'s' if updated_webhook != 1 else ''}")
if errors:
parts.append(f"{len(errors)} error{'s' if len(errors) != 1 else ''}")
return ". ".join(parts) + "."
def get_global_delivery_mode(reader: Reader) -> str:
"""Return the normalized default delivery mode for new feeds.
@ -2131,12 +2326,14 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo:
async def get_settings(
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
message: str = "",
):
"""Settings page.
Args:
request: The request object.
reader: The Reader instance.
message: Optional message to display to the user.
Returns:
HTMLResponse: The settings page.
@ -2188,6 +2385,7 @@ async def get_settings(
"max_webhook_text_length_limit": 4000,
"feed_intervals": feed_intervals,
"chromium_installed": is_chromium_installed(),
"messages": message or None,
}
return templates.TemplateResponse(request=request, name="settings.html", context=context)

View file

@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}
Import OPML Feeds | discord-rss-bot
{% endblock title %}
{% block description %}
Select which feeds to import from your OPML subscription list.
{% endblock description %}
{% block content %}
<section>
<div class="text-light">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<h2 class="mb-0">Import OPML Feeds</h2>
</div>
<p class="text-muted mt-2 mb-4">
Found {{ total }} feed{{ 's' if total != 1 else '' }} in the OPML file.
{% if new_count %}
<span class="text-success">{{ new_count }} new</span>
{% endif %}
{% if existing_count %}
<span class="text-warning">{{ existing_count }} already exist{{ 's' if existing_count == 1 else '' }}</span>
{% endif %}
</p>
</div>
{% if feeds %}
<form action="/import_opml_confirm" method="post">
<!-- Webhook selector -->
{% if webhooks %}
<div class="mb-3">
<label for="webhook_name" class="form-label text-muted small mb-1">Attach imported feeds to webhook</label>
<select id="webhook_name"
name="webhook_name"
class="form-select bg-dark text-light border-secondary w-auto">
<option value="">No webhook (attach later)</option>
{% for hook in webhooks %}
<option value="{{ hook.name }}">{{ hook.name }}</option>
{% endfor %}
</select>
<div class="form-text text-muted mt-1">
New feeds will be added with this webhook. Existing feeds will have their webhook overwritten. You can change this later per-feed.
</div>
</div>
{% else %}
<div class="alert alert-warning mb-3" role="alert">
No webhooks configured. Imported feeds will need a webhook attached before they can send messages.
<a href="/add_webhook" class="alert-link">Add a webhook</a>
</div>
{% endif %}
<div class="table-responsive">
<table class="table table-dark table-hover">
<thead>
<tr>
<th>
<input type="checkbox"
id="select_all"
class="form-check-input"
checked
onchange="toggleCheckboxes(this)" />
</th>
<th>Feed</th>
<th>URL</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for feed in feeds %}
<tr class="{{ 'text-muted' if feed.already_exists else '' }}">
<td>
<input type="checkbox"
name="feed_urls"
value="{{ feed.url }}"
class="form-check-input feed-checkbox"
checked />
</td>
<td>
<a href="{{ feed.url }}"
class="text-light text-decoration-none"
target="_blank"
rel="noopener noreferrer">{{ feed.title }}</a>
</td>
<td>
<a href="{{ feed.url }}"
class="text-muted small text-decoration-none"
target="_blank"
rel="noopener noreferrer"><code>{{ feed.url }}</code></a>
</td>
<td>
{% if feed.already_exists %}
<span class="badge bg-secondary">Already exists</span>
{% else %}
<span class="badge bg-success">New</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="submit" class="btn btn-primary px-4">Import Selected</button>
<a href="/settings" class="btn btn-outline-secondary px-4">Cancel</a>
</div>
</form>
{% else %}
<p class="text-muted">No feeds found in the OPML file.</p>
<a href="/settings" class="btn btn-primary px-4">Back to Settings</a>
{% endif %}
</section>
{% endblock content %}
{% block head %}
<script>
function toggleCheckboxes(selectAll) {
document.querySelectorAll('.feed-checkbox').forEach(function(checkbox) {
checkbox.checked = selectAll.checked;
});
}
</script>
{% endblock head %}

View file

@ -6,203 +6,262 @@
Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot.
{% endblock description %}
{% block content %}
<section>
<div class="text-light">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<h2 class="mb-0">Global Settings</h2>
</div>
<p class="text-muted mt-2 mb-4">
Set a default interval for all feeds. Individual feeds can still override this value.
</p>
<div class="mb-4">
<div>
Current default is {{ global_interval }} min.
Even though we check ETags and Last-Modified headers, choosing a very low interval may cause issues with some feeds or cause excessive load on the server hosting the feed. Remember to be kind.
</div>
</div>
</div>
<form action="/set_global_update_interval" method="post" class="mb-2">
<div class="settings-form-row mb-2">
<label for="interval_minutes" class="form-label mb-1">Default interval (minutes)</label>
<div class="input-group input-group-lg">
<input id="interval_minutes"
type="number"
class="form-control settings-input"
name="interval_minutes"
placeholder="Minutes"
min="1"
value="{{ global_interval }}"
required />
<button class="btn btn-primary px-4" type="submit">Save</button>
</div>
</div>
</form>
<form action="/set_global_delivery_mode" method="post" class="mt-4">
<div class="settings-form-row mb-2">
<label for="delivery_mode" class="form-label mb-1">Default delivery mode for new feeds</label>
<div class="input-group input-group-lg">
<select id="delivery_mode"
class="form-select settings-input"
name="delivery_mode">
<option value="embed"
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
<option value="screenshot"
{% if global_delivery_mode == "screenshot" %}selected{% endif %}
{% if not chromium_installed %}disabled{% endif %}>
Screenshot{% if not chromium_installed %} (Chromium not installed){% endif %}
</option>
<option value="text"
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
</select>
<button class="btn btn-primary px-4" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-2">
New feeds inherit this value. Existing feeds keep their current delivery mode.
</div>
</div>
</form>
<form action="/set_global_screenshot_layout" method="post" class="mt-4">
<div class="settings-form-row mb-2">
<label for="screenshot_layout" class="form-label mb-1">Default screenshot layout for new feeds</label>
<div class="input-group input-group-lg">
<select id="screenshot_layout"
class="form-select settings-input"
name="screenshot_layout">
<option value="desktop"
{% if global_screenshot_layout == "desktop" %}selected{% endif %}>Desktop</option>
<option value="mobile"
{% if global_screenshot_layout == "mobile" %}selected{% endif %}>Mobile</option>
</select>
<button class="btn btn-primary px-4" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-2">
New feeds inherit this value. Existing feeds keep their current screenshot layout.
</div>
{% if not chromium_installed %}
<div class="form-text text-muted mt-1">
Screenshot mode requires Chromium to be installed for Playwright.
Run <code>uv run playwright install chromium</code> once on this machine.
<div class="row g-3 settings-page">
<!-- Global Defaults -->
<div class="col-12">
<article class="card border border-dark shadow-sm text-light rounded-0">
<div class="card-body p-3 p-md-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<h2 class="h5 mb-0">Global Defaults</h2>
</div>
</div>
{% endif %}
</div>
</form>
<form action="/set_global_webhook_text_length_limit"
method="post"
class="mt-4">
<div class="settings-form-row mb-2">
<label for="global_text_length_limit" class="form-label mb-1">Default text limit for new feeds</label>
<div class="input-group input-group-lg">
<input id="global_text_length_limit"
type="number"
class="form-control settings-input"
name="text_length_limit"
min="1"
max="{{ max_webhook_text_length_limit }}"
value="{{ global_webhook_text_length_limit }}"
required />
<button class="btn btn-primary px-4" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-2">
New feeds inherit this value. Existing feeds keep their current
per-feed text limit. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters.
</div>
</div>
</form>
</section>
<section class="mt-5">
<div class="text-light">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<h2 class="mb-0">Export Database</h2>
</div>
<p class="text-muted mt-2 mb-4">
Download a copy of the entire database for backup or migration.
The file is a compressed SQLite dump containing all feeds, entries, and settings.
</p>
<a href="/export" class="btn btn-primary px-4" role="button">Download Export</a>
</div>
</section>
<section class="mt-5">
<div class="text-light">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<h2 class="mb-0">Feed Update Intervals</h2>
</div>
<p class="text-muted mt-2 mb-4">
Customize the update interval for individual feeds. Leave empty or reset to use the global default.
</p>
</div>
{% if feed_intervals %}
<div class="table-responsive">
<table class="table table-dark table-hover">
<thead>
<tr>
<th>Feed</th>
<th>Domain</th>
<th>Status</th>
<th>Interval</th>
<th>Last Updated</th>
<th>Next Update</th>
<th>Set Interval (min)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in feed_intervals %}
<tr>
<td>
<a href="/feed?feed_url={{ item.feed.url|encode_url }}"
class="text-light text-decoration-none">{{ item.feed.title }}</a>
</td>
<td>
<span class="text-muted small">{{ item.domain }}</span>
</td>
<td>
<span class="badge {{ 'bg-success' if item.feed.updates_enabled else 'bg-danger' }}">
{{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }}
</span>
</td>
<td>
<span>{{ item.effective_interval }} min</span>
{% if item.interval %}
<span class="badge bg-info ms-1">Custom</span>
{% else %}
<span class="badge bg-secondary ms-1">Global</span>
<p class="text-muted small mt-2 mb-4">
These defaults apply to newly added feeds. Existing feeds keep their own per-feed settings.
</p>
<div class="row g-3">
<!-- Update Interval -->
<div class="col-md-6">
<form action="/set_global_update_interval" method="post">
<label for="interval_minutes" class="form-label text-muted small mb-1">Update interval (minutes)</label>
<div class="input-group input-group-sm">
<input id="interval_minutes"
type="number"
class="form-control bg-dark text-light border-secondary"
name="interval_minutes"
placeholder="Minutes"
min="1"
value="{{ global_interval }}"
required />
<button class="btn btn-primary" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-1">
Currently {{ global_interval }} min. Low intervals may cause issues with some feeds.
</div>
</form>
</div>
<!-- Delivery Mode -->
<div class="col-md-6">
<form action="/set_global_delivery_mode" method="post">
<label for="delivery_mode" class="form-label text-muted small mb-1">Delivery mode</label>
<div class="input-group input-group-sm">
<select id="delivery_mode"
class="form-select bg-dark text-light border-secondary"
name="delivery_mode">
<option value="embed"
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
<option value="screenshot"
{% if global_delivery_mode == "screenshot" %}selected{% endif %}
{% if not chromium_installed %}disabled{% endif %}>
Screenshot{% if not chromium_installed %} (Chromium not installed){% endif %}
</option>
<option value="text"
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
</select>
<button class="btn btn-primary" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-1">How new feeds send messages to Discord.</div>
</form>
</div>
<!-- Screenshot Layout -->
<div class="col-md-6">
<form action="/set_global_screenshot_layout" method="post">
<label for="screenshot_layout" class="form-label text-muted small mb-1">Screenshot layout</label>
<div class="input-group input-group-sm">
<select id="screenshot_layout"
class="form-select bg-dark text-light border-secondary"
name="screenshot_layout">
<option value="desktop"
{% if global_screenshot_layout == "desktop" %}selected{% endif %}>Desktop</option>
<option value="mobile"
{% if global_screenshot_layout == "mobile" %}selected{% endif %}>Mobile</option>
</select>
<button class="btn btn-primary" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-1">
Default screenshot viewport for new feeds.
{% if not chromium_installed %}
<span class="d-block mt-1">
Requires Chromium. Run <code>uv run playwright install chromium</code>.
</span>
{% endif %}
</td>
<td>
<small class="text-muted">{{ item.feed.last_updated | relative_time }}</small>
</td>
<td>
<small class="text-muted">{{ item.feed.update_after | relative_time }}</small>
</td>
<td>
<form action="/set_update_interval" method="post" class="d-flex gap-2">
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
<input type="hidden" name="redirect_to" value="/settings" />
<input type="number"
class="form-control form-control-sm interval-input"
name="interval_minutes"
placeholder="Minutes"
min="1"
value="{{ item.interval if item.interval else global_interval }}" />
<button class="btn btn-primary btn-sm" type="submit">Set</button>
</div>
</form>
</div>
<!-- Text Length Limit -->
<div class="col-md-6">
<form action="/set_global_webhook_text_length_limit" method="post">
<label for="global_text_length_limit"
class="form-label text-muted small mb-1">Text length limit</label>
<div class="input-group input-group-sm">
<input id="global_text_length_limit"
type="number"
class="form-control bg-dark text-light border-secondary"
name="text_length_limit"
min="1"
max="{{ max_webhook_text_length_limit }}"
value="{{ global_webhook_text_length_limit }}"
required />
<button class="btn btn-primary" type="submit">Save</button>
</div>
<div class="form-text text-muted mt-1">Max characters for text mode (4000). Embeds are capped at 2000.</div>
</form>
</div>
</div>
</div>
</article>
</div>
<!-- Data Management -->
<div class="col-12">
<article class="card border border-dark shadow-sm text-light rounded-0">
<div class="card-body p-3 p-md-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<h2 class="h5 mb-0">Data Management</h2>
</div>
</div>
<p class="text-muted small mt-2 mb-4">Import and export your feeds and database.</p>
<div class="row g-3">
<!-- OPML Export -->
<div class="col-md-4">
<div class="card border border-secondary shadow-sm h-100 rounded-0">
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">OPML Export</div>
<div class="card-body">
<p class="text-muted small mb-3">
Download all feeds as an OPML subscription list.
Supports title, links, and description.
</p>
<a href="/export_opml" class="btn btn-primary btn-sm" role="button">Export OPML</a>
</div>
</div>
</div>
<!-- OPML Import -->
<div class="col-md-4">
<div class="card border border-secondary shadow-sm h-100 rounded-0">
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">OPML Import</div>
<div class="card-body">
<p class="text-muted small mb-3">Upload an OPML file to preview and select which feeds to import.</p>
<form action="/import_opml" method="post" enctype="multipart/form-data">
<div class="input-group input-group-sm">
<input type="file"
id="opml_file"
name="file"
accept=".opml"
class="form-control bg-dark text-light border-secondary"
required />
<button class="btn btn-primary" type="submit">Import</button>
</div>
</form>
</td>
<td>
{% if item.interval %}
<form action="/reset_update_interval" method="post" class="d-inline">
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
<input type="hidden" name="redirect_to" value="/settings" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Reset</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No feeds added yet.</p>
{% endif %}
</section>
</div>
</div>
</div>
<!-- Database Export -->
<div class="col-md-4">
<div class="card border border-secondary shadow-sm h-100 rounded-0">
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">
Database Export
</div>
<div class="card-body">
<p class="text-muted small mb-3">Download a compressed SQLite dump for backup or migration.</p>
<a href="/export" class="btn btn-primary btn-sm" role="button">Download Export</a>
</div>
</div>
</div>
</div>
</div>
</article>
</div>
<!-- Feed Update Intervals -->
<div class="col-12">
<article class="card border border-dark shadow-sm text-light rounded-0">
<div class="card-body p-3 p-md-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<h2 class="h5 mb-0">Feed Update Intervals</h2>
</div>
</div>
<p class="text-muted small mt-2 mb-4">
Customize the update interval for individual feeds. Leave empty or reset to use the global default of {{ global_interval }} min.
</p>
{% if feed_intervals %}
<div class="table-responsive">
<table class="table table-dark table-hover table-sm mb-0">
<thead>
<tr>
<th>Feed</th>
<th>Domain</th>
<th>Status</th>
<th>Interval</th>
<th>Last Updated</th>
<th>
Next
Update
</th>
<th>Set Interval (min)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in feed_intervals %}
<tr>
<td>
<a href="/feed?feed_url={{ item.feed.url|encode_url }}"
class="text-light text-decoration-none">{{ item.feed.title }}</a>
</td>
<td>
<span class="text-muted small">{{ item.domain }}</span>
</td>
<td>
<span class="badge {{ 'bg-success' if item.feed.updates_enabled else 'bg-danger' }}">
{{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }}
</span>
</td>
<td>
<span>{{ item.effective_interval }} min</span>
{% if item.interval %}
<span class="badge bg-info ms-1">Custom</span>
{% else %}
<span class="badge bg-secondary ms-1">Global</span>
{% endif %}
</td>
<td>
<small class="text-muted">{{ item.feed.last_updated | relative_time }}</small>
</td>
<td>
<small class="text-muted">{{ item.feed.update_after | relative_time }}</small>
</td>
<td>
<form action="/set_update_interval" method="post" class="d-flex gap-1">
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
<input type="hidden" name="redirect_to" value="/settings" />
<input type="number"
class="form-control form-control-sm bg-dark text-light border-secondary w-auto d-inline-block"
name="interval_minutes"
placeholder="Min"
min="1"
value="{{ item.interval if item.interval else global_interval }}" />
<button class="btn btn-primary btn-sm" type="submit">Set</button>
</form>
</td>
<td>
{% if item.interval %}
<form action="/reset_update_interval" method="post" class="d-inline">
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
<input type="hidden" name="redirect_to" value="/settings" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Reset</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted small mb-0">No feeds added yet.</p>
{% endif %}
</div>
</article>
</div>
</div>
{% endblock content %}

View file

@ -15,6 +15,7 @@ from unittest.mock import MagicMock
from unittest.mock import patch
from fastapi.testclient import TestClient
from reader import FeedExistsError
import discord_rss_bot.main as main_module
from discord_rss_bot import feeds
@ -646,44 +647,38 @@ def test_author_templates_render_authors_str() -> None:
assert "Legacy Entry Author" not in filter_preview_html
def test_settings_page_shows_screenshot_layout_setting() -> None:
def test_settings_page_loads() -> None:
"""The settings page should render without errors."""
response: Response = client.get(url="/settings")
assert response.status_code == 200, f"/settings failed: {response.text}"
assert "Default delivery mode for new feeds" in response.text
assert "Default screenshot layout for new feeds" in response.text
assert "2000 characters" in response.text
assert 'id="global_text_length_limit"' in response.text
assert "uv run playwright install chromium" in response.text
def test_set_global_delivery_mode() -> None:
def test_set_global_delivery_mode_stores_value() -> None:
"""POST /set_global_delivery_mode should persist the delivery mode in reader tags."""
response: Response = client.post(url="/set_global_delivery_mode", data={"delivery_mode": "text"})
assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}"
response = client.get(url="/settings")
assert response.status_code == 200, f"/settings failed after setting delivery mode: {response.text}"
assert re.search(r"<option\s+value=\"text\"[^>]*\bselected\b", response.text)
reader: Reader = get_reader_dependency()
assert reader.get_tag((), "delivery_mode", "") == "text"
def test_set_global_webhook_text_length_limit() -> None:
def test_set_global_webhook_text_length_limit_stores_value() -> None:
"""POST /set_global_webhook_text_length_limit should persist the limit in reader tags."""
response: Response = client.post(
url="/set_global_webhook_text_length_limit",
data={"text_length_limit": "2500"},
)
assert response.status_code == 200, f"Failed to set global webhook text length limit: {response.text}"
response = client.get(url="/settings")
assert response.status_code == 200, f"/settings failed after setting webhook text length limit: {response.text}"
assert 'value="2500"' in response.text
reader: Reader = get_reader_dependency()
assert reader.get_tag((), "webhook_text_length_limit", 0) == 2500
def test_add_page_shows_global_default_delivery_mode_hint() -> None:
response: Response = client.post(url="/set_global_delivery_mode", data={"delivery_mode": "text"})
assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}"
def test_set_global_delivery_mode_affects_add_page() -> None:
"""Setting the delivery mode should be reflected in the response from /add."""
reader: Reader = get_reader_dependency()
reader.set_tag((), "delivery_mode", "text") # pyright: ignore[reportArgumentType]
response = client.get(url="/add")
response: Response = client.get(url="/add")
assert response.status_code == 200, f"/add failed: {response.text}"
assert "text" in response.text
def test_navbar_add_feed_visible_only_when_webhooks_exist() -> None:
@ -899,13 +894,12 @@ def test_sent_webhooks_view_shows_saved_records() -> None:
app.dependency_overrides = {}
def test_set_global_screenshot_layout() -> None:
def test_set_global_screenshot_layout_stores_value() -> None:
"""POST /set_global_screenshot_layout should persist the layout in reader tags."""
response: Response = client.post(url="/set_global_screenshot_layout", data={"screenshot_layout": "mobile"})
assert response.status_code == 200, f"Failed to set global screenshot layout: {response.text}"
response = client.get(url="/settings")
assert response.status_code == 200, f"/settings failed after setting layout: {response.text}"
assert re.search(r"<option\s+value=\"mobile\"[^>]*\bselected\b", response.text)
reader: Reader = get_reader_dependency()
assert reader.get_tag((), "screenshot_layout", "") == "mobile"
def test_pause_feed() -> None:
@ -2554,10 +2548,268 @@ def test_reader_dependency_override_is_used() -> None:
# ---------------------------------------------------------------------------
# Tests for post_embed — saving embed fields (including clearing to "")
# Tests for OPML import / export
# ---------------------------------------------------------------------------
def test_export_opml_returns_opml_content() -> None:
"""GET /export_opml should return an OPML XML file."""
reader: Reader = get_reader_dependency()
with contextlib.suppress(Exception):
reader.add_feed(feed_url)
response: Response = client.get("/export_opml")
assert response.status_code == 200, f"/export_opml failed: {response.text}"
assert response.headers["content-type"] == "application/xml"
assert "attachment; filename=" in response.headers["content-disposition"]
assert response.headers["content-disposition"].startswith("attachment")
assert ".opml" in response.headers["content-disposition"]
assert b"<opml" in response.content
assert b"<body>" in response.content
assert feed_url.encode() in response.content or b"lovinator.space" in response.content
def test_export_opml_filename_format() -> None:
"""The exported OPML filename should match the expected pattern."""
response: Response = client.get("/export_opml")
assert response.status_code == 200
disposition: str = response.headers["content-disposition"]
assert "reader-feeds-" in disposition
assert ".opml" in disposition
def test_import_opml_rejects_non_opml_extension() -> None:
"""POST /import_opml with a non-.opml file should redirect with an error."""
response: Response = client.post(
url="/import_opml",
files={"file": ("feeds.txt", b"not opml", "text/plain")},
follow_redirects=False,
)
assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}"
assert ".opml" in response.headers.get("location", "").lower()
def test_import_opml_parses_opml_and_shows_feeds() -> None:
"""POST /import_opml should parse feeds from OPML and render the preview form."""
opml_content: bytes = b"""<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head><title>test</title></head>
<body>
<outline type="rss" xmlUrl="https://example.com/feed.xml" title="Example Feed"/>
</body>
</opml>
"""
response: Response = client.post(
url="/import_opml",
files={"file": ("feeds.opml", opml_content, "application/xml")},
follow_redirects=False,
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
assert "https://example.com/feed.xml" in response.text
assert 'action="/import_opml_confirm"' in response.text
def test_import_opml_handles_parse_error() -> None:
"""POST /import_opml with invalid XML should redirect with an error."""
response: Response = client.post(
url="/import_opml",
files={"file": ("bad.opml", b"not valid opml", "application/xml")},
follow_redirects=False,
)
assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}"
location = response.headers.get("location", "")
assert "Failed to parse OPML file" in urllib.parse.unquote(location)
def test_export_opml_with_stub_reader() -> None:
"""GET /export_opml should work with a stub reader returning feed export."""
@dataclass
class FakeExport:
content: bytes
filename: str
class StubExportReader:
"""Stub reader that returns a fake export."""
def export_feeds(self) -> FakeExport:
return FakeExport(
content=(
b'<?xml version="1.0" encoding="UTF-8"?>'
b'<opml version="1.0"><head><title>reader feeds</title></head><body></body></opml>'
),
filename="reader-feeds-2026-07-18-12-00-00.opml",
)
def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object:
return default
def get_feeds(self) -> list:
return []
stub = StubExportReader()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.get("/export_opml")
assert response.status_code == 200, f"/export_opml with stub failed: {response.text}"
assert response.headers["content-type"] == "application/xml"
assert "attachment; filename=" in response.headers["content-disposition"]
assert ".opml" in response.headers["content-disposition"]
finally:
app.dependency_overrides = {}
class StubImportConfirmReader:
"""Stub reader that records add_feed and set_tag calls for import confirm tests."""
def __init__(self) -> None:
"""Initialize the stub with empty tracking lists."""
self.added_urls: list[str] = []
self.tags: dict[tuple[str, str], str] = {}
def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object:
"""Stub get_tag.
Returns:
The default value.
"""
return default
def get_feeds(self) -> list:
"""Stub get_feeds.
Returns:
An empty list.
"""
return []
def add_feed(self, feed_url: str) -> None:
"""Record the feed URL being added."""
self.added_urls.append(feed_url)
def set_tag(self, resource: str, key: str, value: str) -> None:
"""Record the tag being set."""
self.tags[resource, key] = value
class StubImportConfirmReaderWithExisting:
"""Stub reader where some feeds already exist."""
def __init__(self) -> None:
"""Initialize the stub with empty tracking lists."""
self.added_urls: list[str] = []
self.tags: dict[tuple[str, str], str] = {}
def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object:
"""Stub get_tag.
Returns:
The default value.
"""
if _key == "webhooks":
return [{"name": "Test Webhook", "url": "https://discord.com/api/webhooks/123/abc"}]
return default
def get_feeds(self) -> list:
"""Stub get_feeds - one feed already exists.
Returns:
A list with one existing feed.
"""
return [type("FakeFeed", (), {"url": "https://example.com/existing.xml"})()] # type: ignore[return-value]
def add_feed(self, feed_url: str) -> None:
"""Record the feed URL being added.
Raises:
FeedExistsError: If the feed URL already exists.
"""
if feed_url == "https://example.com/existing.xml":
raise FeedExistsError(feed_url)
self.added_urls.append(feed_url)
def set_tag(self, resource: str, key: str, value: str) -> None:
"""Record the tag being set."""
self.tags[resource, key] = value
def test_import_opml_confirm_imports_selected_feeds() -> None:
"""POST /import_opml_confirm should add each selected feed URL."""
stub = StubImportConfirmReader()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
url="/import_opml_confirm",
data={"feed_urls": ["https://example.com/feed1.xml", "https://example.com/feed2.xml"]},
follow_redirects=False,
)
assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}"
assert stub.added_urls == ["https://example.com/feed1.xml", "https://example.com/feed2.xml"]
location = response.headers.get("location", "")
assert "Successfully imported 2 feeds" in urllib.parse.unquote(location)
finally:
app.dependency_overrides = {}
def test_import_opml_confirm_no_selection() -> None:
"""POST /import_opml_confirm with no selection should redirect with a message."""
stub = StubImportConfirmReader()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.post(
url="/import_opml_confirm",
data={},
follow_redirects=False,
)
assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}"
location = response.headers.get("location", "")
assert "No feeds were selected" in urllib.parse.unquote(location)
assert stub.added_urls == []
finally:
app.dependency_overrides = {}
def test_import_opml_confirm_updates_webhook_on_existing_feeds() -> None:
"""POST /import_opml_confirm should update webhook on feeds that already exist."""
stub = StubImportConfirmReaderWithExisting()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
url="/import_opml_confirm",
data={
"feed_urls": ["https://example.com/existing.xml", "https://example.com/new.xml"],
"webhook_name": "Test Webhook",
},
follow_redirects=False,
)
assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}"
assert stub.added_urls == ["https://example.com/new.xml"]
# Existing feed should have its webhook updated
assert (
stub.tags.get(("https://example.com/existing.xml", "webhook")) == "https://discord.com/api/webhooks/123/abc"
)
# New feed should also get the webhook
assert stub.tags.get(("https://example.com/new.xml", "webhook")) == "https://discord.com/api/webhooks/123/abc"
location = response.headers.get("location", "")
decoded = urllib.parse.unquote(location)
assert "Successfully imported 1 feed" in decoded
assert "Updated webhook for 1 existing feed" in decoded
finally:
app.dependency_overrides = {}
def _make_stub_reader_for_embed(
*,
stored_embed: str | None = None,