Compare commits

..

No commits in common. "f9cac0974dfb67963aa03bd185539e04657ef26e" and "ac01862a1779f3d31e62c6ade9fb541c42b25282" have entirely different histories.

7 changed files with 82 additions and 271 deletions

View file

@ -56,15 +56,11 @@ class Command(BaseCommand):
if count == 0: if count == 0:
msg = f"No entries found for feed: {url}" msg = f"No entries found for feed: {url}"
self.stdout.write(self.style.WARNING(msg)) self.stdout.write(self.style.WARNING(msg))
else: else:
proceed: bool = True
if not force: if not force:
proceed: bool = self.confirm_and_list_entries( return self.confirm_and_list_entries(url, entries_qs, count)
url=url,
entries_qs=entries_qs,
count=count,
)
if proceed:
entries_qs.delete() entries_qs.delete()
msg = f"Deleted {count} entries for feed: {url}" msg = f"Deleted {count} entries for feed: {url}"
self.stdout.write(self.style.SUCCESS(msg)) self.stdout.write(self.style.SUCCESS(msg))
@ -77,23 +73,15 @@ class Command(BaseCommand):
else: else:
msg: str = "\tFeed is up to date, but no new entries were archived." msg: str = "\tFeed is up to date, but no new entries were archived."
self.stdout.write(self.style.WARNING(msg)) self.stdout.write(self.style.WARNING(msg))
return None
def confirm_and_list_entries( def confirm_and_list_entries(
self, self,
url: str, url: str,
entries_qs: QuerySet[Entry, Entry], entries_qs: QuerySet[Entry, Entry],
count: int, count: int,
) -> bool: ) -> None:
"""Confirm with the user before deleting entries and list some of them. """Confirm with the user before deleting entries and list some of them."""
Args:
url (str): The URL of the feed.
entries_qs (QuerySet[Entry, Entry]): The queryset of entries to be deleted.
count (int): The total number of entries to be deleted.
Returns:
True if user confirms, False otherwise.
"""
msg: str = f"The following {count} entries will be removed for feed: {url}" msg: str = f"The following {count} entries will be removed for feed: {url}"
self.stdout.write(self.style.WARNING(msg)) self.stdout.write(self.style.WARNING(msg))
@ -106,8 +94,7 @@ class Command(BaseCommand):
confirm: str = input("Are you sure you want to proceed? (yes/no): ") confirm: str = input("Are you sure you want to proceed? (yes/no): ")
if confirm.lower() != "yes": if confirm.lower() != "yes":
self.stdout.write(self.style.ERROR("Operation cancelled.")) self.stdout.write(self.style.ERROR("Operation cancelled."))
return False return
return True
def get_entry_title(entry: Entry) -> str | None: def get_entry_title(entry: Entry) -> str | None:

View file

@ -10,25 +10,9 @@ if TYPE_CHECKING:
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
# / path("", views.home, name="home"),
path( path("feeds/", views.feed_list, name="feed-list"),
"", path("feeds/<int:feed_id>/", views.feed_detail, name="feed-detail"),
views.home,
name="home",
),
# /feeds/
path(
"feeds/",
views.feed_list,
name="feeds",
),
# /feeds/<feed_id>/
path(
"feeds/<int:feed_id>/",
views.feed_detail,
name="details",
),
# /feeds/<feed_id>/entries/<entry_id>/
path( path(
"feeds/<int:feed_id>/entries/<int:entry_id>/", "feeds/<int:feed_id>/entries/<int:entry_id>/",
views.entry_detail, views.entry_detail,

View file

@ -4,7 +4,7 @@ import html
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.db.models.query import QuerySet from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.shortcuts import render from django.shortcuts import render
@ -14,7 +14,6 @@ from feeds.models import Feed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse
from pytest_django.asserts import QuerySet from pytest_django.asserts import QuerySet
@ -39,16 +38,35 @@ def feed_detail(request: HttpRequest, feed_id: int) -> HttpResponse:
HttpResponse: An HTML response containing the feed details and its entries. HttpResponse: An HTML response containing the feed details and its entries.
""" """
feed: Feed = get_object_or_404(Feed, id=feed_id) feed: Feed = get_object_or_404(Feed, id=feed_id)
entries: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed).order_by( entries: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed).order_by(
"-published_at", "-published_at",
"-fetched_at", "-fetched_at",
)[:50] )[:50]
context: dict[str, Feed | QuerySet[Entry, Entry]] = { html: list[str] = [
"feed": feed, "<!DOCTYPE html>",
"entries": entries, f"<html><head><title>FeedVault - {feed.url}</title></head><body>",
} "<h1>Feed Detail</h1>",
return render(request, "feeds/feed_detail.html", context) f"<p><b>URL:</b> {feed.url}</p>",
f"<p><b>Domain:</b> {feed.domain}</p>",
f"<p><b>Active:</b> {'yes' if feed.is_active else 'no'}</p>",
f"<p><b>Created:</b> {feed.created_at}</p>",
f"<p><b>Last fetched:</b> {feed.last_fetched_at}</p>",
"<h2>Entries (latest 50)</h2>",
"<ul>",
]
for entry in entries:
title: str | None = entry.data.get("title") if entry.data else None
summary: str | None = entry.data.get("summary") if entry.data else None
snippet: str = title or summary or "[no title]"
html.append(
f"<li><b>{entry.published_at or entry.fetched_at}:</b> "
f'<a href="/feeds/{feed.pk}/entries/{entry.pk}/">{snippet}</a> '
f"<small>(id: {entry.entry_id})</small></li>",
)
html.extend(("</ul>", '<p><a href="/">Back to list</a></p>', "</body></html>"))
return HttpResponse("\n".join(html))
def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse: def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse:
@ -65,19 +83,32 @@ def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpRespo
feed: Feed = get_object_or_404(Feed, id=feed_id) feed: Feed = get_object_or_404(Feed, id=feed_id)
entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed) entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed)
# Prepare entry data for display # Render images if present in entry.data
entry_data_html: str = ""
if entry.data: if entry.data:
formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False) formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False)
escaped_json: str | None = html.escape(formatted_json) escaped_json: str = html.escape(formatted_json)
note: str = '<div style="font-size:small;color:#666;margin-bottom:4px;">Note: HTML in the JSON is escaped for display and will not be rendered as HTML.</div>'
entry_data_html: str = f"{note}<pre>{escaped_json}</pre>"
else: else:
escaped_json: str | None = None entry_data_html: str = "<p>[No data]</p>"
context = { html_lines: list[str] = [
"feed": feed, "<!DOCTYPE html>",
"entry": entry, f"<html><head><title>FeedVault - Entry {entry.entry_id}</title></head><body>",
"escaped_json": escaped_json, "<h1>Entry Detail</h1>",
} f"<p><b>Feed:</b> <a href='/feeds/{feed.pk}/'>{feed.url}</a></p>",
return render(request, "feeds/entry_detail.html", context) f"<p><b>Entry ID:</b> {entry.entry_id}</p>",
f"<p><b>Published:</b> {entry.published_at}</p>",
f"<p><b>Fetched:</b> {entry.fetched_at}</p>",
f"<p><b>Content Hash:</b> {entry.content_hash}</p>",
f"<p><b>Error Message:</b> {entry.error_message or '[none]'} </p>",
"<h2>Entry Data</h2>",
entry_data_html,
f'<p><a href="/feeds/{feed.pk}/">Back to feed</a></p>',
"</body></html>",
]
return HttpResponse("\n".join(html_lines))
def home(request: HttpRequest) -> HttpResponse: def home(request: HttpRequest) -> HttpResponse:

View file

@ -3,141 +3,33 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
{% if description %}<meta name="description" content="{{ description }}" />{% endif %} <meta name="keywords" content="feeds, archive, FeedVault" />
{% if keywords %}<meta name="keywords" content="{{ keywords }}" />{% endif %} <title>
{% if author %}<meta name="author" content="{{ author }}" />{% endif %} {% block title %}
{% if canonical %}<link rel="canonical" href="{{ canonical }}" />{% endif %} FeedVault
<title>{{ title|default:"FeedVault" }}</title> {% endblock title %}
<style> </title>
html {
max-width: 88ch;
padding: calc(1vmin + 0.5rem);
margin-inline: auto;
font-size: clamp(1em, 0.909em + 0.45vmin, 1.25em);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
color-scheme: light dark;
}
h1 {
font-size: 2.5rem;
font-weight: 600;
margin: 0;
}
header {
text-align: center;
}
.search {
display: flex;
justify-content: center;
margin-top: 1rem;
margin-inline: auto;
}
.leftright {
display: flex;
justify-content: center;
}
.left {
margin-right: auto;
}
.right {
margin-left: auto;
}
textarea {
width: 100%;
height: 10rem;
resize: vertical;
}
.messages {
list-style-type: none;
}
.error {
color: red;
}
.success {
color: green;
}
.warning {
color: orange;
}
</style>
</head> </head>
<body> <body>
{% if messages %}
<ul class="messages" role="alert" aria-live="polite">
{% for message in messages %}
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}
role="alert">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<header> <header>
<h1> <h1>FeedVault</h1>
<a href="{% url 'home' %}" aria-label="FeedVault Home">FeedVault</a> <nav>
</h1> <ul>
</header> <li>
<div class="leftright"> <a href="{% url 'home' %}">Home</a>
<div class="left"> </li>
<small>Archive of <li>
<a href="https://en.wikipedia.org/wiki/Web_feed" <a href="{% url 'feed-list' %}">Feeds</a>
aria-label="Wikipedia article on web feeds">web feeds</a>. </li>
{{ stats }} </ul>
</small>
</div>
<div class="right">
<form role="search"
action="#"
method="get"
class="search"
aria-label="Search form">
<input type="search" name="q" placeholder="Search" aria-label="Search input" />
<button type="submit" aria-label="Search button">Search</button>
</form>
</div>
</div>
<nav aria-label="Main navigation">
<small>
<div class="leftright">
<div class="left">
<a href="{% url 'home' %}" aria-label="Home page">Home</a> |
<a href="{% url 'feeds' %}" aria-label="Feeds page">Feeds</a> |
<a href="#" aria-label="Contact page">Contact</a>
</div>
<div class="right">
<a href="https://github.com/TheLovinator1/FeedVault"
aria-label="GitHub page">GitHub</a> |
<a href="https://github.com/sponsors/TheLovinator1"
aria-label="Donate page">Donate</a>
</div>
</div>
</small>
</nav> </nav>
<hr /> </header>
<main> <main>
{% block content %}<!-- default content -->{% endblock %} {% block content %}
{% endblock content %}
</main> </main>
<hr />
<footer> <footer>
<small> <p>Web scraping is not a crime. - No rights reserved. - A birthday present for Plipp ❤️</p>
<div class="leftright">
<div class="left">Web scraping is not a crime.</div>
<div class="right">No rights reserved.</div>
</div>
<div class="leftright">
<div class="left">TheLovinator#9276 on Discord</div>
<div class="right">A birthday present for Plipp ❤️</div>
</div>
</small>
</footer> </footer>
</body> </body>
</html> </html>

View file

@ -1,34 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Entry Detail</h1>
<p>
<b>Feed:</b> <a href="{% url 'details' feed.pk %}">{{ feed.url }}</a>
</p>
<p>
<b>Entry ID:</b> {{ entry.entry_id }}
</p>
<p>
<b>Published:</b> {{ entry.published_at }}
</p>
<p>
<b>Fetched:</b> {{ entry.fetched_at }}
</p>
<p>
<b>Content Hash:</b> {{ entry.content_hash }}
</p>
<p>
<b>Error Message:</b> {{ entry.error_message|default:"[none]" }}
</p>
<h2>Entry Data</h2>
{% if escaped_json %}
<div style="font-size:small;color:#666;margin-bottom:4px;">
Note: HTML in the JSON is escaped for display and will not be rendered as HTML.
</div>
<pre>{{ escaped_json|safe }}</pre>
{% else %}
<p>[No data]</p>
{% endif %}
<p>
<a href="{% url 'details' feed.pk %}">Back to feed</a>
</p>
{% endblock content %}

View file

@ -1,49 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Feed Detail</h1>
<p>
<b>URL:</b> {{ feed.url }}
</p>
<p>
<b>Domain:</b> {{ feed.domain }}
</p>
<p>
<b>Active:</b> {{ feed.is_active|yesno:"yes,no" }}
</p>
<p>
<b>Created:</b> {{ feed.created_at }}
</p>
<p>
<b>Last fetched:</b> {{ feed.last_fetched_at }}
</p>
<h2>Entries (latest 50)</h2>
<table>
<thead>
<tr>
<th>Title/Description</th>
<th>Entry ID</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>
<a href="{% url 'entry-detail' feed.pk entry.pk %}">
{{ entry.data.title|default:entry.data.description|default:"[no title]" }}
</a>
<br />
<small>{{ entry.published_at|default:entry.fetched_at }}</small>
</td>
<td>
<small>
<a href="{{ entry.entry_id }}">{{ entry.entry_id|cut:'https://'|truncatechars:50 }}</a>
</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<a href="{% url 'feeds' %}">Back to list</a>
</p>
{% endblock content %}

View file

@ -4,7 +4,7 @@
<ul> <ul>
{% for feed in feeds %} {% for feed in feeds %}
<li> <li>
<a href="{% url 'details' feed.pk %}">{{ feed.url }}</a> <a href="{% url 'feed-detail' feed.pk %}">{{ feed.url }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>