diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90e4cce --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# You can optionally store backups of your bot's configuration in a git repository. +# This allows you to track changes by subscribing to the repository or using a RSS feed. +# Local path for the backup git repository (e.g., /data/backup or /home/user/backups/discord-rss-bot) +# When set, the bot will initialize a git repo here and commit state.json after every configuration change +# GIT_BACKUP_PATH= + +# Remote URL for pushing backup commits (e.g., git@github.com:username/private-config.git) +# Optional - only set if you want automatic pushes to a remote repository +# Leave empty to keep git history local only +# GIT_BACKUP_REMOTE= + +# Sentry Configuration (Optional) +# Sentry DSN for error tracking and monitoring +# Leave empty to disable Sentry integration +# SENTRY_DSN= + +# Testing Configuration +# Discord webhook URL used for testing (optional, only needed when running tests) +# TEST_WEBHOOK_URL= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ebdf7e..6d68331 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: # GitHub Container Registry - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 if: github.event_name != 'pull_request' with: registry: ghcr.io @@ -25,18 +25,18 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} # Download the latest commit from the master branch - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Set up QEMU - id: qemu - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: image: tonistiigi/binfmt:master platforms: linux/amd64,linux/arm64 cache-image: false # Set up Buildx so we can build multi-arch images - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 # Install the latest version of ruff - uses: astral-sh/ruff-action@v3 @@ -68,7 +68,7 @@ jobs: # Extract metadata (tags, labels) from Git reference and GitHub events for Docker - id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index with: @@ -79,7 +79,7 @@ jobs: type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} # Build and push the Docker image - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/README.md b/README.md index 440058a..09b6bbc 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Subscribe to RSS feeds and get updates to a Discord webhook. +Email: [tlovinator@gmail.com](mailto:tlovinator@gmail.com) + +Discord: TheLovinator#9276 + ## Features - Subscribe to RSS feeds and get updates to a Discord webhook. @@ -10,6 +14,7 @@ Subscribe to RSS feeds and get updates to a Discord webhook. - Choose between Discord embed or plain text. - Regex filters for RSS feeds. - Blacklist/whitelist words in the title/description/author/etc. +- Set different update frequencies for each feed or use a global default. - Gets extra information from APIs if available, currently for: - [https://feeds.c3kay.de/](https://feeds.c3kay.de/) - Genshin Impact News @@ -40,7 +45,7 @@ or [install directly on your computer](#install-directly-on-your-computer). ### Install directly on your computer - Install the latest of [uv](https://docs.astral.sh/uv/#installation): - - `powershell -ExecutionPolicy ByPass -c "irm | iex"` + - `powershell -ExecutionPolicy ByPass -c "irm | iex"` - Download the project from GitHub with Git or download the [ZIP](https://github.com/TheLovinator1/discord-rss-bot/archive/refs/heads/master.zip). - If you want to update the bot, you can run `git pull` in the project folder or download the ZIP again. @@ -58,8 +63,49 @@ or [install directly on your computer](#install-directly-on-your-computer). - Use [Windows Task Scheduler](https://en.wikipedia.org/wiki/Windows_Task_Scheduler). - Or add a shortcut to `%userprofile%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup`. -## Contact +## Git Backup (State Version Control) -Email: [tlovinator@gmail.com](mailto:tlovinator@gmail.com) +The bot can commit every configuration change (adding/removing feeds, webhook +changes, blacklist/whitelist updates) to a separate private Git repository so +you get a full, auditable history of state changes — similar to `etckeeper`. -Discord: TheLovinator#9276 +### Configuration + +Set the following environment variables (e.g. in `docker-compose.yml` or a +`.env` file): + +| Variable | Required | Description | +| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `GIT_BACKUP_PATH` | Yes | Local path where the backup git repository is stored. The bot will initialise it automatically if it does not yet exist. | +| `GIT_BACKUP_REMOTE` | No | Remote URL to push to after each commit (e.g. `git@github.com:you/private-config.git`). Leave unset to keep the history local only. | + +### What is backed up + +After every relevant change a `state.json` file is written and committed. +The file contains: + +- All feed URLs together with their webhook URL, custom message, embed + settings, and any blacklist/whitelist filters. +- The global list of Discord webhooks. + +### Docker example + +```yaml +services: + discord-rss-bot: + image: ghcr.io/thelovinator1/discord-rss-bot:latest + volumes: + - ./data:/data + environment: + - GIT_BACKUP_PATH=/data/backup + - GIT_BACKUP_REMOTE=git@github.com:you/private-config.git +``` + +For SSH-based remotes mount your SSH key into the container and make sure the +host key is trusted, e.g.: + +```yaml + volumes: + - ./data:/data + - ~/.ssh:/root/.ssh:ro +``` diff --git a/discord_rss_bot/custom_filters.py b/discord_rss_bot/custom_filters.py index 99fe77d..7d8fe83 100644 --- a/discord_rss_bot/custom_filters.py +++ b/discord_rss_bot/custom_filters.py @@ -4,12 +4,15 @@ import urllib.parse from functools import lru_cache from typing import TYPE_CHECKING -from discord_rss_bot.filter.blacklist import entry_should_be_skipped, feed_has_blacklist_tags -from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent +from discord_rss_bot.filter.blacklist import entry_should_be_skipped +from discord_rss_bot.filter.blacklist import feed_has_blacklist_tags +from discord_rss_bot.filter.whitelist import has_white_tags +from discord_rss_bot.filter.whitelist import should_be_sent from discord_rss_bot.settings import get_reader if TYPE_CHECKING: - from reader import Entry, Reader + from reader import Entry + from reader import Reader # Our reader reader: Reader = get_reader() diff --git a/discord_rss_bot/custom_message.py b/discord_rss_bot/custom_message.py index 99a7e11..058b275 100644 --- a/discord_rss_bot/custom_message.py +++ b/discord_rss_bot/custom_message.py @@ -1,12 +1,17 @@ from __future__ import annotations +import html import json import logging from dataclasses import dataclass -from bs4 import BeautifulSoup, Tag +from bs4 import BeautifulSoup +from bs4 import Tag from markdownify import markdownify -from reader import Entry, Feed, Reader, TagNotFoundError +from reader import Entry +from reader import Feed +from reader import Reader +from reader import TagNotFoundError from discord_rss_bot.is_url_valid import is_url_valid from discord_rss_bot.settings import get_reader @@ -68,6 +73,10 @@ def replace_tags_in_text_message(entry: Entry) -> str: first_image: str = get_first_image(summary, content) + # Unescape HTML entities (e.g., <h1> becomes

) before converting to markdown + summary = html.unescape(summary) + content = html.unescape(content) + summary = markdownify( html=summary, strip=["img", "table", "td", "tr", "tbody", "thead"], @@ -199,6 +208,10 @@ def replace_tags_in_embed(feed: Feed, entry: Entry) -> CustomEmbed: first_image: str = get_first_image(summary, content) + # Unescape HTML entities (e.g., <h1> becomes

) before converting to markdown + summary = html.unescape(summary) + content = html.unescape(content) + summary = markdownify( html=summary, strip=["img", "table", "td", "tr", "tbody", "thead"], diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index ff38a6c..8e6ca63 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -5,42 +5,41 @@ import logging import os import pprint import re -from typing import TYPE_CHECKING, Any -from urllib.parse import ParseResult, urlparse +from typing import TYPE_CHECKING +from typing import Any +from urllib.parse import ParseResult +from urllib.parse import urlparse import tldextract -from discord_webhook import DiscordEmbed, DiscordWebhook +from discord_webhook import DiscordEmbed +from discord_webhook import DiscordWebhook from fastapi import HTTPException from markdownify import markdownify -from reader import ( - Entry, - EntryNotFoundError, - Feed, - FeedExistsError, - FeedNotFoundError, - Reader, - ReaderError, - StorageError, - TagNotFoundError, -) +from reader import Entry +from reader import EntryNotFoundError +from reader import Feed +from reader import FeedExistsError +from reader import FeedNotFoundError +from reader import Reader +from reader import ReaderError +from reader import StorageError +from reader import TagNotFoundError -from discord_rss_bot.custom_message import ( - CustomEmbed, - get_custom_message, - replace_tags_in_embed, - replace_tags_in_text_message, -) +from discord_rss_bot.custom_message import CustomEmbed +from discord_rss_bot.custom_message import get_custom_message +from discord_rss_bot.custom_message import replace_tags_in_embed +from discord_rss_bot.custom_message import replace_tags_in_text_message from discord_rss_bot.filter.blacklist import entry_should_be_skipped -from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent -from discord_rss_bot.hoyolab_api import ( - create_hoyolab_webhook, - extract_post_id_from_hoyolab_url, - fetch_hoyolab_post, - is_c3kay_feed, -) +from discord_rss_bot.filter.whitelist import has_white_tags +from discord_rss_bot.filter.whitelist import should_be_sent +from discord_rss_bot.hoyolab_api import create_hoyolab_webhook +from discord_rss_bot.hoyolab_api import extract_post_id_from_hoyolab_url +from discord_rss_bot.hoyolab_api import fetch_hoyolab_post +from discord_rss_bot.hoyolab_api import is_c3kay_feed from discord_rss_bot.is_url_valid import is_url_valid from discord_rss_bot.missing_tags import add_missing_tags -from discord_rss_bot.settings import default_custom_message, get_reader +from discord_rss_bot.settings import default_custom_message +from discord_rss_bot.settings import get_reader if TYPE_CHECKING: from collections.abc import Iterable @@ -99,7 +98,7 @@ def extract_domain(url: str) -> str: # noqa: PLR0911 return "Other" -def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None) -> str | None: # noqa: PLR0912 +def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None) -> str | None: # noqa: C901, PLR0912 """Send a single entry to Discord. Args: @@ -241,7 +240,7 @@ def set_title(custom_embed: CustomEmbed, discord_embed: DiscordEmbed) -> None: discord_embed.set_title(embed_title) if embed_title else None -def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook: +def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook: # noqa: C901 """Create a webhook with an embed. Args: @@ -342,7 +341,7 @@ def set_entry_as_read(reader: Reader, entry: Entry) -> None: logger.exception("Error setting entry to read: %s", entry.id) -def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None: # noqa: PLR0912 +def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None: # noqa: C901, PLR0912 """Send entries to Discord. If response was not ok, we will log the error and mark the entry as unread, so it will be sent again next time. @@ -521,7 +520,7 @@ def truncate_webhook_message(webhook_message: str) -> str: return webhook_message -def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: +def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # noqa: C901 """Add a new feed, update it and mark every entry as read. Args: diff --git a/discord_rss_bot/git_backup.py b/discord_rss_bot/git_backup.py new file mode 100644 index 0000000..4106d21 --- /dev/null +++ b/discord_rss_bot/git_backup.py @@ -0,0 +1,252 @@ +"""Git backup module for committing bot state changes to a private repository. + +Configure the backup by setting these environment variables: +- ``GIT_BACKUP_PATH``: Local filesystem path for the backup git repository. + When set, the bot will initialise a git repo there (if one doesn't exist) + and commit an export of its state after every relevant change. +- ``GIT_BACKUP_REMOTE``: Optional remote URL (e.g. ``git@github.com:you/private-repo.git``). + When set, every commit is followed by a ``git push`` to this remote. + +The exported state is written as ``state.json`` inside the backup repo. It +contains the list of feeds together with their webhook URL, filter settings +(blacklist / whitelist, regex variants), custom messages and embed settings. +Global webhooks are also included. + +Example docker-compose snippet:: + + environment: + - GIT_BACKUP_PATH=/data/backup + - GIT_BACKUP_REMOTE=git@github.com:you/private-config.git +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess # noqa: S404 +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +from reader import TagNotFoundError + +if TYPE_CHECKING: + from reader import Reader + +logger: logging.Logger = logging.getLogger(__name__) +GIT_EXECUTABLE: str = shutil.which("git") or "git" + + +type TAG_VALUE = ( + dict[str, str | int | float | bool | dict[str, Any] | list[Any] | None] + | list[str | int | float | bool | dict[str, Any] | list[Any] | None] + | None +) +"""Type alias for the value of a feed tag, which can be a nested structure of dicts and lists, or None.""" + +# Tags that are exported per-feed (empty values are omitted). +_FEED_TAGS: tuple[str, ...] = ( + "webhook", + "custom_message", + "should_send_embed", + "embed", + "blacklist_title", + "blacklist_summary", + "blacklist_content", + "blacklist_author", + "regex_blacklist_title", + "regex_blacklist_summary", + "regex_blacklist_content", + "regex_blacklist_author", + "whitelist_title", + "whitelist_summary", + "whitelist_content", + "whitelist_author", + "regex_whitelist_title", + "regex_whitelist_summary", + "regex_whitelist_content", + "regex_whitelist_author", + ".reader.update", +) + + +def get_backup_path() -> Path | None: + """Return the configured backup path, or *None* if not configured. + + Returns: + Path to the backup repository, or None if ``GIT_BACKUP_PATH`` is unset. + """ + raw: str = os.environ.get("GIT_BACKUP_PATH", "").strip() + return Path(raw) if raw else None + + +def get_backup_remote() -> str: + """Return the configured remote URL, or an empty string if not set. + + Returns: + The remote URL string from ``GIT_BACKUP_REMOTE``, or ``""`` if unset. + """ + return os.environ.get("GIT_BACKUP_REMOTE", "").strip() + + +def setup_backup_repo(backup_path: Path) -> bool: + """Ensure the backup directory exists and contains a git repository. + + If the directory does not yet contain a ``.git`` folder a new repository is + initialised. A basic git identity is configured locally so that commits + succeed even in environments where a global ``~/.gitconfig`` is absent. + + Args: + backup_path: Local path for the backup repository. + + Returns: + ``True`` if the repository is ready, ``False`` on any error. + """ + try: + backup_path.mkdir(parents=True, exist_ok=True) + git_dir: Path = backup_path / ".git" + if not git_dir.exists(): + subprocess.run([GIT_EXECUTABLE, "init", str(backup_path)], check=True, capture_output=True) # noqa: S603 + logger.info("Initialised git backup repository at %s", backup_path) + + # Ensure a local identity exists so that `git commit` always works. + for key, value in (("user.email", "discord-rss-bot@localhost"), ("user.name", "discord-rss-bot")): + result: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "config", "--local", key], + check=False, + capture_output=True, + ) + if result.returncode != 0: + subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "config", "--local", key, value], + check=True, + capture_output=True, + ) + + # Configure the remote if GIT_BACKUP_REMOTE is set. + remote_url: str = get_backup_remote() + if remote_url: + # Check if remote "origin" already exists. + check_remote: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "remote", "get-url", "origin"], + check=False, + capture_output=True, + ) + if check_remote.returncode != 0: + # Remote doesn't exist, add it. + subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "remote", "add", "origin", remote_url], + check=True, + capture_output=True, + ) + logger.info("Added remote 'origin' with URL: %s", remote_url) + else: + # Remote exists, update it if the URL has changed. + current_url: str = check_remote.stdout.decode().strip() + if current_url != remote_url: + subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "remote", "set-url", "origin", remote_url], + check=True, + capture_output=True, + ) + logger.info("Updated remote 'origin' URL from %s to %s", current_url, remote_url) + except Exception: + logger.exception("Failed to set up git backup repository at %s", backup_path) + return False + return True + + +def export_state(reader: Reader, backup_path: Path) -> None: + """Serialise the current bot state to ``state.json`` inside *backup_path*. + + Args: + reader: The :class:`reader.Reader` instance to read state from. + backup_path: Destination directory for the exported ``state.json``. + """ + feeds_state: list[dict] = [] + for feed in reader.get_feeds(): + feed_data: dict = {"url": feed.url} + for tag in _FEED_TAGS: + try: + value: TAG_VALUE = reader.get_tag(feed, tag, None) + if value is not None and value != "": # noqa: PLC1901 + feed_data[tag] = value + except Exception: + logger.exception("Failed to read tag '%s' for feed '%s' during state export", tag, feed.url) + feeds_state.append(feed_data) + + try: + webhooks: list[str | int | float | bool | dict[str, Any] | list[Any] | None] = list( + reader.get_tag((), "webhooks", []) + ) + except TagNotFoundError: + webhooks = [] + + # Export global update interval if set + global_update_interval: dict[str, Any] | None = None + try: + global_update_config = reader.get_tag((), ".reader.update", None) + if isinstance(global_update_config, dict): + global_update_interval = global_update_config + except TagNotFoundError: + pass + + state: dict = {"feeds": feeds_state, "webhooks": webhooks} + if global_update_interval is not None: + state["global_update_interval"] = global_update_interval + state_file: Path = backup_path / "state.json" + state_file.write_text(json.dumps(state, indent=2, default=str), encoding="utf-8") + + +def commit_state_change(reader: Reader, message: str) -> None: + """Export current state and commit it to the backup repository. + + This is a no-op when ``GIT_BACKUP_PATH`` is not configured. Errors are + logged but never raised so that a backup failure never interrupts normal + bot operation. + + Args: + reader: The :class:`reader.Reader` instance to read state from. + message: Commit message describing the change (e.g. ``"Add feed example.com/rss.xml"``). + """ + backup_path: Path | None = get_backup_path() + if backup_path is None: + return + + if not setup_backup_repo(backup_path): + return + + try: + export_state(reader, backup_path) + + subprocess.run([GIT_EXECUTABLE, "-C", str(backup_path), "add", "-A"], check=True, capture_output=True) # noqa: S603 + + # Only create a commit if there are staged changes. + diff_result: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "diff", "--cached", "--exit-code"], + check=False, + capture_output=True, + ) + if diff_result.returncode == 0: + logger.debug("No state changes to commit for: %s", message) + return + + subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "commit", "-m", message], + check=True, + capture_output=True, + ) + logger.info("Committed state change to backup repo: %s", message) + + # Push to remote if configured. + if get_backup_remote(): + subprocess.run( # noqa: S603 + [GIT_EXECUTABLE, "-C", str(backup_path), "push", "origin", "HEAD"], + check=True, + capture_output=True, + ) + logger.info("Pushed state change to remote 'origin': %s", message) + except Exception: + logger.exception("Failed to commit state change '%s' to backup repo", message) diff --git a/discord_rss_bot/hoyolab_api.py b/discord_rss_bot/hoyolab_api.py index cb1ed71..227a413 100644 --- a/discord_rss_bot/hoyolab_api.py +++ b/discord_rss_bot/hoyolab_api.py @@ -4,10 +4,12 @@ import contextlib import json import logging import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING +from typing import Any import requests -from discord_webhook import DiscordEmbed, DiscordWebhook +from discord_webhook import DiscordEmbed +from discord_webhook import DiscordWebhook if TYPE_CHECKING: from reader import Entry diff --git a/discord_rss_bot/is_url_valid.py b/discord_rss_bot/is_url_valid.py index cca1491..c986b4a 100644 --- a/discord_rss_bot/is_url_valid.py +++ b/discord_rss_bot/is_url_valid.py @@ -1,6 +1,7 @@ from __future__ import annotations -from urllib.parse import ParseResult, urlparse +from urllib.parse import ParseResult +from urllib.parse import urlparse def is_url_valid(url: str) -> bool: diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index 7ef30da..2e7af0f 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -7,48 +7,62 @@ import typing import urllib.parse from contextlib import asynccontextmanager from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC +from datetime import datetime from functools import lru_cache -from typing import TYPE_CHECKING, Annotated, cast +from typing import TYPE_CHECKING +from typing import Annotated +from typing import Any +from typing import cast import httpx import sentry_sdk import uvicorn from apscheduler.schedulers.asyncio import AsyncIOScheduler -from fastapi import FastAPI, Form, HTTPException, Request +from fastapi import FastAPI +from fastapi import Form +from fastapi import HTTPException +from fastapi import Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from httpx import Response from markdownify import markdownify -from reader import Entry, EntryNotFoundError, Feed, FeedNotFoundError, Reader, TagNotFoundError +from reader import Entry +from reader import EntryNotFoundError +from reader import Feed +from reader import FeedNotFoundError +from reader import Reader +from reader import TagNotFoundError from starlette.responses import RedirectResponse from discord_rss_bot import settings -from discord_rss_bot.custom_filters import ( - entry_is_blacklisted, - entry_is_whitelisted, -) -from discord_rss_bot.custom_message import ( - CustomEmbed, - get_custom_message, - get_embed, - get_first_image, - replace_tags_in_text_message, - save_embed, -) -from discord_rss_bot.feeds import create_feed, extract_domain, send_entry_to_discord, send_to_discord +from discord_rss_bot.custom_filters import entry_is_blacklisted +from discord_rss_bot.custom_filters import entry_is_whitelisted +from discord_rss_bot.custom_message import CustomEmbed +from discord_rss_bot.custom_message import get_custom_message +from discord_rss_bot.custom_message import get_embed +from discord_rss_bot.custom_message import get_first_image +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 create_feed +from discord_rss_bot.feeds import extract_domain +from discord_rss_bot.feeds import send_entry_to_discord +from discord_rss_bot.feeds import send_to_discord +from discord_rss_bot.git_backup import commit_state_change +from discord_rss_bot.git_backup import get_backup_path from discord_rss_bot.missing_tags import add_missing_tags from discord_rss_bot.search import create_search_context from discord_rss_bot.settings import get_reader if TYPE_CHECKING: - from collections.abc import AsyncGenerator, Iterable + from collections.abc import AsyncGenerator + from collections.abc import Iterable from reader.types import JSONType -LOGGING_CONFIG = { +LOGGING_CONFIG: dict[str, Any] = { "version": 1, "disable_existing_loggers": True, "formatters": { @@ -86,6 +100,46 @@ logging.config.dictConfig(LOGGING_CONFIG) logger: logging.Logger = logging.getLogger(__name__) reader: Reader = get_reader() +# Time constants for relative time formatting +SECONDS_PER_MINUTE = 60 +SECONDS_PER_HOUR = 3600 +SECONDS_PER_DAY = 86400 + + +def relative_time(dt: datetime | None) -> str: + """Convert a datetime to a relative time string (e.g., '2 hours ago', 'in 5 minutes'). + + Args: + dt: The datetime to convert (should be timezone-aware). + + Returns: + A human-readable relative time string. + """ + if dt is None: + return "Never" + + now = datetime.now(tz=UTC) + diff = dt - now + seconds = int(abs(diff.total_seconds())) + is_future = diff.total_seconds() > 0 + + # Determine the appropriate unit and value + if seconds < SECONDS_PER_MINUTE: + value = seconds + unit = "s" + elif seconds < SECONDS_PER_HOUR: + value = seconds // SECONDS_PER_MINUTE + unit = "m" + elif seconds < SECONDS_PER_DAY: + value = seconds // SECONDS_PER_HOUR + unit = "h" + else: + value = seconds // SECONDS_PER_DAY + unit = "d" + + # Format based on future or past + return f"in {value}{unit}" if is_future else f"{value}{unit} ago" + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: @@ -117,6 +171,8 @@ templates.env.filters["encode_url"] = lambda url: urllib.parse.quote(url) if url templates.env.filters["entry_is_whitelisted"] = entry_is_whitelisted templates.env.filters["entry_is_blacklisted"] = entry_is_blacklisted templates.env.filters["discord_markdown"] = markdownify +templates.env.filters["relative_time"] = relative_time +templates.env.globals["get_backup_path"] = get_backup_path @app.post("/add_webhook") @@ -130,11 +186,11 @@ async def post_add_webhook( webhook_name: The name of the webhook. webhook_url: The url of the webhook. - Raises: - HTTPException: If the webhook already exists. - Returns: RedirectResponse: Redirect to the index page. + + Raises: + HTTPException: If the webhook already exists. """ # Get current webhooks from the database if they exist otherwise use an empty list. webhooks = list(reader.get_tag((), "webhooks", [])) @@ -151,6 +207,8 @@ async def post_add_webhook( reader.set_tag((), "webhooks", webhooks) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Add webhook {webhook_name.strip()}") + return RedirectResponse(url="/", status_code=303) # TODO(TheLovinator): Show this error on the page. @@ -165,11 +223,12 @@ async def post_delete_webhook(webhook_url: Annotated[str, Form()]) -> RedirectRe Args: webhook_url: The url of the webhook. + Returns: + RedirectResponse: Redirect to the index page. + Raises: HTTPException: If the webhook could not be deleted - Returns: - RedirectResponse: Redirect to the index page. """ # TODO(TheLovinator): Check if the webhook is in use by any feeds before deleting it. # TODO(TheLovinator): Replace HTTPException with a custom exception for both of these. @@ -196,6 +255,8 @@ async def post_delete_webhook(webhook_url: Annotated[str, Form()]) -> RedirectRe # Add our new list of webhooks to the database. reader.set_tag((), "webhooks", webhooks) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Delete webhook {webhook_url.strip()}") + return RedirectResponse(url="/", status_code=303) @@ -215,6 +276,7 @@ async def post_create_feed( """ clean_feed_url: str = feed_url.strip() create_feed(reader, feed_url, webhook_dropdown) + commit_state_change(reader, f"Add feed {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -286,6 +348,8 @@ async def post_set_whitelist( reader.set_tag(clean_feed_url, "regex_whitelist_content", regex_whitelist_content) # pyright: ignore[reportArgumentType][call-overload] reader.set_tag(clean_feed_url, "regex_whitelist_author", regex_whitelist_author) # pyright: ignore[reportArgumentType][call-overload] + commit_state_change(reader, f"Update whitelist for {clean_feed_url}") + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -367,6 +431,7 @@ async def post_set_blacklist( reader.set_tag(clean_feed_url, "regex_blacklist_summary", regex_blacklist_summary) # pyright: ignore[reportArgumentType][call-overload] reader.set_tag(clean_feed_url, "regex_blacklist_content", regex_blacklist_content) # pyright: ignore[reportArgumentType][call-overload] reader.set_tag(clean_feed_url, "regex_blacklist_author", regex_blacklist_author) # pyright: ignore[reportArgumentType][call-overload] + commit_state_change(reader, f"Update blacklist for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -433,6 +498,7 @@ async def post_set_custom( reader.set_tag(feed_url, "custom_message", default_custom_message) clean_feed_url: str = feed_url.strip() + commit_state_change(reader, f"Update custom message for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -552,6 +618,7 @@ async def post_embed( # Save the data. save_embed(reader, feed, custom_embed) + commit_state_change(reader, f"Update embed settings for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -567,6 +634,7 @@ async def post_use_embed(feed_url: Annotated[str, Form()]) -> RedirectResponse: """ clean_feed_url: str = feed_url.strip() reader.set_tag(clean_feed_url, "should_send_embed", True) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Enable embed mode for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -582,9 +650,106 @@ async def post_use_text(feed_url: Annotated[str, Form()]) -> RedirectResponse: """ clean_feed_url: str = feed_url.strip() reader.set_tag(clean_feed_url, "should_send_embed", False) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Disable embed mode for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) +@app.post("/set_update_interval") +async def post_set_update_interval( + feed_url: Annotated[str, Form()], + interval_minutes: Annotated[int | None, Form()] = None, + redirect_to: Annotated[str, Form()] = "", +) -> RedirectResponse: + """Set the update interval for a feed. + + Args: + feed_url: The feed to change. + interval_minutes: The update interval in minutes (None to reset to global default). + redirect_to: Optional redirect URL (defaults to feed page). + + Returns: + RedirectResponse: Redirect to the specified page or feed page. + """ + clean_feed_url: str = feed_url.strip() + + # If no interval specified, reset to global default + if interval_minutes is None: + try: + reader.delete_tag(clean_feed_url, ".reader.update") + commit_state_change(reader, f"Reset update interval to default for {clean_feed_url}") + except TagNotFoundError: + pass + else: + # Validate interval (minimum 1 minute, no maximum) + interval_minutes = max(interval_minutes, 1) + reader.set_tag(clean_feed_url, ".reader.update", {"interval": interval_minutes}) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Set update interval to {interval_minutes} minutes for {clean_feed_url}") + + # Update the feed immediately to recalculate update_after with the new interval + try: + reader.update_feed(clean_feed_url) + logger.info("Updated feed after interval change: %s", clean_feed_url) + except Exception: + logger.exception("Failed to update feed after interval change: %s", clean_feed_url) + + if redirect_to: + return RedirectResponse(url=redirect_to, status_code=303) + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) + + +@app.post("/reset_update_interval") +async def post_reset_update_interval( + feed_url: Annotated[str, Form()], + redirect_to: Annotated[str, Form()] = "", +) -> RedirectResponse: + """Reset the update interval for a feed to use the global default. + + Args: + feed_url: The feed to change. + redirect_to: Optional redirect URL (defaults to feed page). + + Returns: + RedirectResponse: Redirect to the specified page or feed page. + """ + clean_feed_url: str = feed_url.strip() + + try: + reader.delete_tag(clean_feed_url, ".reader.update") + commit_state_change(reader, f"Reset update interval to default for {clean_feed_url}") + except TagNotFoundError: + # Tag doesn't exist, which is fine + pass + + # Update the feed immediately to recalculate update_after with the new interval + try: + reader.update_feed(clean_feed_url) + logger.info("Updated feed after interval reset: %s", clean_feed_url) + except Exception: + logger.exception("Failed to update feed after interval reset: %s", clean_feed_url) + + if redirect_to: + return RedirectResponse(url=redirect_to, status_code=303) + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) + + +@app.post("/set_global_update_interval") +async def post_set_global_update_interval(interval_minutes: Annotated[int, Form()]) -> RedirectResponse: + """Set the global default update interval. + + Args: + interval_minutes: The update interval in minutes. + + Returns: + RedirectResponse: Redirect to the settings page. + """ + # Validate interval (minimum 1 minute, no maximum) + interval_minutes = max(interval_minutes, 1) + + reader.set_tag((), ".reader.update", {"interval": interval_minutes}) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Set global update interval to {interval_minutes} minutes") + return RedirectResponse(url="/settings", status_code=303) + + @app.get("/add", response_class=HTMLResponse) def get_add(request: Request): """Page for adding a new feed. @@ -603,7 +768,7 @@ def get_add(request: Request): @app.get("/feed", response_class=HTMLResponse) -async def get_feed(feed_url: str, request: Request, starting_after: str = ""): +async def get_feed(feed_url: str, request: Request, starting_after: str = ""): # noqa: C901, PLR0912, PLR0914, PLR0915 """Get a feed by URL. Args: @@ -611,11 +776,11 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): request: The request object. starting_after: The entry to start after. Used for pagination. - Raises: - HTTPException: If the feed is not found. - Returns: HTMLResponse: The feed page. + + Raises: + HTTPException: If the feed is not found. """ entries_per_page: int = 20 @@ -628,7 +793,7 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): # Only show button if more than 10 entries. total_entries: int = reader.get_entry_counts(feed=feed).total or 0 - show_more_entires_button: bool = total_entries > entries_per_page + is_show_more_entries_button_visible: bool = total_entries > entries_per_page # Get entries from the feed. if starting_after: @@ -641,6 +806,27 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): msg: str = f"{e}\n\n{[entry.id for entry in current_entries]}" html: str = create_html_for_feed(current_entries) + # Get feed and global intervals for error case too + feed_interval: int | None = None + try: + feed_update_config = reader.get_tag(feed, ".reader.update") + if isinstance(feed_update_config, dict) and "interval" in feed_update_config: + interval_value = feed_update_config["interval"] + if isinstance(interval_value, int): + feed_interval = interval_value + except TagNotFoundError: + pass + + global_interval: int = 60 + try: + global_update_config = reader.get_tag((), ".reader.update") + if isinstance(global_update_config, dict) and "interval" in global_update_config: + interval_value = global_update_config["interval"] + if isinstance(interval_value, int): + global_interval = interval_value + except TagNotFoundError: + pass + context = { "request": request, "feed": feed, @@ -650,8 +836,10 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): "should_send_embed": False, "last_entry": None, "messages": msg, - "show_more_entires_button": show_more_entires_button, + "is_show_more_entries_button_visible": is_show_more_entries_button_visible, "total_entries": total_entries, + "feed_interval": feed_interval, + "global_interval": global_interval, } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -680,6 +868,29 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): add_missing_tags(reader) should_send_embed: bool = bool(reader.get_tag(feed, "should_send_embed")) + # Get the update interval for this feed + feed_interval: int | None = None + try: + feed_update_config = reader.get_tag(feed, ".reader.update") + if isinstance(feed_update_config, dict) and "interval" in feed_update_config: + interval_value = feed_update_config["interval"] + if isinstance(interval_value, int): + feed_interval = interval_value + except TagNotFoundError: + # No custom interval set for this feed, will use global default + pass + + # Get the global default update interval + global_interval: int = 60 # Default to 60 minutes if not set + try: + global_update_config = reader.get_tag((), ".reader.update") + if isinstance(global_update_config, dict) and "interval" in global_update_config: + interval_value = global_update_config["interval"] + if isinstance(interval_value, int): + global_interval = interval_value + except TagNotFoundError: + pass + context = { "request": request, "feed": feed, @@ -688,8 +899,10 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): "html": html, "should_send_embed": should_send_embed, "last_entry": last_entry, - "show_more_entires_button": show_more_entires_button, + "is_show_more_entries_button_visible": is_show_more_entries_button_visible, "total_entries": total_entries, + "feed_interval": feed_interval, + "global_interval": global_interval, } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -819,6 +1032,56 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo: return our_hook +@app.get("/settings", response_class=HTMLResponse) +async def get_settings(request: Request): + """Settings page. + + Args: + request: The request object. + + Returns: + HTMLResponse: The settings page. + """ + # Get the global default update interval + global_interval: int = 60 # Default to 60 minutes if not set + try: + global_update_config = reader.get_tag((), ".reader.update") + if isinstance(global_update_config, dict) and "interval" in global_update_config: + interval_value = global_update_config["interval"] + if isinstance(interval_value, int): + global_interval = interval_value + except TagNotFoundError: + pass + + # Get all feeds with their intervals + feeds: Iterable[Feed] = reader.get_feeds() + feed_intervals = [] + for feed in feeds: + feed_interval: int | None = None + try: + feed_update_config = reader.get_tag(feed, ".reader.update") + if isinstance(feed_update_config, dict) and "interval" in feed_update_config: + interval_value = feed_update_config["interval"] + if isinstance(interval_value, int): + feed_interval = interval_value + except TagNotFoundError: + pass + + feed_intervals.append({ + "feed": feed, + "interval": feed_interval, + "effective_interval": feed_interval or global_interval, + "domain": extract_domain(feed.url), + }) + + context = { + "request": request, + "global_interval": global_interval, + "feed_intervals": feed_intervals, + } + return templates.TemplateResponse(request=request, name="settings.html", context=context) + + @app.get("/webhooks", response_class=HTMLResponse) async def get_webhooks(request: Request): """Page for adding a new webhook. @@ -845,23 +1108,25 @@ async def get_webhooks(request: Request): @app.get("/", response_class=HTMLResponse) -def get_index(request: Request): +def get_index(request: Request, message: str = ""): """This is the root of the website. Args: request: The request object. + message: Optional message to display to the user. Returns: HTMLResponse: The index page. """ - return templates.TemplateResponse(request=request, name="index.html", context=make_context_index(request)) + return templates.TemplateResponse(request=request, name="index.html", context=make_context_index(request, message)) -def make_context_index(request: Request): +def make_context_index(request: Request, message: str = ""): """Create the needed context for the index page. Args: request: The request object. + message: Optional message to display to the user. Returns: dict: The context for the index page. @@ -894,6 +1159,7 @@ def make_context_index(request: Request): "webhooks": hooks, "broken_feeds": broken_feeds, "feeds_without_attached_webhook": feeds_without_attached_webhook, + "messages": message or None, } @@ -904,17 +1170,20 @@ async def remove_feed(feed_url: Annotated[str, Form()]): Args: feed_url: The feed to add. - Raises: - HTTPException: Feed not found Returns: RedirectResponse: Redirect to the index page. + + Raises: + HTTPException: Feed not found """ try: reader.delete_feed(urllib.parse.unquote(feed_url)) except FeedNotFoundError as e: raise HTTPException(status_code=404, detail="Feed not found") from e + commit_state_change(reader, f"Remove feed {urllib.parse.unquote(feed_url)}") + return RedirectResponse(url="/", status_code=303) @@ -926,11 +1195,12 @@ async def update_feed(request: Request, feed_url: str): request: The request object. feed_url: The feed URL to update. - Raises: - HTTPException: If the feed is not found. Returns: RedirectResponse: Redirect to the feed page. + + Raises: + HTTPException: If the feed is not found. """ try: reader.update_feed(urllib.parse.unquote(feed_url)) @@ -941,6 +1211,33 @@ async def update_feed(request: Request, feed_url: str): return RedirectResponse(url="/feed?feed_url=" + urllib.parse.quote(feed_url), status_code=303) +@app.post("/backup") +async def manual_backup(request: Request) -> RedirectResponse: + """Manually trigger a git backup of the current state. + + Args: + request: The request object. + + Returns: + RedirectResponse: Redirect to the index page with a success or error message. + """ + backup_path = get_backup_path() + if backup_path is None: + message = "Git backup is not configured. Set GIT_BACKUP_PATH environment variable to enable backups." + logger.warning("Manual git backup attempted but GIT_BACKUP_PATH is not configured") + return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303) + + try: + commit_state_change(reader, "Manual backup triggered from web UI") + message = "Successfully created git backup!" + logger.info("Manual git backup completed successfully") + except Exception as e: + message = f"Failed to create git backup: {e}" + logger.exception("Manual git backup failed") + + return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303) + + @app.get("/search", response_class=HTMLResponse) async def search(request: Request, query: str): """Get entries matching a full-text search query. @@ -988,11 +1285,12 @@ def modify_webhook(old_hook: Annotated[str, Form()], new_hook: Annotated[str, Fo old_hook: The webhook to modify. new_hook: The new webhook. + Returns: + RedirectResponse: Redirect to the webhook page. + Raises: HTTPException: Webhook could not be modified. - Returns: - RedirectResponse: Redirect to the webhook page. """ # Get current webhooks from the database if they exist otherwise use an empty list. webhooks = list(reader.get_tag((), "webhooks", [])) @@ -1042,11 +1340,11 @@ def extract_youtube_video_id(url: str) -> str | None: # Handle standard YouTube URLs (youtube.com/watch?v=VIDEO_ID) if "youtube.com/watch" in url and "v=" in url: - return url.split("v=")[1].split("&")[0] + return url.split("v=")[1].split("&", maxsplit=1)[0] # Handle shortened YouTube URLs (youtu.be/VIDEO_ID) if "youtu.be/" in url: - return url.split("youtu.be/")[1].split("?")[0] + return url.split("youtu.be/")[1].split("?", maxsplit=1)[0] return None diff --git a/discord_rss_bot/missing_tags.py b/discord_rss_bot/missing_tags.py index 84f375e..589893e 100644 --- a/discord_rss_bot/missing_tags.py +++ b/discord_rss_bot/missing_tags.py @@ -1,8 +1,11 @@ from __future__ import annotations -from reader import Feed, Reader, TagNotFoundError +from reader import Feed +from reader import Reader +from reader import TagNotFoundError -from discord_rss_bot.settings import default_custom_embed, default_custom_message +from discord_rss_bot.settings import default_custom_embed +from discord_rss_bot.settings import default_custom_message def add_custom_message(reader: Reader, feed: Feed) -> None: diff --git a/discord_rss_bot/search.py b/discord_rss_bot/search.py index 4c9a2ae..a39f304 100644 --- a/discord_rss_bot/search.py +++ b/discord_rss_bot/search.py @@ -8,7 +8,10 @@ from discord_rss_bot.settings import get_reader if TYPE_CHECKING: from collections.abc import Iterable - from reader import EntrySearchResult, Feed, HighlightedString, Reader + from reader import EntrySearchResult + from reader import Feed + from reader import HighlightedString + from reader import Reader def create_search_context(query: str, custom_reader: Reader | None = None) -> dict: diff --git a/discord_rss_bot/settings.py b/discord_rss_bot/settings.py index d730b10..676a185 100644 --- a/discord_rss_bot/settings.py +++ b/discord_rss_bot/settings.py @@ -5,7 +5,9 @@ from functools import lru_cache from pathlib import Path from platformdirs import user_data_dir -from reader import Reader, make_reader +from reader import Reader +from reader import TagNotFoundError +from reader import make_reader if typing.TYPE_CHECKING: from reader.types import JSONType @@ -38,7 +40,12 @@ def get_reader(custom_location: Path | None = None) -> Reader: reader: Reader = make_reader(url=str(db_location)) # https://reader.readthedocs.io/en/latest/api.html#reader.types.UpdateConfig - # Set the update interval to 15 minutes - reader.set_tag((), ".reader.update", {"interval": 15}) + # Set the default update interval to 15 minutes if not already configured + # Users can change this via the Settings page or per-feed in the feed page + try: + reader.get_tag((), ".reader.update") + except TagNotFoundError: + # Set default + reader.set_tag((), ".reader.update", {"interval": 15}) return reader diff --git a/discord_rss_bot/static/styles.css b/discord_rss_bot/static/styles.css index db0cfba..5758237 100644 --- a/discord_rss_bot/static/styles.css +++ b/discord_rss_bot/static/styles.css @@ -13,3 +13,7 @@ body { .form-text { color: #acabab; } + +.interval-input { + max-width: 120px; +} \ No newline at end of file diff --git a/discord_rss_bot/templates/feed.html b/discord_rss_bot/templates/feed.html index 340a8a3..d58c714 100644 --- a/discord_rss_bot/templates/feed.html +++ b/discord_rss_bot/templates/feed.html @@ -1,90 +1,145 @@ {% extends "base.html" %} {% block title %} -| {{ feed.title }} + | {{ feed.title }} {% endblock title %} {% block content %} -
- -

- {{ feed.title }} ({{ total_entries }} entries) -

- {% if not feed.updates_enabled %} - Disabled - {% endif %} - - {% if feed.last_exception %} -
-
{{ feed.last_exception.type_name }}:
- {{ feed.last_exception.value_str }} - -
-
{{ feed.last_exception.traceback_str }}
+
+ +

+ {{ feed.title }} ({{ total_entries }} entries) +

+ {% if not feed.updates_enabled %}Disabled{% endif %} + {% if feed.last_exception %} +
+
{{ feed.last_exception.type_name }}:
+ {{ feed.last_exception.value_str }} + +
+
{{ feed.last_exception.traceback_str }}
+
+
+ {% endif %} + +
+ Update +
+ +
+ {% if not feed.updates_enabled %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% if not "youtube.com/feeds/videos.xml" in feed.url %} + {% if should_send_embed %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% endif %} +
+ + + +
+
Feed Information
+
+
+ Added: {{ feed.added | relative_time }} +
+
+ Last Updated: {{ feed.last_updated | relative_time }} +
+
+ Last Retrieved: {{ feed.last_retrieved | relative_time }} +
+
+ Next Update: {{ feed.update_after | relative_time }} +
+
+ Updates: {{ 'Enabled' if feed.updates_enabled else 'Disabled' }} +
+
+
+ +
+
Update Interval
+ {% if feed_interval %} +

+ Current: {{ feed_interval }} minutes + {% if feed_interval >= 60 %}({{ (feed_interval / 60) | round(1) }} hours){% endif %} + Custom +

+ {% else %} +

+ Current: {{ global_interval }} minutes + {% if global_interval >= 60 %}({{ (global_interval / 60) | round(1) }} hours){% endif %} + Using global default +

+ {% endif %} +
+ +
+ + +
+
+ {% if feed_interval %} +
+ + +
+ {% endif %}
+ {# Rendered HTML content #} +
{{ html|safe }}
+ {% if is_show_more_entries_button_visible %} + + Show more entries + {% endif %} - - -
- Update - -
- -
- - {% if not feed.updates_enabled %} -
- -
- {% else %} -
- -
- {% endif %} - - {% if not "youtube.com/feeds/videos.xml" in feed.url %} - {% if should_send_embed %} -
- -
- {% else %} -
- -
- {% endif %} - {% endif %} -
- - - -
- -{# Rendered HTML content #} -
{{ html|safe }}
- -{% if show_more_entires_button %} - - Show more entries - -{% endif %} - {% endblock content %} diff --git a/discord_rss_bot/templates/nav.html b/discord_rss_bot/templates/nav.html index 8b9ee37..7442554 100644 --- a/discord_rss_bot/templates/nav.html +++ b/discord_rss_bot/templates/nav.html @@ -1,6 +1,9 @@