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
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
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.
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue