Add support for importing and exporting OMPL feeds
This commit is contained in:
parent
f40773e013
commit
2746b961b2
4 changed files with 846 additions and 220 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
|
|
@ -25,9 +26,11 @@ import uvicorn
|
|||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import Depends
|
||||
from fastapi import FastAPI
|
||||
from fastapi import File
|
||||
from fastapi import Form
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
from fastapi import UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
|
@ -38,10 +41,12 @@ from reader import Entry
|
|||
from reader import EntryNotFoundError
|
||||
from reader import Feed
|
||||
from reader import FeedExistsError
|
||||
from reader import FeedImportError
|
||||
from reader import FeedNotFoundError
|
||||
from reader import Reader
|
||||
from reader import ReaderError
|
||||
from reader import TagNotFoundError
|
||||
from reader import opml
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
|
|
@ -276,6 +281,196 @@ templates.env.globals["get_backup_path"] = get_backup_path # pyright: ignore[re
|
|||
templates.env.globals["has_webhooks"] = has_webhooks # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
@app.get("/export_opml")
|
||||
def export_opml(
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
) -> StarletteResponse:
|
||||
"""Export all feeds as an OPML subscription list.
|
||||
|
||||
Args:
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
StarletteResponse: The OPML file for download.
|
||||
"""
|
||||
export = reader.export_feeds()
|
||||
return StarletteResponse(
|
||||
content=export.content,
|
||||
status_code=200,
|
||||
headers={
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Disposition": f'attachment; filename="{export.filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/import_opml", response_model=None)
|
||||
async def import_opml(
|
||||
request: Request,
|
||||
file: Annotated[UploadFile, File()],
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
):
|
||||
"""Upload an OPML file and show a preview of feeds to import.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
file: The uploaded OPML file.
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The OPML import preview page.
|
||||
RedirectResponse: Redirect to settings on error.
|
||||
"""
|
||||
if not file.filename or not file.filename.lower().endswith(".opml"):
|
||||
return RedirectResponse(
|
||||
url=f"/settings?message={urllib.parse.quote('Please upload a file with a .opml extension.')}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
try:
|
||||
content: bytes = await file.read()
|
||||
feeds_to_import = opml.parse(io.BytesIO(content))
|
||||
except FeedImportError as e:
|
||||
return RedirectResponse(
|
||||
url=f"/settings?message={urllib.parse.quote(f'Failed to parse OPML file: {e}')}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
# Check which feeds already exist
|
||||
existing_urls: set[str] = {feed.url for feed in reader.get_feeds()}
|
||||
feed_list = [
|
||||
{
|
||||
"url": feed.url,
|
||||
"title": feed.title or feed.url,
|
||||
"already_exists": feed.url in existing_urls,
|
||||
}
|
||||
for feed in feeds_to_import
|
||||
]
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"feeds": feed_list,
|
||||
"total": len(feed_list),
|
||||
"new_count": sum(1 for f in feed_list if not f["already_exists"]),
|
||||
"existing_count": sum(1 for f in feed_list if f["already_exists"]),
|
||||
"webhooks": reader.get_tag((), "webhooks", []),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="import_opml_preview.html", context=context)
|
||||
|
||||
|
||||
@app.post("/import_opml_confirm")
|
||||
async def import_opml_confirm(
|
||||
request: Request,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
feed_urls: Annotated[list[str] | None, Form()] = None,
|
||||
webhook_name: Annotated[str | None, Form()] = None,
|
||||
) -> RedirectResponse:
|
||||
"""Import the selected feeds from the OPML preview.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
reader: The Reader instance.
|
||||
feed_urls: The selected feed URLs to import.
|
||||
webhook_name: Optional webhook name to attach imported feeds to.
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Redirect to the settings page with a status message.
|
||||
"""
|
||||
if feed_urls is None:
|
||||
feed_urls = []
|
||||
if not feed_urls:
|
||||
return RedirectResponse(
|
||||
url="/settings?message=No%20feeds%20were%20selected%20for%20import.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
webhook_url = _resolve_webhook_url(reader, webhook_name)
|
||||
|
||||
imported, updated_webhook, errors = _import_opml_feeds(reader, feed_urls, webhook_url)
|
||||
message = _summarize_opml_import(imported, updated_webhook, errors)
|
||||
|
||||
logger.info("OPML import complete: %s", message)
|
||||
commit_state_change(reader, f"OPML import: {imported} feeds")
|
||||
|
||||
return RedirectResponse(url=f"/settings?message={urllib.parse.quote(message)}", status_code=303)
|
||||
|
||||
|
||||
def _resolve_webhook_url(reader: Reader, webhook_name: str | None) -> str:
|
||||
"""Resolve a webhook name to its URL from reader storage.
|
||||
|
||||
Args:
|
||||
reader: The Reader instance.
|
||||
webhook_name: The webhook name to look up.
|
||||
|
||||
Returns:
|
||||
The webhook URL, or empty string if not found or no name given.
|
||||
"""
|
||||
if not webhook_name:
|
||||
return ""
|
||||
hooks = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
|
||||
for hook in hooks:
|
||||
if hook.get("name") == webhook_name.strip():
|
||||
return hook.get("url", "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _import_opml_feeds(
|
||||
reader: Reader,
|
||||
feed_urls: list[str],
|
||||
webhook_url: str,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Add feeds from an OPML import, optionally setting webhooks.
|
||||
|
||||
Args:
|
||||
reader: The Reader instance.
|
||||
feed_urls: The feed URLs to add.
|
||||
webhook_url: Webhook URL to attach, or empty string.
|
||||
|
||||
Returns:
|
||||
A tuple of (imported_count, updated_webhook_count, error_messages).
|
||||
"""
|
||||
imported: int = 0
|
||||
updated_webhook: int = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for feed_url in feed_urls:
|
||||
try:
|
||||
reader.add_feed(feed_url)
|
||||
if webhook_url:
|
||||
reader.set_tag(feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
|
||||
imported += 1
|
||||
except FeedExistsError:
|
||||
if webhook_url:
|
||||
reader.set_tag(feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
|
||||
updated_webhook += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{feed_url}: {e}")
|
||||
logger.exception("Failed to import feed: %s", feed_url)
|
||||
|
||||
return imported, updated_webhook, errors
|
||||
|
||||
|
||||
def _summarize_opml_import(imported: int, updated_webhook: int, errors: list[str]) -> str:
|
||||
"""Build a human-readable summary of an OPML import result.
|
||||
|
||||
Args:
|
||||
imported: Number of newly imported feeds.
|
||||
updated_webhook: Number of existing feeds whose webhook was updated.
|
||||
errors: List of error strings.
|
||||
|
||||
Returns:
|
||||
A summary string.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if imported:
|
||||
parts.append(f"Successfully imported {imported} feed{'s' if imported != 1 else ''}")
|
||||
if updated_webhook:
|
||||
parts.append(f"Updated webhook for {updated_webhook} existing feed{'s' if updated_webhook != 1 else ''}")
|
||||
if errors:
|
||||
parts.append(f"{len(errors)} error{'s' if len(errors) != 1 else ''}")
|
||||
return ". ".join(parts) + "."
|
||||
|
||||
|
||||
def get_global_delivery_mode(reader: Reader) -> str:
|
||||
"""Return the normalized default delivery mode for new feeds.
|
||||
|
||||
|
|
@ -2131,12 +2326,14 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo:
|
|||
async def get_settings(
|
||||
request: Request,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
message: str = "",
|
||||
):
|
||||
"""Settings page.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
reader: The Reader instance.
|
||||
message: Optional message to display to the user.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The settings page.
|
||||
|
|
@ -2188,6 +2385,7 @@ async def get_settings(
|
|||
"max_webhook_text_length_limit": 4000,
|
||||
"feed_intervals": feed_intervals,
|
||||
"chromium_installed": is_chromium_installed(),
|
||||
"messages": message or None,
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="settings.html", context=context)
|
||||
|
||||
|
|
|
|||
117
discord_rss_bot/templates/import_opml_preview.html
Normal file
117
discord_rss_bot/templates/import_opml_preview.html
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
Import OPML Feeds | discord-rss-bot
|
||||
{% endblock title %}
|
||||
{% block description %}
|
||||
Select which feeds to import from your OPML subscription list.
|
||||
{% endblock description %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<div class="text-light">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h2 class="mb-0">Import OPML Feeds</h2>
|
||||
</div>
|
||||
<p class="text-muted mt-2 mb-4">
|
||||
Found {{ total }} feed{{ 's' if total != 1 else '' }} in the OPML file.
|
||||
{% if new_count %}
|
||||
<span class="text-success">{{ new_count }} new</span>
|
||||
{% endif %}
|
||||
{% if existing_count %}
|
||||
<span class="text-warning">{{ existing_count }} already exist{{ 's' if existing_count == 1 else '' }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if feeds %}
|
||||
<form action="/import_opml_confirm" method="post">
|
||||
<!-- Webhook selector -->
|
||||
{% if webhooks %}
|
||||
<div class="mb-3">
|
||||
<label for="webhook_name" class="form-label text-muted small mb-1">Attach imported feeds to webhook</label>
|
||||
<select id="webhook_name"
|
||||
name="webhook_name"
|
||||
class="form-select bg-dark text-light border-secondary w-auto">
|
||||
<option value="">No webhook (attach later)</option>
|
||||
{% for hook in webhooks %}
|
||||
<option value="{{ hook.name }}">{{ hook.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-muted mt-1">
|
||||
New feeds will be added with this webhook. Existing feeds will have their webhook overwritten. You can change this later per-feed.
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mb-3" role="alert">
|
||||
No webhooks configured. Imported feeds will need a webhook attached before they can send messages.
|
||||
<a href="/add_webhook" class="alert-link">Add a webhook</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox"
|
||||
id="select_all"
|
||||
class="form-check-input"
|
||||
checked
|
||||
onchange="toggleCheckboxes(this)" />
|
||||
</th>
|
||||
<th>Feed</th>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for feed in feeds %}
|
||||
<tr class="{{ 'text-muted' if feed.already_exists else '' }}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
name="feed_urls"
|
||||
value="{{ feed.url }}"
|
||||
class="form-check-input feed-checkbox"
|
||||
checked />
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ feed.url }}"
|
||||
class="text-light text-decoration-none"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{{ feed.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ feed.url }}"
|
||||
class="text-muted small text-decoration-none"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"><code>{{ feed.url }}</code></a>
|
||||
</td>
|
||||
<td>
|
||||
{% if feed.already_exists %}
|
||||
<span class="badge bg-secondary">Already exists</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">New</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary px-4">Import Selected</button>
|
||||
<a href="/settings" class="btn btn-outline-secondary px-4">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted">No feeds found in the OPML file.</p>
|
||||
<a href="/settings" class="btn btn-primary px-4">Back to Settings</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
{% block head %}
|
||||
<script>
|
||||
function toggleCheckboxes(selectAll) {
|
||||
document.querySelectorAll('.feed-checkbox').forEach(function(checkbox) {
|
||||
checkbox.checked = selectAll.checked;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock head %}
|
||||
|
|
@ -6,203 +6,262 @@
|
|||
Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot.
|
||||
{% endblock description %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<div class="text-light">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h2 class="mb-0">Global Settings</h2>
|
||||
</div>
|
||||
<p class="text-muted mt-2 mb-4">
|
||||
Set a default interval for all feeds. Individual feeds can still override this value.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
Current default is {{ global_interval }} min.
|
||||
Even though we check ETags and Last-Modified headers, choosing a very low interval may cause issues with some feeds or cause excessive load on the server hosting the feed. Remember to be kind.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/set_global_update_interval" method="post" class="mb-2">
|
||||
<div class="settings-form-row mb-2">
|
||||
<label for="interval_minutes" class="form-label mb-1">Default interval (minutes)</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input id="interval_minutes"
|
||||
type="number"
|
||||
class="form-control settings-input"
|
||||
name="interval_minutes"
|
||||
placeholder="Minutes"
|
||||
min="1"
|
||||
value="{{ global_interval }}"
|
||||
required />
|
||||
<button class="btn btn-primary px-4" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form action="/set_global_delivery_mode" method="post" class="mt-4">
|
||||
<div class="settings-form-row mb-2">
|
||||
<label for="delivery_mode" class="form-label mb-1">Default delivery mode for new feeds</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<select id="delivery_mode"
|
||||
class="form-select settings-input"
|
||||
name="delivery_mode">
|
||||
<option value="embed"
|
||||
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
|
||||
<option value="screenshot"
|
||||
{% if global_delivery_mode == "screenshot" %}selected{% endif %}
|
||||
{% if not chromium_installed %}disabled{% endif %}>
|
||||
Screenshot{% if not chromium_installed %} (Chromium not installed){% endif %}
|
||||
</option>
|
||||
<option value="text"
|
||||
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
|
||||
</select>
|
||||
<button class="btn btn-primary px-4" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-2">
|
||||
New feeds inherit this value. Existing feeds keep their current delivery mode.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form action="/set_global_screenshot_layout" method="post" class="mt-4">
|
||||
<div class="settings-form-row mb-2">
|
||||
<label for="screenshot_layout" class="form-label mb-1">Default screenshot layout for new feeds</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<select id="screenshot_layout"
|
||||
class="form-select settings-input"
|
||||
name="screenshot_layout">
|
||||
<option value="desktop"
|
||||
{% if global_screenshot_layout == "desktop" %}selected{% endif %}>Desktop</option>
|
||||
<option value="mobile"
|
||||
{% if global_screenshot_layout == "mobile" %}selected{% endif %}>Mobile</option>
|
||||
</select>
|
||||
<button class="btn btn-primary px-4" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-2">
|
||||
New feeds inherit this value. Existing feeds keep their current screenshot layout.
|
||||
</div>
|
||||
{% if not chromium_installed %}
|
||||
<div class="form-text text-muted mt-1">
|
||||
Screenshot mode requires Chromium to be installed for Playwright.
|
||||
Run <code>uv run playwright install chromium</code> once on this machine.
|
||||
<div class="row g-3 settings-page">
|
||||
<!-- Global Defaults -->
|
||||
<div class="col-12">
|
||||
<article class="card border border-dark shadow-sm text-light rounded-0">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">Global Defaults</h2>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
<form action="/set_global_webhook_text_length_limit"
|
||||
method="post"
|
||||
class="mt-4">
|
||||
<div class="settings-form-row mb-2">
|
||||
<label for="global_text_length_limit" class="form-label mb-1">Default text limit for new feeds</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input id="global_text_length_limit"
|
||||
type="number"
|
||||
class="form-control settings-input"
|
||||
name="text_length_limit"
|
||||
min="1"
|
||||
max="{{ max_webhook_text_length_limit }}"
|
||||
value="{{ global_webhook_text_length_limit }}"
|
||||
required />
|
||||
<button class="btn btn-primary px-4" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-2">
|
||||
New feeds inherit this value. Existing feeds keep their current
|
||||
per-feed text limit. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="mt-5">
|
||||
<div class="text-light">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h2 class="mb-0">Export Database</h2>
|
||||
</div>
|
||||
<p class="text-muted mt-2 mb-4">
|
||||
Download a copy of the entire database for backup or migration.
|
||||
The file is a compressed SQLite dump containing all feeds, entries, and settings.
|
||||
</p>
|
||||
<a href="/export" class="btn btn-primary px-4" role="button">Download Export</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-5">
|
||||
<div class="text-light">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h2 class="mb-0">Feed Update Intervals</h2>
|
||||
</div>
|
||||
<p class="text-muted mt-2 mb-4">
|
||||
Customize the update interval for individual feeds. Leave empty or reset to use the global default.
|
||||
</p>
|
||||
</div>
|
||||
{% if feed_intervals %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feed</th>
|
||||
<th>Domain</th>
|
||||
<th>Status</th>
|
||||
<th>Interval</th>
|
||||
<th>Last Updated</th>
|
||||
<th>Next Update</th>
|
||||
<th>Set Interval (min)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in feed_intervals %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/feed?feed_url={{ item.feed.url|encode_url }}"
|
||||
class="text-light text-decoration-none">{{ item.feed.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">{{ item.domain }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {{ 'bg-success' if item.feed.updates_enabled else 'bg-danger' }}">
|
||||
{{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ item.effective_interval }} min</span>
|
||||
{% if item.interval %}
|
||||
<span class="badge bg-info ms-1">Custom</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">Global</span>
|
||||
<p class="text-muted small mt-2 mb-4">
|
||||
These defaults apply to newly added feeds. Existing feeds keep their own per-feed settings.
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
<!-- Update Interval -->
|
||||
<div class="col-md-6">
|
||||
<form action="/set_global_update_interval" method="post">
|
||||
<label for="interval_minutes" class="form-label text-muted small mb-1">Update interval (minutes)</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="interval_minutes"
|
||||
type="number"
|
||||
class="form-control bg-dark text-light border-secondary"
|
||||
name="interval_minutes"
|
||||
placeholder="Minutes"
|
||||
min="1"
|
||||
value="{{ global_interval }}"
|
||||
required />
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-1">
|
||||
Currently {{ global_interval }} min. Low intervals may cause issues with some feeds.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Delivery Mode -->
|
||||
<div class="col-md-6">
|
||||
<form action="/set_global_delivery_mode" method="post">
|
||||
<label for="delivery_mode" class="form-label text-muted small mb-1">Delivery mode</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<select id="delivery_mode"
|
||||
class="form-select bg-dark text-light border-secondary"
|
||||
name="delivery_mode">
|
||||
<option value="embed"
|
||||
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
|
||||
<option value="screenshot"
|
||||
{% if global_delivery_mode == "screenshot" %}selected{% endif %}
|
||||
{% if not chromium_installed %}disabled{% endif %}>
|
||||
Screenshot{% if not chromium_installed %} (Chromium not installed){% endif %}
|
||||
</option>
|
||||
<option value="text"
|
||||
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-1">How new feeds send messages to Discord.</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Screenshot Layout -->
|
||||
<div class="col-md-6">
|
||||
<form action="/set_global_screenshot_layout" method="post">
|
||||
<label for="screenshot_layout" class="form-label text-muted small mb-1">Screenshot layout</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<select id="screenshot_layout"
|
||||
class="form-select bg-dark text-light border-secondary"
|
||||
name="screenshot_layout">
|
||||
<option value="desktop"
|
||||
{% if global_screenshot_layout == "desktop" %}selected{% endif %}>Desktop</option>
|
||||
<option value="mobile"
|
||||
{% if global_screenshot_layout == "mobile" %}selected{% endif %}>Mobile</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-1">
|
||||
Default screenshot viewport for new feeds.
|
||||
{% if not chromium_installed %}
|
||||
<span class="d-block mt-1">
|
||||
Requires Chromium. Run <code>uv run playwright install chromium</code>.
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ item.feed.last_updated | relative_time }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ item.feed.update_after | relative_time }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<form action="/set_update_interval" method="post" class="d-flex gap-2">
|
||||
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
|
||||
<input type="hidden" name="redirect_to" value="/settings" />
|
||||
<input type="number"
|
||||
class="form-control form-control-sm interval-input"
|
||||
name="interval_minutes"
|
||||
placeholder="Minutes"
|
||||
min="1"
|
||||
value="{{ item.interval if item.interval else global_interval }}" />
|
||||
<button class="btn btn-primary btn-sm" type="submit">Set</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Text Length Limit -->
|
||||
<div class="col-md-6">
|
||||
<form action="/set_global_webhook_text_length_limit" method="post">
|
||||
<label for="global_text_length_limit"
|
||||
class="form-label text-muted small mb-1">Text length limit</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="global_text_length_limit"
|
||||
type="number"
|
||||
class="form-control bg-dark text-light border-secondary"
|
||||
name="text_length_limit"
|
||||
min="1"
|
||||
max="{{ max_webhook_text_length_limit }}"
|
||||
value="{{ global_webhook_text_length_limit }}"
|
||||
required />
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-1">Max characters for text mode (4000). Embeds are capped at 2000.</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<!-- Data Management -->
|
||||
<div class="col-12">
|
||||
<article class="card border border-dark shadow-sm text-light rounded-0">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">Data Management</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2 mb-4">Import and export your feeds and database.</p>
|
||||
<div class="row g-3">
|
||||
<!-- OPML Export -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border border-secondary shadow-sm h-100 rounded-0">
|
||||
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">OPML Export</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Download all feeds as an OPML subscription list.
|
||||
Supports title, links, and description.
|
||||
</p>
|
||||
<a href="/export_opml" class="btn btn-primary btn-sm" role="button">Export OPML</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OPML Import -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border border-secondary shadow-sm h-100 rounded-0">
|
||||
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">OPML Import</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Upload an OPML file to preview and select which feeds to import.</p>
|
||||
<form action="/import_opml" method="post" enctype="multipart/form-data">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="file"
|
||||
id="opml_file"
|
||||
name="file"
|
||||
accept=".opml"
|
||||
class="form-control bg-dark text-light border-secondary"
|
||||
required />
|
||||
<button class="btn btn-primary" type="submit">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.interval %}
|
||||
<form action="/reset_update_interval" method="post" class="d-inline">
|
||||
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
|
||||
<input type="hidden" name="redirect_to" value="/settings" />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Reset</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No feeds added yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Database Export -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border border-secondary shadow-sm h-100 rounded-0">
|
||||
<div class="card-header bg-transparent border-secondary text-muted text-uppercase small fw-semibold">
|
||||
Database Export
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Download a compressed SQLite dump for backup or migration.</p>
|
||||
<a href="/export" class="btn btn-primary btn-sm" role="button">Download Export</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<!-- Feed Update Intervals -->
|
||||
<div class="col-12">
|
||||
<article class="card border border-dark shadow-sm text-light rounded-0">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">Feed Update Intervals</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2 mb-4">
|
||||
Customize the update interval for individual feeds. Leave empty or reset to use the global default of {{ global_interval }} min.
|
||||
</p>
|
||||
{% if feed_intervals %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feed</th>
|
||||
<th>Domain</th>
|
||||
<th>Status</th>
|
||||
<th>Interval</th>
|
||||
<th>Last Updated</th>
|
||||
<th>
|
||||
Next
|
||||
Update
|
||||
</th>
|
||||
<th>Set Interval (min)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in feed_intervals %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/feed?feed_url={{ item.feed.url|encode_url }}"
|
||||
class="text-light text-decoration-none">{{ item.feed.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">{{ item.domain }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {{ 'bg-success' if item.feed.updates_enabled else 'bg-danger' }}">
|
||||
{{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ item.effective_interval }} min</span>
|
||||
{% if item.interval %}
|
||||
<span class="badge bg-info ms-1">Custom</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">Global</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ item.feed.last_updated | relative_time }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ item.feed.update_after | relative_time }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<form action="/set_update_interval" method="post" class="d-flex gap-1">
|
||||
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
|
||||
<input type="hidden" name="redirect_to" value="/settings" />
|
||||
<input type="number"
|
||||
class="form-control form-control-sm bg-dark text-light border-secondary w-auto d-inline-block"
|
||||
name="interval_minutes"
|
||||
placeholder="Min"
|
||||
min="1"
|
||||
value="{{ item.interval if item.interval else global_interval }}" />
|
||||
<button class="btn btn-primary btn-sm" type="submit">Set</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.interval %}
|
||||
<form action="/reset_update_interval" method="post" class="d-inline">
|
||||
<input type="hidden" name="feed_url" value="{{ item.feed.url }}" />
|
||||
<input type="hidden" name="redirect_to" value="/settings" />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Reset</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0">No feeds added yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue