diff --git a/assets/styles.css b/assets/styles.css index 86c16a0..6f3276b 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -765,36 +765,13 @@ } /* ========================================================================== - Badges (for feed types, validation status) + Badge extensions for search results ========================================================================== */ -.badge { - border-radius: var(--border-radius); - display: inline-block; +/* Extend Indiekit badges with small variant for inline use */ +.badge--small { font-size: var(--font-size-small); - font-weight: 500; 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); } -.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 { align-items: center; @@ -842,29 +811,128 @@ } /* ========================================================================== - Notices (errors, warnings) + Notices (inline errors, warnings) ========================================================================== */ .notice { - border-radius: var(--border-radius); + border-radius: var(--border-radius-small, var(--border-radius)); margin-bottom: var(--space-m); padding: var(--space-m); } .notice--error { - background: rgba(var(--color-error-rgb, 255, 68, 68), 0.1); - border: 1px solid var(--color-error, #ff4444); - color: var(--color-error, #ff4444); + background: var(--color-red90, #fef2f2); + border: 1px solid var(--color-error, var(--color-red45)); + color: var(--color-red10, #7f1d1d); } .notice--warning { - background: rgba(255, 204, 0, 0.1); - border: 1px solid var(--color-warning, #ffcc00); - color: #856404; + background: var(--color-yellow90, #fefce8); + border: 1px solid var(--color-yellow50, #eab308); + color: var(--color-yellow10, #713f12); } .notice--success { - background: rgba(34, 197, 94, 0.1); - border: 1px solid var(--color-success, #22c55e); - color: var(--color-success, #22c55e); + background: var(--color-green90, #f0fdf4); + border: 1px solid var(--color-success, var(--color-green50)); + 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); } diff --git a/index.js b/index.js index dfe4609..9637d7b 100644 --- a/index.js +++ b/index.js @@ -91,6 +91,26 @@ export default class MicrosubEndpoint { "/channels/:uid/feeds/remove", 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("/compose", readerController.compose); readerRouter.post("/compose", readerController.submitCompose); diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index 55d4650..9fbfcc6 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -3,8 +3,9 @@ * @module controllers/reader */ -import { discoverAndValidateFeeds } from "../feeds/discovery.js"; +import { discoverAndValidateFeeds, getBestFeed } from "../feeds/discovery.js"; import { validateFeedUrl } from "../feeds/validator.js"; +import { ObjectId } from "mongodb"; import { refreshFeedNow } from "../polling/scheduler.js"; import { getChannels, @@ -15,8 +16,11 @@ import { } from "../storage/channels.js"; import { getFeedsForChannel, + getFeedById, createFeed, deleteFeed, + updateFeed, + updateFeedStatus, } from "../storage/feeds.js"; import { getTimelineItems, @@ -701,6 +705,210 @@ export async function markAllRead(request, response) { 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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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 = { index, channels, @@ -714,6 +922,11 @@ export const readerController = { feeds, addFeed, removeFeed, + feedDetails, + editFeedForm, + updateFeedUrl, + rediscoverFeed, + refreshFeed, item, compose, submitCompose, diff --git a/lib/polling/processor.js b/lib/polling/processor.js index ade25de..32a480e 100644 --- a/lib/polling/processor.js +++ b/lib/polling/processor.js @@ -6,7 +6,11 @@ import { getRedisClient, publishEvent } from "../cache/redis.js"; import { fetchAndParseFeed } from "../feeds/fetcher.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 { addItem } from "../storage/items.js"; import { @@ -167,9 +171,21 @@ export async function processFeed(application, feed) { result.success = true; 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) { 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 try { const tierResult = calculateNewTier({ @@ -182,8 +198,6 @@ export async function processFeed(application, feed) { tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error unmodified: tierResult.consecutiveUnchanged, nextFetchAt: tierResult.nextFetchAt, - lastError: error.message, - lastErrorAt: new Date(), }); } catch { // Ignore update errors diff --git a/locales/en.json b/locales/en.json index 66de21d..678d624 100644 --- a/locales/en.json +++ b/locales/en.json @@ -34,7 +34,15 @@ "unfollow": "Unfollow", "empty": "No feeds followed in this channel", "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": { "reply": "Reply", diff --git a/package.json b/package.json index ed88cc3..520f202 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit", diff --git a/views/feed-edit.njk b/views/feed-edit.njk new file mode 100644 index 0000000..7511ddd --- /dev/null +++ b/views/feed-edit.njk @@ -0,0 +1,84 @@ +{% extends "layouts/reader.njk" %} + +{% block reader %} +
+ + {{ icon("previous") }} {{ __("microsub.feeds.title") }} + + +

{{ __("microsub.feeds.edit") }}

+ + {% if error %} +
+

{{ error }}

+
+ {% endif %} + +
+
+

Current Feed

+

{{ feed.url }}

+ {% if feed.title %} +

{{ feed.title }}

+ {% endif %} + {% if feed.status == 'error' %} +
+

Status: Error

+ {% if feed.lastError %} +

Last error: {{ feed.lastError }}

+ {% endif %} + {% if feed.consecutiveErrors %} +

Consecutive errors: {{ feed.consecutiveErrors }}

+ {% endif %} +
+ {% endif %} +
+ +
+ {{ input({ + id: "url", + name: "url", + label: "New Feed URL", + type: "url", + required: true, + value: feed.url, + placeholder: "https://example.com/feed.xml", + autocomplete: "off" + }) }} + +

+ Enter the direct URL to the RSS, Atom, or JSON Feed. The URL will be validated before updating. +

+ +
+ {{ button({ text: "Update Feed URL" }) }} + + Cancel + +
+
+ +
+ +
+

Other Actions

+ +
+

Run feed discovery on the current URL to find the actual RSS/Atom feed.

+ {{ button({ + text: "Rediscover Feed", + classes: "button--secondary" + }) }} +
+ +
+

Force refresh this feed now.

+ {{ button({ + text: "Refresh Now", + classes: "button--secondary" + }) }} +
+
+
+
+{% endblock %} diff --git a/views/feeds.njk b/views/feeds.njk index 0861a51..9657dc4 100644 --- a/views/feeds.njk +++ b/views/feeds.njk @@ -13,7 +13,7 @@ {% if feeds.length > 0 %}
{% for feed in feeds %} -
+
{% if feed.photo %} {% endif %}
- {{ feed.title or feed.url }} + + {{ feed.title or feed.url }} + {% if feed.status == 'error' %} + Error + {% elif feed.status == 'active' %} + Active + {% endif %} + {{ feed.url | replace("https://", "") | replace("http://", "") }} + {% if feed.lastError %} + {{ feed.lastError }} + {% endif %} + {% if feed.consecutiveErrors > 0 %} + {{ feed.consecutiveErrors }} consecutive errors + {% endif %} + {% if feed.lastSuccessAt %} + Last success: {{ feed.lastSuccessAt | date("relative") }} + {% endif %}
-
- - {{ button({ - text: __("microsub.feeds.unfollow"), - classes: "button--secondary button--small" - }) }} -
+
+ + {{ icon("edit") }} + +
+ +
+
+ +
+
+ + +
+
{% endfor %}
diff --git a/views/search.njk b/views/search.njk index 650b9e5..ddfeef5 100644 --- a/views/search.njk +++ b/views/search.njk @@ -46,11 +46,11 @@
{{ result.title or "Feed" }} - + {{ result.typeLabel }} {% if result.isCommentsFeed %} - Comments + Comments {% endif %} {{ result.url | replace("https://", "") | replace("http://", "") }} @@ -73,7 +73,7 @@ }) }} {% else %} - Invalid + Invalid {% endif %}
{% endfor %}