Compare commits
No commits in common. "64a116d9478643141c6738c55361f8964bc83d52" and "d61e8ccf1004b5951c5d640705501d1928d23ac5" have entirely different histories.
64a116d947
...
d61e8ccf10
6 changed files with 211 additions and 2200 deletions
|
|
@ -1,12 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
import re
|
||||
import tempfile
|
||||
import typing
|
||||
import urllib.parse
|
||||
from contextlib import asynccontextmanager
|
||||
|
|
@ -16,7 +13,6 @@ from datetime import datetime
|
|||
from functools import lru_cache
|
||||
from html import escape
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Annotated
|
||||
from typing import TypedDict
|
||||
|
|
@ -28,11 +24,9 @@ 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
|
||||
|
|
@ -43,14 +37,11 @@ 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
|
||||
|
||||
from discord_rss_bot.custom_filters import entry_is_blacklisted
|
||||
from discord_rss_bot.custom_filters import entry_is_whitelisted
|
||||
|
|
@ -63,7 +54,6 @@ from discord_rss_bot.custom_message import get_message_username
|
|||
from discord_rss_bot.custom_message import replace_tags_in_text_message
|
||||
from discord_rss_bot.custom_message import save_embed
|
||||
from discord_rss_bot.feeds import FeedUpdateError
|
||||
from discord_rss_bot.feeds import JsonValue
|
||||
from discord_rss_bot.feeds import SentWebhookRecord
|
||||
from discord_rss_bot.feeds import coerce_media_gallery_image_limit
|
||||
from discord_rss_bot.feeds import coerce_webhook_text_length_limit
|
||||
|
|
@ -94,15 +84,12 @@ from discord_rss_bot.git_backup import commit_state_change
|
|||
from discord_rss_bot.git_backup import get_backup_path
|
||||
from discord_rss_bot.is_url_valid import is_url_valid
|
||||
from discord_rss_bot.search import create_search_context
|
||||
from discord_rss_bot.settings import data_dir
|
||||
from discord_rss_bot.settings import default_custom_embed
|
||||
from discord_rss_bot.settings import default_custom_message
|
||||
from discord_rss_bot.settings import get_reader
|
||||
from discord_rss_bot.settings import make_app_reader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from reader.types import JSONType
|
||||
|
||||
|
|
@ -287,196 +274,6 @@ 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.
|
||||
|
||||
|
|
@ -2332,14 +2129,12 @@ 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.
|
||||
|
|
@ -2391,7 +2186,6 @@ 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)
|
||||
|
||||
|
|
@ -2597,33 +2391,6 @@ async def update_feed(
|
|||
return RedirectResponse(url="/feed?feed_url=" + urllib.parse.quote(feed_url), status_code=303)
|
||||
|
||||
|
||||
@app.get("/export")
|
||||
def export_database(
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
) -> StarletteResponse:
|
||||
"""Export the entire database as a compressed SQLite file.
|
||||
|
||||
Args:
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
StarletteResponse: The exported database file for download.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
export_path: Path = reader._storage.export(tmpdir, "discord-rss-bot-export") # ruff:ignore[private-member-access]
|
||||
filename: str = export_path.name
|
||||
file_bytes: bytes = export_path.read_bytes()
|
||||
|
||||
return StarletteResponse(
|
||||
content=file_bytes,
|
||||
status_code=200,
|
||||
headers={
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/backup")
|
||||
async def manual_backup(
|
||||
request: Request,
|
||||
|
|
@ -2655,439 +2422,12 @@ async def manual_backup(
|
|||
return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303)
|
||||
|
||||
|
||||
def _get_grouped_feeds(reader: Reader) -> list[dict[str, typing.Any]]:
|
||||
"""Build a list of webhook groups with pre-computed indices for template use.
|
||||
|
||||
Each group dict contains:
|
||||
name: The webhook name (or "Orphaned (no webhook)").
|
||||
group_idx: 1-based index for the group.
|
||||
feeds: List of dicts with:
|
||||
feed: The Feed object.
|
||||
feed_idx: 1-based index within the group.
|
||||
|
||||
Returns:
|
||||
list[dict]: Grouped feeds with pre-computed indices for template rendering.
|
||||
"""
|
||||
hooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
|
||||
feeds_by_webhook: dict[str, list[Feed]] = {}
|
||||
orphaned: list[Feed] = []
|
||||
|
||||
for feed in reader.get_feeds():
|
||||
feed_webhook: str = str(reader.get_tag(feed.url, "webhook", ""))
|
||||
hook_name: str = ""
|
||||
for hook in hooks:
|
||||
if hook["url"] == feed_webhook:
|
||||
hook_name = hook["name"]
|
||||
break
|
||||
if hook_name:
|
||||
feeds_by_webhook.setdefault(hook_name, []).append(feed)
|
||||
else:
|
||||
orphaned.append(feed)
|
||||
|
||||
grouped: list[dict[str, typing.Any]] = []
|
||||
for group_idx, (name, feed_list) in enumerate(feeds_by_webhook.items(), start=1):
|
||||
grouped.append({
|
||||
"name": name,
|
||||
"group_idx": group_idx,
|
||||
"feeds": [{"feed": f, "feed_idx": idx} for idx, f in enumerate(feed_list, start=1)],
|
||||
})
|
||||
if orphaned:
|
||||
grouped.append({
|
||||
"name": "Orphaned (no webhook)",
|
||||
"group_idx": len(grouped) + 1,
|
||||
"feeds": [{"feed": f, "feed_idx": idx} for idx, f in enumerate(orphaned, start=1)],
|
||||
})
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
@app.get("/mass", response_class=HTMLResponse)
|
||||
async def get_mass(
|
||||
request: Request,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
active_tab: str = "create",
|
||||
) -> HTMLResponse:
|
||||
"""Mass operations page: create, delete, or modify feeds in bulk.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
reader: The Reader instance.
|
||||
active_tab: The active tab (create, delete, modify).
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The mass operations page.
|
||||
"""
|
||||
hooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
|
||||
context: dict[str, typing.Any] = {
|
||||
"request": request,
|
||||
"webhooks": hooks,
|
||||
"all_feeds_grouped": _get_grouped_feeds(reader),
|
||||
"active_tab": active_tab,
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="mass.html", context=context)
|
||||
|
||||
|
||||
def _create_and_tag_feed(reader: Reader, feed_url: str, webhook_url: str) -> str | None:
|
||||
"""Add a feed and set its tags, without updating.
|
||||
|
||||
Returns:
|
||||
The feed URL on success, or None if adding failed.
|
||||
"""
|
||||
clean_url: str = feed_url.strip()
|
||||
try:
|
||||
reader.add_feed(clean_url)
|
||||
except FeedExistsError:
|
||||
pass
|
||||
except ReaderError:
|
||||
return None
|
||||
|
||||
reader.set_tag(clean_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(clean_url, "save_sent_webhooks", True) # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(clean_url, "media_gallery_image_limit", cast("JSONType", 1))
|
||||
|
||||
global_webhook_text_length_limit: int = coerce_webhook_text_length_limit(
|
||||
cast("JsonValue", reader.get_tag((), "webhook_text_length_limit", 4000)), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
reader.set_tag(clean_url, "webhook_text_length_limit", cast("JSONType", global_webhook_text_length_limit))
|
||||
reader.set_tag(clean_url, "custom_message", default_custom_message) # pyright: ignore[reportArgumentType]
|
||||
|
||||
global_screenshot_layout: str = str(reader.get_tag((), "screenshot_layout", "desktop")).strip().lower()
|
||||
if global_screenshot_layout not in {"desktop", "mobile"}:
|
||||
global_screenshot_layout = "desktop"
|
||||
reader.set_tag(clean_url, "screenshot_layout", global_screenshot_layout) # pyright: ignore[reportArgumentType]
|
||||
|
||||
global_delivery_mode: str = str(reader.get_tag((), "delivery_mode", "embed")).strip().lower()
|
||||
if global_delivery_mode not in {"embed", "text"}:
|
||||
global_delivery_mode = "embed"
|
||||
reader.set_tag(clean_url, "delivery_mode", global_delivery_mode) # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(clean_url, "should_send_embed", global_delivery_mode == "embed") # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(clean_url, "embed", json.dumps(default_custom_embed)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
return clean_url
|
||||
|
||||
|
||||
def _modify_single_feed( # ruff:ignore[complex-structure, too-many-branches, too-many-statements]
|
||||
reader: Reader,
|
||||
url: str,
|
||||
modify_action: str,
|
||||
modify_value: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Apply a modification action to a single feed and return the result.
|
||||
|
||||
Args:
|
||||
reader: The Reader instance.
|
||||
url: The feed URL to modify.
|
||||
modify_action: The action to perform.
|
||||
modify_value: The value for the action.
|
||||
|
||||
Returns:
|
||||
dict: Result with url, success, error, and action_taken keys.
|
||||
"""
|
||||
result: dict[str, typing.Any] = {
|
||||
"url": url,
|
||||
"success": False,
|
||||
"error": "",
|
||||
"action_taken": "",
|
||||
}
|
||||
|
||||
try:
|
||||
reader.get_feed(url)
|
||||
except FeedNotFoundError:
|
||||
result["error"] = "Feed not found"
|
||||
return result
|
||||
|
||||
if modify_action == "pause":
|
||||
reader.disable_feed_updates(url)
|
||||
result["success"] = True
|
||||
result["action_taken"] = "Paused"
|
||||
elif modify_action == "unpause":
|
||||
reader.enable_feed_updates(url)
|
||||
result["success"] = True
|
||||
result["action_taken"] = "Unpaused"
|
||||
elif modify_action == "change_webhook":
|
||||
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
webhook_url: str = ""
|
||||
for hook in webhooks:
|
||||
if hook["name"] == modify_value:
|
||||
webhook_url = hook["url"]
|
||||
break
|
||||
if webhook_url:
|
||||
reader.set_tag(url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = f"Webhook changed to {modify_value}"
|
||||
else:
|
||||
result["error"] = f"Webhook '{modify_value}' not found"
|
||||
elif modify_action == "delivery_mode":
|
||||
if modify_value == "embed":
|
||||
reader.set_tag(url, "delivery_mode", "embed") # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(url, "should_send_embed", True) # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = "Delivery mode set to embed"
|
||||
elif modify_value == "text":
|
||||
reader.set_tag(url, "delivery_mode", "text") # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(url, "should_send_embed", False) # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = "Delivery mode set to text"
|
||||
elif modify_value == "screenshot_desktop":
|
||||
reader.set_tag(url, "delivery_mode", "screenshot") # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(url, "screenshot_layout", "desktop") # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = "Delivery mode set to screenshot (desktop)"
|
||||
elif modify_value == "screenshot_mobile":
|
||||
reader.set_tag(url, "delivery_mode", "screenshot") # pyright: ignore[reportArgumentType]
|
||||
reader.set_tag(url, "screenshot_layout", "mobile") # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = "Delivery mode set to screenshot (mobile)"
|
||||
else:
|
||||
result["error"] = f"Unknown delivery mode: {modify_value}"
|
||||
elif modify_action == "screenshot_layout":
|
||||
if modify_value in {"desktop", "mobile"}:
|
||||
reader.set_tag(url, "screenshot_layout", modify_value) # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = f"Screenshot layout set to {modify_value}"
|
||||
else:
|
||||
result["error"] = f"Unknown layout: {modify_value}"
|
||||
elif modify_action == "update_interval":
|
||||
try:
|
||||
interval: int = int(modify_value)
|
||||
except ValueError as e:
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
if interval < 1:
|
||||
result["error"] = "Interval must be at least 1 minute"
|
||||
else:
|
||||
reader.set_tag(url, ".reader.update", {"interval": interval}) # pyright: ignore[reportArgumentType]
|
||||
result["success"] = True
|
||||
result["action_taken"] = f"Update interval set to {interval} minute(s)" # ruff:ignore[hardcoded-sql-expression]
|
||||
else:
|
||||
result["error"] = f"Unknown action: {modify_action}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _update_and_mark_read(db_path: Path, feed_url: str) -> tuple[str, bool, str]:
|
||||
"""Update a feed and mark entries as read, in its own reader instance.
|
||||
|
||||
Called from worker threads to parallelize HTTP fetches.
|
||||
|
||||
Returns:
|
||||
(feed_url, success, error_message)
|
||||
"""
|
||||
worker_reader: Reader = make_app_reader(db_path)
|
||||
try:
|
||||
worker_reader.update_feed(feed_url)
|
||||
for entry in worker_reader.get_entries(feed=feed_url, read=False):
|
||||
worker_reader.set_entry_read(entry, True)
|
||||
except ReaderError as e:
|
||||
logger.warning("Failed to update feed %s: %s", feed_url, e)
|
||||
return feed_url, False, str(e)[:200]
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error updating feed %s", feed_url)
|
||||
return feed_url, False, str(e)[:200]
|
||||
finally:
|
||||
worker_reader.close()
|
||||
return feed_url, True, ""
|
||||
|
||||
|
||||
@app.post("/mass/create", response_class=HTMLResponse)
|
||||
async def post_mass_create( # ruff:ignore[complex-structure]
|
||||
request: Request,
|
||||
feed_urls: Annotated[str, Form()],
|
||||
webhook_dropdown: Annotated[str, Form()],
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
) -> HTMLResponse:
|
||||
"""Create multiple feeds at once.
|
||||
|
||||
Phase 1: Add feeds and set tags (sequential, fast, no HTTP).
|
||||
Phase 2: Update feeds in parallel via a thread pool.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
feed_urls: Feed URLs (one per line).
|
||||
webhook_dropdown: The webhook to attach feeds to.
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The mass operations page with results.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the selected webhook is not found.
|
||||
"""
|
||||
urls: list[str] = [url.strip() for url in feed_urls.strip().split("\n") if url.strip()]
|
||||
results: list[dict[str, typing.Any]] = []
|
||||
|
||||
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
|
||||
# Resolve webhook name to URL once for all feeds
|
||||
webhook_url: str = ""
|
||||
for hook in webhooks:
|
||||
if hook["name"] == webhook_dropdown:
|
||||
webhook_url = hook["url"]
|
||||
break
|
||||
|
||||
if not webhook_url:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
# Phase 1: Add feeds and set tags (sequential, fast)
|
||||
urls_to_update: list[str] = []
|
||||
for url in urls:
|
||||
added_url: str | None = _create_and_tag_feed(reader, url, webhook_url)
|
||||
if added_url:
|
||||
urls_to_update.append(added_url)
|
||||
results.append({"url": url, "success": False, "feed_url": None, "error": ""})
|
||||
continue
|
||||
results.append({"url": url, "success": False, "feed_url": None, "error": "Failed to add feed"})
|
||||
|
||||
# Phase 2: Update feeds in parallel
|
||||
if urls_to_update:
|
||||
db_path: Path = Path(data_dir) / "db.sqlite"
|
||||
max_workers: int = min(10, len(urls_to_update))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_url = {executor.submit(_update_and_mark_read, db_path, u): u for u in urls_to_update}
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
original_url: str = future_to_url[future]
|
||||
feed_url, update_success, error_msg = future.result()
|
||||
# Find and update the matching result entry
|
||||
for result in results:
|
||||
if result["url"] == original_url:
|
||||
result["success"] = update_success
|
||||
result["feed_url"] = feed_url if update_success else None
|
||||
result["error"] = error_msg
|
||||
break
|
||||
|
||||
reader.update_search()
|
||||
|
||||
success_count: int = sum(1 for r in results if r["success"])
|
||||
if success_count > 0:
|
||||
commit_state_change(reader, f"Mass create {success_count} feed(s)")
|
||||
|
||||
context: dict[str, typing.Any] = {
|
||||
"request": request,
|
||||
"webhooks": webhooks,
|
||||
"all_feeds_grouped": _get_grouped_feeds(reader),
|
||||
"active_tab": "create",
|
||||
"feed_urls": feed_urls,
|
||||
"selected_webhook": webhook_dropdown,
|
||||
"create_results": results,
|
||||
"message": f"Created {success_count} of {len(results)} feed(s).",
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="mass.html", context=context)
|
||||
|
||||
|
||||
@app.post("/mass/delete", response_class=HTMLResponse)
|
||||
async def post_mass_delete(
|
||||
request: Request,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
feed_urls: Annotated[list[str] | None, Form()] = None,
|
||||
) -> HTMLResponse:
|
||||
"""Delete multiple feeds at once.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
reader: The Reader instance.
|
||||
feed_urls: List of feed URLs to delete.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The mass operations page with results.
|
||||
"""
|
||||
if feed_urls is None:
|
||||
feed_urls = []
|
||||
results: list[dict[str, typing.Any]] = []
|
||||
|
||||
for url in feed_urls:
|
||||
result: dict[str, typing.Any] = {"url": url, "success": False, "error": ""}
|
||||
try:
|
||||
reader.delete_feed(url)
|
||||
result["success"] = True
|
||||
except FeedNotFoundError:
|
||||
result["error"] = "Feed not found"
|
||||
except Exception as e: # ruff:ignore[blind-except]
|
||||
result["error"] = str(e)[:200]
|
||||
results.append(result)
|
||||
|
||||
deleted_count: int = sum(1 for r in results if r["success"])
|
||||
if deleted_count > 0:
|
||||
commit_state_change(reader, f"Mass delete {deleted_count} feed(s)")
|
||||
|
||||
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
|
||||
failed_count: int = len(results) - deleted_count
|
||||
context: dict[str, typing.Any] = {
|
||||
"request": request,
|
||||
"webhooks": webhooks,
|
||||
"all_feeds_grouped": _get_grouped_feeds(reader),
|
||||
"active_tab": "delete",
|
||||
"delete_results": results,
|
||||
"delete_summary": {"deleted": deleted_count, "failed": failed_count},
|
||||
"message": f"Deleted {deleted_count} feed(s). {failed_count} failed.",
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="mass.html", context=context)
|
||||
|
||||
|
||||
@app.post("/mass/modify", response_class=HTMLResponse)
|
||||
async def post_mass_modify(
|
||||
request: Request,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
feed_urls: Annotated[list[str] | None, Form()] = None,
|
||||
modify_action: Annotated[str, Form()] = "",
|
||||
modify_value: Annotated[str, Form()] = "",
|
||||
) -> HTMLResponse:
|
||||
"""Modify multiple feeds at once.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
reader: The Reader instance.
|
||||
feed_urls: List of feed URLs to modify.
|
||||
modify_action: The action to perform (pause, unpause, change_webhook, etc.).
|
||||
modify_value: The value for the action.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The mass operations page with results.
|
||||
"""
|
||||
feed_urls_list: list[str] = feed_urls if feed_urls is not None else []
|
||||
results: list[dict[str, typing.Any]] = []
|
||||
|
||||
for url in feed_urls_list:
|
||||
try:
|
||||
result = _modify_single_feed(reader, url, modify_action, modify_value)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to modify feed %s", url)
|
||||
result = {"url": url, "success": False, "error": str(e)[:200], "action_taken": ""}
|
||||
results.append(result)
|
||||
|
||||
modified_count: int = sum(1 for r in results if r["success"])
|
||||
failed_count: int = sum(1 for r in results if not r["success"])
|
||||
if modified_count > 0:
|
||||
commit_state_change(reader, f"Mass modify {modified_count} feed(s)")
|
||||
|
||||
webhooks = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
|
||||
context: dict[str, typing.Any] = {
|
||||
"request": request,
|
||||
"webhooks": webhooks,
|
||||
"all_feeds_grouped": _get_grouped_feeds(reader),
|
||||
"active_tab": "modify",
|
||||
"modify_results": results,
|
||||
"modify_action": modify_action,
|
||||
"modify_value": modify_value,
|
||||
"modify_summary": {
|
||||
"modified": modified_count,
|
||||
"failed": failed_count,
|
||||
"skipped": 0,
|
||||
},
|
||||
"message": f"Modified {modified_count} feed(s). {failed_count} failed.",
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="mass.html", context=context)
|
||||
|
||||
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
async def search(
|
||||
request: Request,
|
||||
query: str,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
) -> HTMLResponse:
|
||||
):
|
||||
"""Get entries matching a full-text search query.
|
||||
|
||||
Args:
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
Mass operations | discord-rss-bot
|
||||
{% endblock title %}
|
||||
{% block description %}
|
||||
Mass create, delete, or modify RSS feeds in bulk.
|
||||
{% endblock description %}
|
||||
{% block content %}
|
||||
{% if message %}
|
||||
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
||||
<pre>{{ message }}</pre>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="nav nav-tabs nav-dark border-dark mb-3" id="massTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link text-muted {% if active_tab == 'create' %}active bg-dark text-light{% endif %}"
|
||||
id="create-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#create"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="create"
|
||||
aria-selected="{% if active_tab == 'create' %}true{% else %}false{% endif %}">
|
||||
Mass Create
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link text-muted {% if active_tab == 'delete' %}active bg-dark text-light{% endif %}"
|
||||
id="delete-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#delete"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="delete"
|
||||
aria-selected="{% if active_tab == 'delete' %}true{% else %}false{% endif %}">
|
||||
Mass Delete
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link text-muted {% if active_tab == 'modify' %}active bg-dark text-light{% endif %}"
|
||||
id="modify-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#modify"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="modify"
|
||||
aria-selected="{% if active_tab == 'modify' %}true{% else %}false{% endif %}">
|
||||
Mass Modify
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="massTabsContent">
|
||||
{# ===== Mass Create ===== #}
|
||||
<div class="tab-pane fade {% if active_tab == 'create' %}show active{% endif %}"
|
||||
id="create"
|
||||
role="tabpanel"
|
||||
aria-labelledby="create-tab">
|
||||
<div class="card border border-dark p-3 text-light">
|
||||
<h3 class="h5">Create multiple feeds</h3>
|
||||
<p class="text-muted small mb-3">Enter one feed URL per line. All feeds will be attached to the selected webhook.</p>
|
||||
<form action="/mass/create" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="feed_urls" class="form-label">Feed URLs (one per line)</label>
|
||||
<textarea name="feed_urls"
|
||||
id="feed_urls"
|
||||
class="form-control bg-dark border-dark text-muted"
|
||||
rows="10"
|
||||
placeholder="https://example.com/feed.xml https://other.com/rss https://third.com/atom">{{ feed_urls or '' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="webhook_dropdown" class="form-label">Webhook</label>
|
||||
<select class="form-select bg-dark border-dark text-muted"
|
||||
id="webhook_dropdown"
|
||||
name="webhook_dropdown"
|
||||
required>
|
||||
<option value="" {% if not selected_webhook %}selected{% endif %} disabled>Choose webhook...</option>
|
||||
{% for hook in webhooks %}
|
||||
<option value="{{ hook.name }}"
|
||||
{% if hook.name == selected_webhook %}selected{% endif %}>{{ hook.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create all feeds</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if create_results %}
|
||||
<div class="card border border-dark p-3 text-light mt-3">
|
||||
<h3 class="h5">Results</h3>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for result in create_results %}
|
||||
<li class="mb-2 {% if result.success %}text-success{% else %}text-danger{% endif %}">
|
||||
<strong>{{ result.url }}</strong>
|
||||
{% if result.success %}
|
||||
✓ Added
|
||||
{% if result.feed_url %}
|
||||
- <a class="text-success" href="/feed?feed_url={{ result.feed_url|encode_url }}">View feed</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
✗ {{ result.error }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# ===== Mass Delete ===== #}
|
||||
<div class="tab-pane fade {% if active_tab == 'delete' %}show active{% endif %}"
|
||||
id="delete"
|
||||
role="tabpanel"
|
||||
aria-labelledby="delete-tab">
|
||||
<div class="card border border-dark p-3 text-light">
|
||||
<h3 class="h5">Delete multiple feeds</h3>
|
||||
<p class="text-muted small mb-3">Select feeds to delete. This action cannot be undone.</p>
|
||||
{% if all_feeds_grouped %}
|
||||
<form id="mass-delete-form" action="/mass/delete" method="post">
|
||||
{% for group in all_feeds_grouped %}
|
||||
<div class="mb-3 border border-dark p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="h6 mb-0">{{ group.name }} ({{ group.feeds|length }})</h4>
|
||||
<div>
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm select-all"
|
||||
data-group="{{ group.group_idx }}">Select all</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm deselect-all"
|
||||
data-group="{{ group.group_idx }}">Deselect all</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for item in group.feeds %}
|
||||
{% set feed = item.feed %}
|
||||
<li class="form-check">
|
||||
<input class="form-check-input mass-delete-checkbox group-{{ group.group_idx }}"
|
||||
type="checkbox"
|
||||
name="feed_urls"
|
||||
value="{{ feed.url }}"
|
||||
id="delete-{{ group.group_idx }}-{{ item.feed_idx }}" />
|
||||
<label class="form-check-label text-muted" for="delete-{{ group.group_idx }}-{{ item.feed_idx }}">
|
||||
{% if feed.title %}
|
||||
{{ feed.title }}
|
||||
{% else %}
|
||||
{{ feed.url }}
|
||||
{% endif %}
|
||||
{% if feed.title %}<span class="small">- {{ feed.url }}</span>{% endif %}
|
||||
{% if not feed.updates_enabled %}<span class="text-warning small">Paused</span>{% endif %}
|
||||
{% if feed.last_exception %}<span class="text-danger small">Error</span>{% endif %}
|
||||
</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Are you sure you want to delete the selected feeds? This cannot be undone.');">
|
||||
Delete selected feeds
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No feeds found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if delete_results %}
|
||||
<div class="card border border-dark p-3 text-light mt-3">
|
||||
<h3 class="h5">Results</h3>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for result in delete_results %}
|
||||
<li class="mb-2 {% if result.success %}text-success{% else %}text-danger{% endif %}">
|
||||
<strong>{{ result.url }}</strong>
|
||||
{% if result.success %}
|
||||
✓ Deleted
|
||||
{% else %}
|
||||
✗ {{ result.error }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="mt-2 text-muted">{{ delete_summary.deleted }} deleted, {{ delete_summary.failed }} failed</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# ===== Mass Modify ===== #}
|
||||
<div class="tab-pane fade {% if active_tab == 'modify' %}show active{% endif %}"
|
||||
id="modify"
|
||||
role="tabpanel"
|
||||
aria-labelledby="modify-tab">
|
||||
<div class="card border border-dark p-3 text-light">
|
||||
<h3 class="h5">Modify multiple feeds</h3>
|
||||
<p class="text-muted small mb-3">Select feeds and choose a setting to update.</p>
|
||||
{% if all_feeds_grouped %}
|
||||
<form id="mass-modify-form" action="/mass/modify" method="post">
|
||||
{% for group in all_feeds_grouped %}
|
||||
<div class="mb-3 border border-dark p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="h6 mb-0">{{ group.name }} ({{ group.feeds|length }})</h4>
|
||||
<div>
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm select-all"
|
||||
data-group="modify-{{ group.group_idx }}">Select all</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm deselect-all"
|
||||
data-group="modify-{{ group.group_idx }}">Deselect all</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for item in group.feeds %}
|
||||
{% set feed = item.feed %}
|
||||
<li class="form-check">
|
||||
<input class="form-check-input mass-modify-checkbox group-modify-{{ group.group_idx }}"
|
||||
type="checkbox"
|
||||
name="feed_urls"
|
||||
value="{{ feed.url }}"
|
||||
id="modify-{{ group.group_idx }}-{{ item.feed_idx }}" />
|
||||
<label class="form-check-label text-muted"
|
||||
for="modify-{{ group.group_idx }}-{{ item.feed_idx }}">
|
||||
{% if feed.title %}
|
||||
{{ feed.title }}
|
||||
{% else %}
|
||||
{{ feed.url }}
|
||||
{% endif %}
|
||||
{% if feed.title %}<span class="small">- {{ feed.url }}</span>{% endif %}
|
||||
{% if not feed.updates_enabled %}<span class="text-warning small">Paused</span>{% endif %}
|
||||
</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<hr class="border-secondary" />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="modify_action" class="form-label">Action</label>
|
||||
<select class="form-select bg-dark border-dark text-muted"
|
||||
id="modify_action"
|
||||
name="modify_action"
|
||||
required>
|
||||
<option value="" disabled {% if not modify_action %}selected{% endif %}>Choose action...</option>
|
||||
<option value="pause" {% if modify_action == 'pause' %}selected{% endif %}>Pause (disable updates)</option>
|
||||
<option value="unpause" {% if modify_action == 'unpause' %}selected{% endif %}>Unpause (enable updates)</option>
|
||||
<option value="change_webhook" {% if modify_action == 'change_webhook' %}selected{% endif %}>Change webhook</option>
|
||||
<option value="delivery_mode" {% if modify_action == 'delivery_mode' %}selected{% endif %}>Change delivery mode</option>
|
||||
<option value="screenshot_layout" {% if modify_action == 'screenshot_layout' %}selected{% endif %}>Change screenshot layout</option>
|
||||
<option value="update_interval" {% if modify_action == 'update_interval' %}selected{% endif %}>Change update interval (minutes)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4" id="modify-value-group">
|
||||
<label for="modify_value" class="form-label">Value</label>
|
||||
<div id="modify-value-dynamic">
|
||||
<input type="text"
|
||||
name="modify_value"
|
||||
id="modify_value"
|
||||
class="form-control bg-dark border-dark text-muted"
|
||||
value="{{ modify_value or '' }}"
|
||||
placeholder="New value" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning mt-3">Apply to selected feeds</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No feeds found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if modify_results %}
|
||||
<div class="card border border-dark p-3 text-light mt-3">
|
||||
<h3 class="h5">Results</h3>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for result in modify_results %}
|
||||
<li class="mb-2 {% if result.success %}text-success{% else %}text-danger{% endif %}">
|
||||
<strong>{{ result.url }}</strong>
|
||||
{% if result.success %}
|
||||
✓ {{ result.action_taken }}
|
||||
{% else %}
|
||||
✗ {{ result.error }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="mt-2 text-muted">
|
||||
{{ modify_summary.modified }} modified, {{ modify_summary.failed }} failed, {{ modify_summary.skipped }} skipped
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
{% block head %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Select/Deselect all for mass delete and modify checkboxes
|
||||
document.querySelectorAll('.select-all').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var group = this.getAttribute('data-group');
|
||||
document.querySelectorAll('.group-' + group).forEach(function(cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.deselect-all').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var group = this.getAttribute('data-group');
|
||||
document.querySelectorAll('.group-' + group).forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Dynamic value field for modify action
|
||||
var actionSelect = document.getElementById('modify_action');
|
||||
var valueDynamic = document.getElementById('modify-value-dynamic');
|
||||
if (actionSelect && valueDynamic) {
|
||||
function updateModifyValueField() {
|
||||
var action = actionSelect.value;
|
||||
var html = '';
|
||||
if (action === 'pause' || action === 'unpause') {
|
||||
html = '<p class="text-muted small mt-2">No additional value needed.</p>';
|
||||
} else if (action === 'change_webhook') {
|
||||
html = `<select name="modify_value" class="form-select bg-dark border-dark text-muted" required>
|
||||
<option value="" disabled selected>Choose webhook...</option>
|
||||
{% for hook in webhooks %}
|
||||
<option value="{{ hook.name }}">{{ hook.name }}</option>
|
||||
{% endfor %}
|
||||
</select>`;
|
||||
} else if (action === 'delivery_mode') {
|
||||
html = `<select name="modify_value" class="form-select bg-dark border-dark text-muted" required>
|
||||
<option value="" disabled selected>Choose mode...</option>
|
||||
<option value="embed">Embed</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="screenshot_desktop">Screenshot (Desktop)</option>
|
||||
<option value="screenshot_mobile">Screenshot (Mobile)</option>
|
||||
</select>`;
|
||||
} else if (action === 'screenshot_layout') {
|
||||
html = `<select name="modify_value" class="form-select bg-dark border-dark text-muted" required>
|
||||
<option value="" disabled selected>Choose layout...</option>
|
||||
<option value="desktop">Desktop</option>
|
||||
<option value="mobile">Mobile</option>
|
||||
</select>`;
|
||||
} else if (action === 'update_interval') {
|
||||
html = '<input type="number" name="modify_value" class="form-control bg-dark border-dark text-muted" required min="1" value="15" placeholder="Minutes" />';
|
||||
} else {
|
||||
html = '<input type="text" name="modify_value" class="form-control bg-dark border-dark text-muted" placeholder="Value" />';
|
||||
}
|
||||
valueDynamic.innerHTML = html;
|
||||
}
|
||||
actionSelect.addEventListener('change', updateModifyValueField);
|
||||
// Init
|
||||
if (actionSelect.value) {
|
||||
updateModifyValueField();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock head %}
|
||||
|
|
@ -18,10 +18,6 @@
|
|||
</li>
|
||||
<li class="nav-item nav-link d-none d-md-block">|</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/mass">Mass ops</a>
|
||||
</li>
|
||||
<li class="nav-item nav-link d-none d-md-block">|</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/webhooks">Webhooks</a>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -6,47 +6,43 @@
|
|||
Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot.
|
||||
{% endblock description %}
|
||||
{% block content %}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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 class="text-muted mt-2 mb-4">
|
||||
Set a default interval for all feeds. Individual feeds can still override this value.
|
||||
</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">
|
||||
<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 bg-dark text-light border-secondary"
|
||||
class="form-control settings-input"
|
||||
name="interval_minutes"
|
||||
placeholder="Minutes"
|
||||
min="1"
|
||||
value="{{ global_interval }}"
|
||||
required />
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<button class="btn btn-primary px-4" 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">
|
||||
<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 bg-dark text-light border-secondary"
|
||||
class="form-select settings-input"
|
||||
name="delivery_mode">
|
||||
<option value="embed"
|
||||
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
|
||||
|
|
@ -58,134 +54,73 @@
|
|||
<option value="text"
|
||||
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<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>
|
||||
<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">
|
||||
<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 bg-dark text-light border-secondary"
|
||||
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" type="submit">Save</button>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</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">
|
||||
<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 bg-dark text-light border-secondary"
|
||||
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" type="submit">Save</button>
|
||||
<button class="btn btn-primary px-4" 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 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>
|
||||
</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>
|
||||
</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>
|
||||
</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 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 table-sm mb-0">
|
||||
<table class="table table-dark table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feed</th>
|
||||
|
|
@ -193,10 +128,7 @@
|
|||
<th>Status</th>
|
||||
<th>Interval</th>
|
||||
<th>Last Updated</th>
|
||||
<th>
|
||||
Next
|
||||
Update
|
||||
</th>
|
||||
<th>Next Update</th>
|
||||
<th>Set Interval (min)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
|
|
@ -231,13 +163,13 @@
|
|||
<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">
|
||||
<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 bg-dark text-light border-secondary w-auto d-inline-block"
|
||||
class="form-control form-control-sm interval-input"
|
||||
name="interval_minutes"
|
||||
placeholder="Min"
|
||||
placeholder="Minutes"
|
||||
min="1"
|
||||
value="{{ item.interval if item.interval else global_interval }}" />
|
||||
<button class="btn btn-primary btn-sm" type="submit">Set</button>
|
||||
|
|
@ -258,10 +190,7 @@
|
|||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0">No feeds added yet.</p>
|
||||
<p class="text-muted">No feeds added yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ from typing import cast
|
|||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from reader import FeedExistsError
|
||||
from reader import FeedNotFoundError
|
||||
|
||||
import discord_rss_bot.main as main_module
|
||||
from discord_rss_bot import feeds
|
||||
|
|
@ -28,6 +25,7 @@ from discord_rss_bot.main import get_reader_dependency
|
|||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx2 import Response
|
||||
from reader import Entry
|
||||
from reader import Reader
|
||||
|
|
@ -648,38 +646,44 @@ def test_author_templates_render_authors_str() -> None:
|
|||
assert "Legacy Entry Author" not in filter_preview_html
|
||||
|
||||
|
||||
def test_settings_page_loads() -> None:
|
||||
"""The settings page should render without errors."""
|
||||
def test_settings_page_shows_screenshot_layout_setting() -> None:
|
||||
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_stores_value() -> None:
|
||||
"""POST /set_global_delivery_mode should persist the delivery mode in reader tags."""
|
||||
def test_set_global_delivery_mode() -> 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}"
|
||||
reader: Reader = get_reader_dependency()
|
||||
assert reader.get_tag((), "delivery_mode", "") == "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)
|
||||
|
||||
|
||||
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."""
|
||||
def test_set_global_webhook_text_length_limit() -> None:
|
||||
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}"
|
||||
reader: Reader = get_reader_dependency()
|
||||
assert reader.get_tag((), "webhook_text_length_limit", 0) == 2500
|
||||
|
||||
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
|
||||
|
||||
|
||||
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]
|
||||
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}"
|
||||
|
||||
response: Response = client.get(url="/add")
|
||||
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:
|
||||
|
|
@ -895,12 +899,13 @@ def test_sent_webhooks_view_shows_saved_records() -> None:
|
|||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_set_global_screenshot_layout_stores_value() -> None:
|
||||
"""POST /set_global_screenshot_layout should persist the layout in reader tags."""
|
||||
def test_set_global_screenshot_layout() -> None:
|
||||
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}"
|
||||
reader: Reader = get_reader_dependency()
|
||||
assert reader.get_tag((), "screenshot_layout", "") == "mobile"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_pause_feed() -> None:
|
||||
|
|
@ -2549,268 +2554,10 @@ def test_reader_dependency_override_is_used() -> None:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for OPML import / export
|
||||
# Tests for post_embed — saving embed fields (including clearing to "")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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,
|
||||
|
|
@ -3356,532 +3103,3 @@ def test_post_set_custom_clears_username_and_avatar() -> None:
|
|||
assert not stub.set_tag.call_args_list[1].args[2]
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mass operations tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubFeedForMass:
|
||||
"""Minimal feed stub for mass operations tests."""
|
||||
|
||||
def __init__(self, url: str, title: str | None = None, updates_enabled: bool = True) -> None: # ruff:ignore[boolean-default-value-positional-argument, boolean-type-hint-positional-argument]
|
||||
self.url: str = url
|
||||
self.title: str | None = title
|
||||
self.updates_enabled: bool = updates_enabled
|
||||
self.last_exception: str | None = None
|
||||
|
||||
|
||||
class _StubReaderForMass:
|
||||
"""Stub reader with feeds grouped under a webhook, used by mass operations tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
feeds: list[_StubFeedForMass] | None = None,
|
||||
*,
|
||||
webhook_name: str = "",
|
||||
webhook_url: str = "",
|
||||
delete_fail: bool = False,
|
||||
) -> None:
|
||||
self._feeds: list[_StubFeedForMass] = feeds or []
|
||||
self._webhook_name: str = webhook_name
|
||||
self._webhook_url: str = webhook_url
|
||||
self._delete_fail: bool = delete_fail
|
||||
self.set_tag_calls: list[tuple[str, str, object]] = []
|
||||
self.disabled_feeds: set[str] = set()
|
||||
self.enabled_feeds: set[str] = set()
|
||||
|
||||
def get_feeds(self) -> list[_StubFeedForMass]:
|
||||
"""Return the list of feeds."""
|
||||
return self._feeds
|
||||
|
||||
def get_tag(self, resource: tuple[()] | str, key: str, default: TestTagValue = None) -> TestTagValue: # ruff:ignore[too-many-return-statements]
|
||||
"""Return stored tag values."""
|
||||
if resource == () and key == "webhooks":
|
||||
if self._webhook_name:
|
||||
return [{"name": self._webhook_name, "url": self._webhook_url}]
|
||||
return []
|
||||
if resource == () and key == "delivery_mode":
|
||||
return "embed"
|
||||
if resource == () and key == "webhook_text_length_limit":
|
||||
return 4000
|
||||
if resource == () and key == "screenshot_layout":
|
||||
return "desktop"
|
||||
if isinstance(resource, str) and key == "webhook":
|
||||
return self._webhook_url
|
||||
return default
|
||||
|
||||
def set_tag(self, resource: str, key: str, value: object) -> None: # pyright: ignore[reportArgumentType]
|
||||
"""Record tag set calls."""
|
||||
self.set_tag_calls.append((resource, key, value))
|
||||
|
||||
def update_search(self) -> None:
|
||||
"""No-op search index update."""
|
||||
|
||||
def delete_feed(self, url: str) -> None:
|
||||
"""Delete a feed by URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If delete_fail was set in the constructor.
|
||||
"""
|
||||
if self._delete_fail:
|
||||
msg = "Delete failed"
|
||||
raise RuntimeError(msg)
|
||||
self._feeds = [f for f in self._feeds if f.url != url]
|
||||
|
||||
def get_feed(self, url: str) -> _StubFeedForMass:
|
||||
"""Get a feed by URL.
|
||||
|
||||
Returns:
|
||||
The matching feed.
|
||||
|
||||
Raises:
|
||||
FeedNotFoundError: If no feed matches the URL.
|
||||
"""
|
||||
for feed in self._feeds:
|
||||
if feed.url == url:
|
||||
return feed
|
||||
raise FeedNotFoundError(url)
|
||||
|
||||
def disable_feed_updates(self, url: str) -> None:
|
||||
"""Disable updates for a feed."""
|
||||
self.disabled_feeds.add(url)
|
||||
self.enabled_feeds.discard(url)
|
||||
|
||||
def enable_feed_updates(self, url: str) -> None:
|
||||
"""Enable updates for a feed."""
|
||||
self.enabled_feeds.add(url)
|
||||
self.disabled_feeds.discard(url)
|
||||
|
||||
def add_feed(self, url: str) -> None:
|
||||
"""Add a feed to the stub."""
|
||||
self._feeds.append(_StubFeedForMass(url=url))
|
||||
|
||||
|
||||
MASS_FEED_1_URL: str = "https://example.com/feed1.xml"
|
||||
MASS_FEED_2_URL: str = "https://example.com/feed2.xml"
|
||||
MASS_WEBHOOK_NAME: str = "Mass test webhook"
|
||||
MASS_WEBHOOK_URL: str = "https://discord.com/api/webhooks/mass/abc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mass_stub_reader() -> _StubReaderForMass:
|
||||
"""Create a stub reader with feeds for mass operations tests.
|
||||
|
||||
Returns:
|
||||
A configured stub reader instance.
|
||||
"""
|
||||
return _StubReaderForMass(
|
||||
feeds=[
|
||||
_StubFeedForMass(url=MASS_FEED_1_URL, title="Feed One"),
|
||||
_StubFeedForMass(url=MASS_FEED_2_URL, title="Feed Two", updates_enabled=False),
|
||||
],
|
||||
webhook_name=MASS_WEBHOOK_NAME,
|
||||
webhook_url=MASS_WEBHOOK_URL,
|
||||
)
|
||||
|
||||
|
||||
class TestMassPage:
|
||||
"""Tests for the mass operations page."""
|
||||
|
||||
def test_mass_page_loads(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""GET /mass should render all three tabs."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.get("/mass")
|
||||
assert response.status_code == 200
|
||||
assert "Mass Create" in response.text
|
||||
assert "Mass Delete" in response.text
|
||||
assert "Mass Modify" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_mass_page_shows_feeds_when_webhook_exists(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""GET /mass should list feeds grouped by webhook."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.get("/mass")
|
||||
assert response.status_code == 200
|
||||
assert MASS_WEBHOOK_NAME in response.text
|
||||
assert "Feed One" in response.text
|
||||
assert "Feed Two" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_mass_page_no_feeds_shows_empty_state(self) -> None:
|
||||
"""GET /mass with no feeds should show 'No feeds found'."""
|
||||
stub = _StubReaderForMass(webhook_name=MASS_WEBHOOK_NAME, webhook_url=MASS_WEBHOOK_URL)
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: stub
|
||||
try:
|
||||
response: Response = client.get("/mass")
|
||||
assert response.status_code == 200
|
||||
assert "No feeds found" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_mass_page_defaults_to_create_tab(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""GET /mass without active_tab should default to create tab."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.get("/mass")
|
||||
assert response.status_code == 200
|
||||
assert 'id="create-tab"' in response.text
|
||||
assert 'aria-selected="true"' in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_mass_page_respects_active_tab_param(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""GET /mass?active_tab=delete should show delete tab active."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.get("/mass?active_tab=delete")
|
||||
assert response.status_code == 200
|
||||
assert "Delete multiple feeds" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_mass_page_shows_webhooks_in_create_form(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""The mass create tab should list webhooks in the dropdown."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.get("/mass")
|
||||
assert response.status_code == 200
|
||||
assert MASS_WEBHOOK_NAME in response.text
|
||||
assert "Create all feeds" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
class TestMassDelete:
|
||||
"""Tests for POST /mass/delete."""
|
||||
|
||||
def test_delete_selected_feeds(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/delete with feed URLs should delete them."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/delete",
|
||||
data={"feed_urls": [MASS_FEED_1_URL, MASS_FEED_2_URL]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Deleted 2 feed(s)" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_delete_no_selection(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/delete with no feed URLs should not crash."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.post("/mass/delete")
|
||||
assert response.status_code == 200
|
||||
assert "Deleted 0 feed(s)" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_delete_feed_error_does_not_crash(self) -> None:
|
||||
"""POST /mass/delete should handle reader errors gracefully."""
|
||||
stub = _StubReaderForMass(
|
||||
feeds=[_StubFeedForMass(url=MASS_FEED_1_URL)],
|
||||
delete_fail=True,
|
||||
)
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: stub
|
||||
try:
|
||||
response: Response = client.post("/mass/delete", data={"feed_urls": [MASS_FEED_1_URL]})
|
||||
assert response.status_code == 200
|
||||
assert "0 deleted, 1 failed" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
class TestMassModify:
|
||||
"""Tests for POST /mass/modify."""
|
||||
|
||||
def test_modify_pause_feed(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with pause action should disable feed updates."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "pause",
|
||||
"modify_value": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert MASS_FEED_1_URL in response.text
|
||||
assert "Modified 1 feed(s)" in response.text
|
||||
assert MASS_FEED_1_URL in mass_stub_reader.disabled_feeds
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_unpause_feed(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with unpause action should enable feed updates."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_2_URL],
|
||||
"modify_action": "unpause",
|
||||
"modify_value": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Modified 1 feed(s)" in response.text
|
||||
assert MASS_FEED_2_URL in mass_stub_reader.enabled_feeds
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_nonexistent_feed(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify for a non-existent feed should report error."""
|
||||
nonexistent = "https://example.com/nonexistent.xml"
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [nonexistent],
|
||||
"modify_action": "pause",
|
||||
"modify_value": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "0 modified, 1 failed" in response.text
|
||||
assert "Feed not found" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_unknown_action(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with an unknown action should report error."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "nonexistent_action",
|
||||
"modify_value": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "0 modified, 1 failed" in response.text
|
||||
assert "Unknown action" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_change_webhook_updates_tag(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with change_webhook should call set_tag with the new webhook URL."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "change_webhook",
|
||||
"modify_value": MASS_WEBHOOK_NAME,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Modified 1 feed(s)" in response.text
|
||||
# Check that set_tag was called with the webhook tag
|
||||
webhook_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "webhook"]
|
||||
assert len(webhook_calls) == 1
|
||||
assert webhook_calls[0][2] == MASS_WEBHOOK_URL
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_unknown_webhook_reports_error(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with a non-existent webhook name should report error."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "change_webhook",
|
||||
"modify_value": "Nonexistent Hook",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "0 modified, 1 failed" in response.text
|
||||
assert "not found" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_delivery_mode_embed(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with delivery_mode=embed should set delivery_mode and should_send_embed."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "delivery_mode",
|
||||
"modify_value": "embed",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Modified 1 feed(s)" in response.text
|
||||
delivery_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "delivery_mode"]
|
||||
embed_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "should_send_embed"]
|
||||
assert len(delivery_calls) == 1
|
||||
assert delivery_calls[0][2] == "embed"
|
||||
assert len(embed_calls) == 1
|
||||
assert embed_calls[0][2] is True
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_delivery_mode_text(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with delivery_mode=text should set should_send_embed to False."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "delivery_mode",
|
||||
"modify_value": "text",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Modified 1 feed(s)" in response.text
|
||||
embed_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "should_send_embed"]
|
||||
assert len(embed_calls) == 1
|
||||
assert embed_calls[0][2] is False
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_update_interval(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with update_interval should store the interval."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
with patch("discord_rss_bot.main.commit_state_change"):
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "update_interval",
|
||||
"modify_value": "30",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Modified 1 feed(s)" in response.text
|
||||
update_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == ".reader.update"]
|
||||
assert len(update_calls) == 1
|
||||
assert update_calls[0][2] == {"interval": 30}
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_update_interval_invalid(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with non-numeric update_interval should report error."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={
|
||||
"feed_urls": [MASS_FEED_1_URL],
|
||||
"modify_action": "update_interval",
|
||||
"modify_value": "not-a-number",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "0 modified, 1 failed" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_modify_no_selection(self, mass_stub_reader: _StubReaderForMass) -> None:
|
||||
"""POST /mass/modify with no feed URLs should not crash."""
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
|
||||
try:
|
||||
response: Response = client.post(
|
||||
"/mass/modify",
|
||||
data={"modify_action": "pause", "modify_value": ""},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Modified 0 feed(s)" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
class TestMassCreate:
|
||||
"""Tests for POST /mass/create."""
|
||||
|
||||
def test_create_rejects_missing_webhook(self) -> None:
|
||||
"""POST /mass/create without a valid webhook should return 404."""
|
||||
stub = _StubReaderForMass()
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: stub
|
||||
try:
|
||||
response: Response = client.post(
|
||||
"/mass/create",
|
||||
data={
|
||||
"feed_urls": "https://example.com/feed.xml",
|
||||
"webhook_dropdown": "nonexistent",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Webhook not found" in response.text
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_create_single_url(self) -> None:
|
||||
"""POST /mass/create with a single URL should attempt to create it."""
|
||||
stub = _StubReaderForMass(webhook_name=MASS_WEBHOOK_NAME, webhook_url=MASS_WEBHOOK_URL)
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: stub
|
||||
try:
|
||||
with (
|
||||
patch("discord_rss_bot.main._update_and_mark_read") as mock_update,
|
||||
patch("discord_rss_bot.main.commit_state_change"),
|
||||
):
|
||||
mock_update.return_value = ("https://example.com/new-feed.xml", True, "")
|
||||
response: Response = client.post(
|
||||
"/mass/create",
|
||||
data={
|
||||
"feed_urls": "https://example.com/new-feed.xml",
|
||||
"webhook_dropdown": MASS_WEBHOOK_NAME,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
def test_create_multiple_urls(self) -> None:
|
||||
"""POST /mass/create with multiple URLs should attempt to create them."""
|
||||
stub = _StubReaderForMass(webhook_name=MASS_WEBHOOK_NAME, webhook_url=MASS_WEBHOOK_URL)
|
||||
app.dependency_overrides[get_reader_dependency] = lambda: stub
|
||||
try:
|
||||
with (
|
||||
patch("discord_rss_bot.main._update_and_mark_read") as mock_update,
|
||||
patch("discord_rss_bot.main.commit_state_change"),
|
||||
):
|
||||
mock_update.side_effect = [
|
||||
("https://example.com/feed1.xml", True, ""),
|
||||
("https://example.com/feed2.xml", True, ""),
|
||||
]
|
||||
response: Response = client.post(
|
||||
"/mass/create",
|
||||
data={
|
||||
"feed_urls": "https://example.com/feed1.xml\nhttps://example.com/feed2.xml",
|
||||
"webhook_dropdown": MASS_WEBHOOK_NAME,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
class TestMassNav:
|
||||
"""Tests for the mass operations navigation link."""
|
||||
|
||||
def test_mass_link_in_navbar(self) -> None:
|
||||
"""The navbar should contain a link to the mass operations page."""
|
||||
response: Response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "/mass" in response.text, f"Expected /mass link in navbar: {response.text}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue