feat: Feed management with status tracking, edit, and rediscover

- Integrate updateFeedStatus into polling processor for health tracking
- Add feed management UI showing status (active/error), errors, actions
- Add edit feed URL feature to change non-RSS URLs to actual feeds
- Add rediscover feature to run feed discovery and update URL
- Add refresh button to force immediate poll
- Update UI to use Indiekit's badge/button classes (badge--green/red, button--warning)
- Add routes: /feeds/:feedId/edit, /feeds/:feedId/rediscover, /feeds/:feedId/refresh

Fixes broken feeds by allowing users to:
1. Edit URL directly to the RSS/Atom feed
2. Click "Rediscover" to auto-find the feed from a blog URL
3. View error details and consecutive error counts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-07 01:47:07 +01:00
parent ab6f81bf72
commit 1182b8ae79
9 changed files with 502 additions and 63 deletions

View File

@@ -765,36 +765,13 @@
} }
/* ========================================================================== /* ==========================================================================
Badges (for feed types, validation status) Badge extensions for search results
========================================================================== */ ========================================================================== */
.badge { /* Extend Indiekit badges with small variant for inline use */
border-radius: var(--border-radius); .badge--small {
display: inline-block;
font-size: var(--font-size-small); font-size: var(--font-size-small);
font-weight: 500;
padding: 2px var(--space-xs); padding: 2px var(--space-xs);
vertical-align: middle;
}
.badge--info {
background: var(--color-primary);
color: var(--color-background);
}
.badge--warning {
background: var(--color-warning, #ffcc00);
color: #000;
}
.badge--error {
background: var(--color-error, #ff4444);
color: #fff;
}
.badge--success {
background: var(--color-success, #22c55e);
color: #fff;
} }
/* ========================================================================== /* ==========================================================================
@@ -826,14 +803,6 @@
border-left: 3px solid var(--color-warning, #ffcc00); border-left: 3px solid var(--color-warning, #ffcc00);
} }
.search__invalid-badge {
background: var(--color-error, #ff4444);
border-radius: var(--border-radius);
color: #fff;
font-size: var(--font-size-small);
font-weight: 500;
padding: var(--space-xs) var(--space-s);
}
.search__subscribe { .search__subscribe {
align-items: center; align-items: center;
@@ -842,29 +811,128 @@
} }
/* ========================================================================== /* ==========================================================================
Notices (errors, warnings) Notices (inline errors, warnings)
========================================================================== */ ========================================================================== */
.notice { .notice {
border-radius: var(--border-radius); border-radius: var(--border-radius-small, var(--border-radius));
margin-bottom: var(--space-m); margin-bottom: var(--space-m);
padding: var(--space-m); padding: var(--space-m);
} }
.notice--error { .notice--error {
background: rgba(var(--color-error-rgb, 255, 68, 68), 0.1); background: var(--color-red90, #fef2f2);
border: 1px solid var(--color-error, #ff4444); border: 1px solid var(--color-error, var(--color-red45));
color: var(--color-error, #ff4444); color: var(--color-red10, #7f1d1d);
} }
.notice--warning { .notice--warning {
background: rgba(255, 204, 0, 0.1); background: var(--color-yellow90, #fefce8);
border: 1px solid var(--color-warning, #ffcc00); border: 1px solid var(--color-yellow50, #eab308);
color: #856404; color: var(--color-yellow10, #713f12);
} }
.notice--success { .notice--success {
background: rgba(34, 197, 94, 0.1); background: var(--color-green90, #f0fdf4);
border: 1px solid var(--color-success, #22c55e); border: 1px solid var(--color-success, var(--color-green50));
color: var(--color-success, #22c55e); color: var(--color-green10, #14532d);
}
/* ==========================================================================
Feed Management Enhancements
========================================================================== */
.feeds__item--error {
border-left: 3px solid var(--color-error, #ff4444);
}
.feeds__error {
color: var(--color-error, #ff4444);
display: block;
font-size: var(--font-size-small);
margin-top: var(--space-xs);
}
.feeds__error-count {
color: var(--color-warning, #ffcc00);
display: block;
font-size: var(--font-size-small);
}
.feeds__meta {
color: var(--color-text-muted);
display: block;
font-size: var(--font-size-small);
}
.feeds__details {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.feeds__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--space-xs);
}
.feeds__actions form {
display: inline;
margin: 0;
}
/* ==========================================================================
Feed Edit Page
========================================================================== */
.feed-edit {
max-width: 40rem;
}
.feed-edit__current {
background: var(--color-offset);
border-radius: var(--border-radius);
margin-bottom: var(--space-l);
padding: var(--space-m);
}
.feed-edit__url {
color: var(--color-text-muted);
font-size: var(--font-size-small);
overflow-wrap: break-word;
word-break: break-all;
}
.feed-edit__title {
font-weight: 600;
}
.feed-edit__form {
margin-bottom: var(--space-l);
}
.feed-edit__help {
color: var(--color-text-muted);
font-size: var(--font-size-small);
margin-bottom: var(--space-m);
}
.feed-edit__actions {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.feed-edit__action {
background: var(--color-offset);
border-radius: var(--border-radius);
padding: var(--space-m);
}
.feed-edit__action p {
margin-bottom: var(--space-s);
} }

View File

@@ -91,6 +91,26 @@ export default class MicrosubEndpoint {
"/channels/:uid/feeds/remove", "/channels/:uid/feeds/remove",
readerController.removeFeed, readerController.removeFeed,
); );
readerRouter.get(
"/channels/:uid/feeds/:feedId",
readerController.feedDetails,
);
readerRouter.get(
"/channels/:uid/feeds/:feedId/edit",
readerController.editFeedForm,
);
readerRouter.post(
"/channels/:uid/feeds/:feedId/edit",
readerController.updateFeedUrl,
);
readerRouter.post(
"/channels/:uid/feeds/:feedId/rediscover",
readerController.rediscoverFeed,
);
readerRouter.post(
"/channels/:uid/feeds/:feedId/refresh",
readerController.refreshFeed,
);
readerRouter.get("/item/:id", readerController.item); readerRouter.get("/item/:id", readerController.item);
readerRouter.get("/compose", readerController.compose); readerRouter.get("/compose", readerController.compose);
readerRouter.post("/compose", readerController.submitCompose); readerRouter.post("/compose", readerController.submitCompose);

View File

@@ -3,8 +3,9 @@
* @module controllers/reader * @module controllers/reader
*/ */
import { discoverAndValidateFeeds } from "../feeds/discovery.js"; import { discoverAndValidateFeeds, getBestFeed } from "../feeds/discovery.js";
import { validateFeedUrl } from "../feeds/validator.js"; import { validateFeedUrl } from "../feeds/validator.js";
import { ObjectId } from "mongodb";
import { refreshFeedNow } from "../polling/scheduler.js"; import { refreshFeedNow } from "../polling/scheduler.js";
import { import {
getChannels, getChannels,
@@ -15,8 +16,11 @@ import {
} from "../storage/channels.js"; } from "../storage/channels.js";
import { import {
getFeedsForChannel, getFeedsForChannel,
getFeedById,
createFeed, createFeed,
deleteFeed, deleteFeed,
updateFeed,
updateFeedStatus,
} from "../storage/feeds.js"; } from "../storage/feeds.js";
import { import {
getTimelineItems, getTimelineItems,
@@ -701,6 +705,210 @@ export async function markAllRead(request, response) {
response.redirect(`${request.baseUrl}/channels/${channelUid}`); response.redirect(`${request.baseUrl}/channels/${channelUid}`);
} }
/**
* View single feed details with status - redirects to edit form
* @param {object} request - Express request
* @param {object} response - Express response
* @returns {Promise<void>}
*/
export async function feedDetails(request, response) {
const { uid, feedId } = request.params;
// Redirect to edit form which shows all details
response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
}
/**
* Edit feed URL form
* @param {object} request - Express request
* @param {object} response - Express response
* @returns {Promise<void>}
*/
export async function editFeedForm(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { uid, feedId } = request.params;
const channelDocument = await getChannel(application, uid, userId);
if (!channelDocument) {
return response.status(404).render("404");
}
const feed = await getFeedById(application, feedId);
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
return response.status(404).render("404");
}
response.render("feed-edit", {
title: request.__("microsub.feeds.edit"),
channel: channelDocument,
feed,
baseUrl: request.baseUrl,
});
}
/**
* Update feed URL
* @param {object} request - Express request
* @param {object} response - Express response
* @returns {Promise<void>}
*/
export async function updateFeedUrl(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { uid, feedId } = request.params;
const { url: newUrl } = request.body;
const channelDocument = await getChannel(application, uid, userId);
if (!channelDocument) {
return response.status(404).render("404");
}
const feed = await getFeedById(application, feedId);
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
return response.status(404).render("404");
}
// Validate the new URL is a valid feed
const validation = await validateFeedUrl(newUrl);
if (!validation.valid) {
return response.render("feed-edit", {
title: request.__("microsub.feeds.edit"),
channel: channelDocument,
feed,
error: validation.error,
baseUrl: request.baseUrl,
});
}
// Update the feed URL and reset error state
await updateFeed(application, feedId, {
url: newUrl,
title: validation.title || feed.title,
status: "active",
lastError: undefined,
lastErrorAt: undefined,
consecutiveErrors: 0,
});
// Trigger immediate fetch
refreshFeedNow(application, feedId).catch((error) => {
console.error(
`[Microsub] Error refreshing updated feed ${newUrl}:`,
error.message,
);
});
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
}
/**
* Rediscover feed - run discovery on URL to find actual RSS feed
* @param {object} request - Express request
* @param {object} response - Express response
* @returns {Promise<void>}
*/
export async function rediscoverFeed(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { uid, feedId } = request.params;
const channelDocument = await getChannel(application, uid, userId);
if (!channelDocument) {
return response.status(404).render("404");
}
const feed = await getFeedById(application, feedId);
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
return response.status(404).render("404");
}
// Run feed discovery on the current URL
try {
const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
const bestFeed = getBestFeed(discoveredFeeds);
if (bestFeed && bestFeed.url !== feed.url) {
// Found a different (better) feed URL - update the record
await updateFeed(application, feedId, {
url: bestFeed.url,
title: bestFeed.title || feed.title,
status: "active",
lastError: undefined,
lastErrorAt: undefined,
consecutiveErrors: 0,
});
console.info(
`[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
);
// Trigger immediate fetch
refreshFeedNow(application, feedId).catch((error) => {
console.error(
`[Microsub] Error refreshing rediscovered feed:`,
error.message,
);
});
} else if (bestFeed) {
// Same URL but valid - just reset error state and refresh
await updateFeedStatus(application, feedId, { success: true });
await updateFeed(application, feedId, {
status: "active",
lastError: undefined,
lastErrorAt: undefined,
consecutiveErrors: 0,
});
refreshFeedNow(application, feedId).catch((error) => {
console.error(`[Microsub] Error refreshing feed:`, error.message);
});
} else {
// No valid feed found
await updateFeedStatus(application, feedId, {
success: false,
error: "No valid feed found at this URL",
});
}
} catch (error) {
await updateFeedStatus(application, feedId, {
success: false,
error: error.message,
});
}
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
}
/**
* Force refresh a feed
* @param {object} request - Express request
* @param {object} response - Express response
* @returns {Promise<void>}
*/
export async function refreshFeed(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { uid, feedId } = request.params;
const channelDocument = await getChannel(application, uid, userId);
if (!channelDocument) {
return response.status(404).render("404");
}
const feed = await getFeedById(application, feedId);
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
return response.status(404).render("404");
}
// Trigger immediate fetch
refreshFeedNow(application, feedId).catch((error) => {
console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
});
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
}
export const readerController = { export const readerController = {
index, index,
channels, channels,
@@ -714,6 +922,11 @@ export const readerController = {
feeds, feeds,
addFeed, addFeed,
removeFeed, removeFeed,
feedDetails,
editFeedForm,
updateFeedUrl,
rediscoverFeed,
refreshFeed,
item, item,
compose, compose,
submitCompose, submitCompose,

View File

@@ -6,7 +6,11 @@
import { getRedisClient, publishEvent } from "../cache/redis.js"; import { getRedisClient, publishEvent } from "../cache/redis.js";
import { fetchAndParseFeed } from "../feeds/fetcher.js"; import { fetchAndParseFeed } from "../feeds/fetcher.js";
import { getChannel } from "../storage/channels.js"; import { getChannel } from "../storage/channels.js";
import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js"; import {
updateFeedAfterFetch,
updateFeedStatus,
updateFeedWebsub,
} from "../storage/feeds.js";
import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js"; import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
import { addItem } from "../storage/items.js"; import { addItem } from "../storage/items.js";
import { import {
@@ -167,9 +171,21 @@ export async function processFeed(application, feed) {
result.success = true; result.success = true;
result.tier = tierResult.tier; result.tier = tierResult.tier;
// Update feed status to active on success
await updateFeedStatus(application, feed._id, {
success: true,
itemCount: parsed.items?.length || 0,
});
} catch (error) { } catch (error) {
result.error = error.message; result.error = error.message;
// Update feed status to error
await updateFeedStatus(application, feed._id, {
success: false,
error: error.message,
});
// Still update the feed to prevent retry storms // Still update the feed to prevent retry storms
try { try {
const tierResult = calculateNewTier({ const tierResult = calculateNewTier({
@@ -182,8 +198,6 @@ export async function processFeed(application, feed) {
tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error
unmodified: tierResult.consecutiveUnchanged, unmodified: tierResult.consecutiveUnchanged,
nextFetchAt: tierResult.nextFetchAt, nextFetchAt: tierResult.nextFetchAt,
lastError: error.message,
lastErrorAt: new Date(),
}); });
} catch { } catch {
// Ignore update errors // Ignore update errors

View File

@@ -34,7 +34,15 @@
"unfollow": "Unfollow", "unfollow": "Unfollow",
"empty": "No feeds followed in this channel", "empty": "No feeds followed in this channel",
"url": "Feed URL", "url": "Feed URL",
"urlPlaceholder": "https://example.com/feed.xml" "urlPlaceholder": "https://example.com/feed.xml",
"edit": "Edit feed",
"rediscover": "Rediscover feed",
"refresh": "Refresh now",
"status": {
"active": "Active",
"error": "Error",
"stale": "Stale"
}
}, },
"item": { "item": {
"reply": "Reply", "reply": "Reply",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-microsub", "name": "@rmdes/indiekit-endpoint-microsub",
"version": "1.0.23", "version": "1.0.24",
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
"keywords": [ "keywords": [
"indiekit", "indiekit",

84
views/feed-edit.njk Normal file
View File

@@ -0,0 +1,84 @@
{% extends "layouts/reader.njk" %}
{% block reader %}
<div class="settings">
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="back-link">
{{ icon("previous") }} {{ __("microsub.feeds.title") }}
</a>
<h2>{{ __("microsub.feeds.edit") }}</h2>
{% if error %}
<div class="notice notice--error">
<p>{{ error }}</p>
</div>
{% endif %}
<div class="feed-edit">
<div class="feed-edit__current">
<h3>Current Feed</h3>
<p class="feed-edit__url">{{ feed.url }}</p>
{% if feed.title %}
<p class="feed-edit__title">{{ feed.title }}</p>
{% endif %}
{% if feed.status == 'error' %}
<div class="notice notice--error">
<p><strong>Status:</strong> Error</p>
{% if feed.lastError %}
<p><strong>Last error:</strong> {{ feed.lastError }}</p>
{% endif %}
{% if feed.consecutiveErrors %}
<p><strong>Consecutive errors:</strong> {{ feed.consecutiveErrors }}</p>
{% endif %}
</div>
{% endif %}
</div>
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit" class="feed-edit__form">
{{ input({
id: "url",
name: "url",
label: "New Feed URL",
type: "url",
required: true,
value: feed.url,
placeholder: "https://example.com/feed.xml",
autocomplete: "off"
}) }}
<p class="feed-edit__help">
Enter the direct URL to the RSS, Atom, or JSON Feed. The URL will be validated before updating.
</p>
<div class="button-group">
{{ button({ text: "Update Feed URL" }) }}
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary">
Cancel
</a>
</div>
</form>
<div class="divider"></div>
<div class="feed-edit__actions">
<h3>Other Actions</h3>
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" class="feed-edit__action">
<p>Run feed discovery on the current URL to find the actual RSS/Atom feed.</p>
{{ button({
text: "Rediscover Feed",
classes: "button--secondary"
}) }}
</form>
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" class="feed-edit__action">
<p>Force refresh this feed now.</p>
{{ button({
text: "Refresh Now",
classes: "button--secondary"
}) }}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -13,7 +13,7 @@
{% if feeds.length > 0 %} {% if feeds.length > 0 %}
<div class="feeds__list"> <div class="feeds__list">
{% for feed in feeds %} {% for feed in feeds %}
<div class="feeds__item"> <div class="feeds__item{% if feed.status == 'error' %} feeds__item--error{% endif %}">
<div class="feeds__info"> <div class="feeds__info">
{% if feed.photo %} {% if feed.photo %}
<img src="{{ feed.photo }}" <img src="{{ feed.photo }}"
@@ -25,19 +25,51 @@
onerror="this.style.display='none'"> onerror="this.style.display='none'">
{% endif %} {% endif %}
<div class="feeds__details"> <div class="feeds__details">
<span class="feeds__name">{{ feed.title or feed.url }}</span> <span class="feeds__name">
{{ feed.title or feed.url }}
{% if feed.status == 'error' %}
<span class="badge badge--red">Error</span>
{% elif feed.status == 'active' %}
<span class="badge badge--green">Active</span>
{% endif %}
</span>
<a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener"> <a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener">
{{ feed.url | replace("https://", "") | replace("http://", "") }} {{ feed.url | replace("https://", "") | replace("http://", "") }}
</a> </a>
{% if feed.lastError %}
<span class="feeds__error">{{ feed.lastError }}</span>
{% endif %}
{% if feed.consecutiveErrors > 0 %}
<span class="feeds__error-count">{{ feed.consecutiveErrors }} consecutive errors</span>
{% endif %}
{% if feed.lastSuccessAt %}
<span class="feeds__meta">Last success: {{ feed.lastSuccessAt | date("relative") }}</span>
{% endif %}
</div> </div>
</div> </div>
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" class="feeds__actions"> <div class="feeds__actions">
<input type="hidden" name="url" value="{{ feed.url }}"> <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit"
{{ button({ class="button button--secondary button--small"
text: __("microsub.feeds.unfollow"), title="Edit feed URL">
classes: "button--secondary button--small" {{ icon("edit") }}
}) }} </a>
</form> <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" style="display:inline;">
<button type="submit" class="button button--secondary button--small" title="Rediscover feed">
{{ icon("discover") }}
</button>
</form>
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" style="display:inline;">
<button type="submit" class="button button--secondary button--small" title="Refresh now">
{{ icon("refresh") }}
</button>
</form>
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" style="display:inline;">
<input type="hidden" name="url" value="{{ feed.url }}">
<button type="submit" class="button button--warning button--small" title="Unfollow">
{{ icon("delete") }}
</button>
</form>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -46,11 +46,11 @@
<div class="search__feed"> <div class="search__feed">
<span class="search__name"> <span class="search__name">
{{ result.title or "Feed" }} {{ result.title or "Feed" }}
<span class="search__type badge badge--{% if result.valid %}info{% else %}warning{% endif %}"> <span class="search__type badge badge--small{% if result.valid %} badge--green{% else %} badge--yellow{% endif %}">
{{ result.typeLabel }} {{ result.typeLabel }}
</span> </span>
{% if result.isCommentsFeed %} {% if result.isCommentsFeed %}
<span class="search__type badge badge--warning">Comments</span> <span class="search__type badge badge--small badge--yellow">Comments</span>
{% endif %} {% endif %}
</span> </span>
<span class="search__url">{{ result.url | replace("https://", "") | replace("http://", "") }}</span> <span class="search__url">{{ result.url | replace("https://", "") | replace("http://", "") }}</span>
@@ -73,7 +73,7 @@
}) }} }) }}
</form> </form>
{% else %} {% else %}
<span class="search__invalid-badge">Invalid</span> <span class="badge badge--small badge--red">Invalid</span>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}