Add support for importing and exporting OMPL feeds

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

View file

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

View file

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