Add support for importing and exporting OMPL feeds
This commit is contained in:
parent
f40773e013
commit
2746b961b2
4 changed files with 846 additions and 220 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
|
|
@ -25,9 +26,11 @@ import uvicorn
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi import File
|
||||||
from fastapi import Form
|
from fastapi import Form
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from fastapi import UploadFile
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
@ -38,10 +41,12 @@ from reader import Entry
|
||||||
from reader import EntryNotFoundError
|
from reader import EntryNotFoundError
|
||||||
from reader import Feed
|
from reader import Feed
|
||||||
from reader import FeedExistsError
|
from reader import FeedExistsError
|
||||||
|
from reader import FeedImportError
|
||||||
from reader import FeedNotFoundError
|
from reader import FeedNotFoundError
|
||||||
from reader import Reader
|
from reader import Reader
|
||||||
from reader import ReaderError
|
from reader import ReaderError
|
||||||
from reader import TagNotFoundError
|
from reader import TagNotFoundError
|
||||||
|
from reader import opml
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
from starlette.responses import Response as StarletteResponse
|
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]
|
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:
|
def get_global_delivery_mode(reader: Reader) -> str:
|
||||||
"""Return the normalized default delivery mode for new feeds.
|
"""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(
|
async def get_settings(
|
||||||
request: Request,
|
request: Request,
|
||||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||||
|
message: str = "",
|
||||||
):
|
):
|
||||||
"""Settings page.
|
"""Settings page.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The request object.
|
request: The request object.
|
||||||
reader: The Reader instance.
|
reader: The Reader instance.
|
||||||
|
message: Optional message to display to the user.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTMLResponse: The settings page.
|
HTMLResponse: The settings page.
|
||||||
|
|
@ -2188,6 +2385,7 @@ async def get_settings(
|
||||||
"max_webhook_text_length_limit": 4000,
|
"max_webhook_text_length_limit": 4000,
|
||||||
"feed_intervals": feed_intervals,
|
"feed_intervals": feed_intervals,
|
||||||
"chromium_installed": is_chromium_installed(),
|
"chromium_installed": is_chromium_installed(),
|
||||||
|
"messages": message or None,
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse(request=request, name="settings.html", context=context)
|
return templates.TemplateResponse(request=request, name="settings.html", context=context)
|
||||||
|
|
||||||
|
|
|
||||||
117
discord_rss_bot/templates/import_opml_preview.html
Normal file
117
discord_rss_bot/templates/import_opml_preview.html
Normal 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 %}
|
||||||
|
|
@ -6,203 +6,262 @@
|
||||||
Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot.
|
Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot.
|
||||||
{% endblock description %}
|
{% endblock description %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section>
|
<div class="row g-3 settings-page">
|
||||||
<div class="text-light">
|
<!-- Global Defaults -->
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="col-12">
|
||||||
<h2 class="mb-0">Global Settings</h2>
|
<article class="card border border-dark shadow-sm text-light rounded-0">
|
||||||
</div>
|
<div class="card-body p-3 p-md-4">
|
||||||
<p class="text-muted mt-2 mb-4">
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||||
Set a default interval for all feeds. Individual feeds can still override this value.
|
<div>
|
||||||
</p>
|
<h2 class="h5 mb-0">Global Defaults</h2>
|
||||||
<div class="mb-4">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
<p class="text-muted small mt-2 mb-4">
|
||||||
</div>
|
These defaults apply to newly added feeds. Existing feeds keep their own per-feed settings.
|
||||||
</form>
|
</p>
|
||||||
<form action="/set_global_webhook_text_length_limit"
|
<div class="row g-3">
|
||||||
method="post"
|
<!-- Update Interval -->
|
||||||
class="mt-4">
|
<div class="col-md-6">
|
||||||
<div class="settings-form-row mb-2">
|
<form action="/set_global_update_interval" method="post">
|
||||||
<label for="global_text_length_limit" class="form-label mb-1">Default text limit for new feeds</label>
|
<label for="interval_minutes" class="form-label text-muted small mb-1">Update interval (minutes)</label>
|
||||||
<div class="input-group input-group-lg">
|
<div class="input-group input-group-sm">
|
||||||
<input id="global_text_length_limit"
|
<input id="interval_minutes"
|
||||||
type="number"
|
type="number"
|
||||||
class="form-control settings-input"
|
class="form-control bg-dark text-light border-secondary"
|
||||||
name="text_length_limit"
|
name="interval_minutes"
|
||||||
min="1"
|
placeholder="Minutes"
|
||||||
max="{{ max_webhook_text_length_limit }}"
|
min="1"
|
||||||
value="{{ global_webhook_text_length_limit }}"
|
value="{{ global_interval }}"
|
||||||
required />
|
required />
|
||||||
<button class="btn btn-primary px-4" type="submit">Save</button>
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text text-muted mt-2">
|
<div class="form-text text-muted mt-1">
|
||||||
New feeds inherit this value. Existing feeds keep their current
|
Currently {{ global_interval }} min. Low intervals may cause issues with some feeds.
|
||||||
per-feed text limit. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters.
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<!-- Delivery Mode -->
|
||||||
</section>
|
<div class="col-md-6">
|
||||||
<section class="mt-5">
|
<form action="/set_global_delivery_mode" method="post">
|
||||||
<div class="text-light">
|
<label for="delivery_mode" class="form-label text-muted small mb-1">Delivery mode</label>
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="input-group input-group-sm">
|
||||||
<h2 class="mb-0">Export Database</h2>
|
<select id="delivery_mode"
|
||||||
</div>
|
class="form-select bg-dark text-light border-secondary"
|
||||||
<p class="text-muted mt-2 mb-4">
|
name="delivery_mode">
|
||||||
Download a copy of the entire database for backup or migration.
|
<option value="embed"
|
||||||
The file is a compressed SQLite dump containing all feeds, entries, and settings.
|
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
|
||||||
</p>
|
<option value="screenshot"
|
||||||
<a href="/export" class="btn btn-primary px-4" role="button">Download Export</a>
|
{% if global_delivery_mode == "screenshot" %}selected{% endif %}
|
||||||
</div>
|
{% if not chromium_installed %}disabled{% endif %}>
|
||||||
</section>
|
Screenshot{% if not chromium_installed %} (Chromium not installed){% endif %}
|
||||||
<section class="mt-5">
|
</option>
|
||||||
<div class="text-light">
|
<option value="text"
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
|
||||||
<h2 class="mb-0">Feed Update Intervals</h2>
|
</select>
|
||||||
</div>
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
<p class="text-muted mt-2 mb-4">
|
</div>
|
||||||
Customize the update interval for individual feeds. Leave empty or reset to use the global default.
|
<div class="form-text text-muted mt-1">How new feeds send messages to Discord.</div>
|
||||||
</p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% if feed_intervals %}
|
<!-- Screenshot Layout -->
|
||||||
<div class="table-responsive">
|
<div class="col-md-6">
|
||||||
<table class="table table-dark table-hover">
|
<form action="/set_global_screenshot_layout" method="post">
|
||||||
<thead>
|
<label for="screenshot_layout" class="form-label text-muted small mb-1">Screenshot layout</label>
|
||||||
<tr>
|
<div class="input-group input-group-sm">
|
||||||
<th>Feed</th>
|
<select id="screenshot_layout"
|
||||||
<th>Domain</th>
|
class="form-select bg-dark text-light border-secondary"
|
||||||
<th>Status</th>
|
name="screenshot_layout">
|
||||||
<th>Interval</th>
|
<option value="desktop"
|
||||||
<th>Last Updated</th>
|
{% if global_screenshot_layout == "desktop" %}selected{% endif %}>Desktop</option>
|
||||||
<th>Next Update</th>
|
<option value="mobile"
|
||||||
<th>Set Interval (min)</th>
|
{% if global_screenshot_layout == "mobile" %}selected{% endif %}>Mobile</option>
|
||||||
<th>Actions</th>
|
</select>
|
||||||
</tr>
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="form-text text-muted mt-1">
|
||||||
{% for item in feed_intervals %}
|
Default screenshot viewport for new feeds.
|
||||||
<tr>
|
{% if not chromium_installed %}
|
||||||
<td>
|
<span class="d-block mt-1">
|
||||||
<a href="/feed?feed_url={{ item.feed.url|encode_url }}"
|
Requires Chromium. Run <code>uv run playwright install chromium</code>.
|
||||||
class="text-light text-decoration-none">{{ item.feed.title }}</a>
|
</span>
|
||||||
</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 %}
|
{% endif %}
|
||||||
</td>
|
</div>
|
||||||
<td>
|
</form>
|
||||||
<small class="text-muted">{{ item.feed.last_updated | relative_time }}</small>
|
</div>
|
||||||
</td>
|
<!-- Text Length Limit -->
|
||||||
<td>
|
<div class="col-md-6">
|
||||||
<small class="text-muted">{{ item.feed.update_after | relative_time }}</small>
|
<form action="/set_global_webhook_text_length_limit" method="post">
|
||||||
</td>
|
<label for="global_text_length_limit"
|
||||||
<td>
|
class="form-label text-muted small mb-1">Text length limit</label>
|
||||||
<form action="/set_update_interval" method="post" class="d-flex gap-2">
|
<div class="input-group input-group-sm">
|
||||||
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
|
<input id="global_text_length_limit"
|
||||||
<input type="hidden" name="redirect_to" value="/settings" />
|
type="number"
|
||||||
<input type="number"
|
class="form-control bg-dark text-light border-secondary"
|
||||||
class="form-control form-control-sm interval-input"
|
name="text_length_limit"
|
||||||
name="interval_minutes"
|
min="1"
|
||||||
placeholder="Minutes"
|
max="{{ max_webhook_text_length_limit }}"
|
||||||
min="1"
|
value="{{ global_webhook_text_length_limit }}"
|
||||||
value="{{ item.interval if item.interval else global_interval }}" />
|
required />
|
||||||
<button class="btn btn-primary btn-sm" type="submit">Set</button>
|
<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>
|
</form>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
</div>
|
||||||
{% if item.interval %}
|
</div>
|
||||||
<form action="/reset_update_interval" method="post" class="d-inline">
|
<!-- Database Export -->
|
||||||
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
|
<div class="col-md-4">
|
||||||
<input type="hidden" name="redirect_to" value="/settings" />
|
<div class="card border border-secondary shadow-sm h-100 rounded-0">
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Reset</button>
|
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">
|
||||||
</form>
|
Database Export
|
||||||
{% endif %}
|
</div>
|
||||||
</td>
|
<div class="card-body">
|
||||||
</tr>
|
<p class="text-muted small mb-3">Download a compressed SQLite dump for backup or migration.</p>
|
||||||
{% endfor %}
|
<a href="/export" class="btn btn-primary btn-sm" role="button">Download Export</a>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
<p class="text-muted">No feeds added yet.</p>
|
</div>
|
||||||
{% endif %}
|
</article>
|
||||||
</section>
|
</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 %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from reader import FeedExistsError
|
||||||
|
|
||||||
import discord_rss_bot.main as main_module
|
import discord_rss_bot.main as main_module
|
||||||
from discord_rss_bot import feeds
|
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
|
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")
|
response: Response = client.get(url="/settings")
|
||||||
assert response.status_code == 200, f"/settings failed: {response.text}"
|
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"})
|
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}"
|
assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}"
|
||||||
|
reader: Reader = get_reader_dependency()
|
||||||
response = client.get(url="/settings")
|
assert reader.get_tag((), "delivery_mode", "") == "text"
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
response: Response = client.post(
|
||||||
url="/set_global_webhook_text_length_limit",
|
url="/set_global_webhook_text_length_limit",
|
||||||
data={"text_length_limit": "2500"},
|
data={"text_length_limit": "2500"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, f"Failed to set global webhook text length limit: {response.text}"
|
assert response.status_code == 200, f"Failed to set global webhook text length limit: {response.text}"
|
||||||
|
reader: Reader = get_reader_dependency()
|
||||||
response = client.get(url="/settings")
|
assert reader.get_tag((), "webhook_text_length_limit", 0) == 2500
|
||||||
assert response.status_code == 200, f"/settings failed after setting webhook text length limit: {response.text}"
|
|
||||||
assert 'value="2500"' in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_page_shows_global_default_delivery_mode_hint() -> None:
|
def test_set_global_delivery_mode_affects_add_page() -> None:
|
||||||
response: Response = client.post(url="/set_global_delivery_mode", data={"delivery_mode": "text"})
|
"""Setting the delivery mode should be reflected in the response from /add."""
|
||||||
assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}"
|
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 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:
|
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 = {}
|
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"})
|
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}"
|
assert response.status_code == 200, f"Failed to set global screenshot layout: {response.text}"
|
||||||
|
reader: Reader = get_reader_dependency()
|
||||||
response = client.get(url="/settings")
|
assert reader.get_tag((), "screenshot_layout", "") == "mobile"
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pause_feed() -> None:
|
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(
|
def _make_stub_reader_for_embed(
|
||||||
*,
|
*,
|
||||||
stored_embed: str | None = None,
|
stored_embed: str | None = None,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue