feat: add feed type indicator and cross-channel duplicate detection

- Store feedType (rss/atom/jsonfeed/hfeed) on feed documents during polling
- Display feed type badge in reader feeds view
- Detect duplicate feeds across all channels using URL normalization
  (trailing slashes, http/https variants)
- Show clear error message when subscribing to a feed that already exists
- Handle duplicates in both reader UI and Microsub API (HTTP 409)
- Bump version to 1.0.44

Confab-Link: http://localhost:8080/sessions/f1d9ff88-e037-4d6e-b595-ed6d9e00898e
This commit is contained in:
Ricardo
2026-03-10 14:27:01 +01:00
parent a51b554068
commit 5037ff3d8f
7 changed files with 182 additions and 34 deletions
+13 -2
View File
@@ -67,13 +67,24 @@ export async function follow(request, response) {
throw new IndiekitError("Channel not found", { status: 404 }); throw new IndiekitError("Channel not found", { status: 404 });
} }
// Create feed subscription // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
const feed = await createFeed(application, { let feed;
try {
feed = await createFeed(application, {
channelId: channelDocument._id, channelId: channelDocument._id,
url, url,
title: undefined, // Will be populated on first fetch title: undefined, // Will be populated on first fetch
photo: undefined, photo: undefined,
}); });
} catch (error) {
if (error.code === "DUPLICATE_FEED") {
throw new IndiekitError(
`Feed already exists in channel "${error.channelName}"`,
{ status: 409 },
);
}
throw error;
}
// Trigger immediate fetch in background (don't await) // Trigger immediate fetch in background (don't await)
// This will also discover and subscribe to WebSub hubs // This will also discover and subscribe to WebSub hubs
+45 -2
View File
@@ -319,7 +319,8 @@ export async function addFeed(request, response) {
return response.status(404).render("404"); return response.status(404).render("404");
} }
// Create feed subscription try {
// Create feed subscription (throws DUPLICATE_FEED if already exists)
const feed = await createFeed(application, { const feed = await createFeed(application, {
channelId: channelDocument._id, channelId: channelDocument._id,
url, url,
@@ -333,6 +334,28 @@ export async function addFeed(request, response) {
}); });
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`); response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
} catch (error) {
if (error.code === "DUPLICATE_FEED") {
// Re-render feeds page with error message
const feedList = await getFeedsForChannel(application, channelDocument._id);
return response.render("feeds", {
title: request.__("microsub.feeds.title"),
channel: channelDocument,
feeds: feedList,
baseUrl: request.baseUrl,
readerBaseUrl: request.baseUrl,
activeView: "channels",
error: `This feed already exists in channel "${error.channelName}"`,
breadcrumbs: [
{ text: "Reader", href: request.baseUrl },
{ text: "Channels", href: `${request.baseUrl}/channels` },
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
{ text: "Feeds" },
],
});
}
throw error;
}
} }
/** /**
@@ -782,7 +805,8 @@ export async function subscribe(request, response) {
} }
} }
// Create feed subscription // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
try {
const feed = await createFeed(application, { const feed = await createFeed(application, {
channelId: channelDocument._id, channelId: channelDocument._id,
url, url,
@@ -796,6 +820,25 @@ export async function subscribe(request, response) {
}); });
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`); response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
} catch (error) {
if (error.code === "DUPLICATE_FEED") {
const channelList = await getChannels(application, userId);
return response.render("search", {
title: request.__("microsub.search.title"),
channels: channelList,
query: url,
validationError: `This feed already exists in channel "${error.channelName}"`,
baseUrl: request.baseUrl,
readerBaseUrl: request.baseUrl,
activeView: "channels",
breadcrumbs: [
{ text: "Reader", href: request.baseUrl },
{ text: "Search" },
],
});
}
throw error;
}
} }
/** /**
+3
View File
@@ -171,12 +171,14 @@ export async function fetchAndParseFeed(url, options = {}) {
// Fetch and parse the discovered feed // Fetch and parse the discovered feed
const feedResult = await fetchFeed(fallbackFeed.url, options); const feedResult = await fetchFeed(fallbackFeed.url, options);
if (!feedResult.notModified) { if (!feedResult.notModified) {
const fallbackType = detectFeedType(feedResult.content, feedResult.contentType);
const parsed = await parseFeed(feedResult.content, fallbackFeed.url, { const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
contentType: feedResult.contentType, contentType: feedResult.contentType,
}); });
return { return {
...feedResult, ...feedResult,
...parsed, ...parsed,
feedType: fallbackType,
hub: feedResult.hub || parsed._hub, hub: feedResult.hub || parsed._hub,
discoveredFrom: url, discoveredFrom: url,
}; };
@@ -194,6 +196,7 @@ export async function fetchAndParseFeed(url, options = {}) {
return { return {
...result, ...result,
...parsed, ...parsed,
feedType: feedType,
hub: result.hub || parsed._hub, hub: result.hub || parsed._hub,
}; };
} }
+4 -1
View File
@@ -132,13 +132,16 @@ export async function processFeed(application, feed) {
lastModified: parsed.lastModified, lastModified: parsed.lastModified,
}; };
// Update feed title/photo if discovered // Update feed title/photo/feedType if discovered
if (parsed.name && !feed.title) { if (parsed.name && !feed.title) {
updateData.title = parsed.name; updateData.title = parsed.name;
} }
if (parsed.photo && !feed.photo) { if (parsed.photo && !feed.photo) {
updateData.photo = parsed.photo; updateData.photo = parsed.photo;
} }
if (parsed.feedType && !feed.feedType) {
updateData.feedType = parsed.feedType;
}
await updateFeedAfterFetch( await updateFeedAfterFetch(
application, application,
+80 -1
View File
@@ -16,6 +16,73 @@ function getCollection(application) {
return application.collections.get("microsub_feeds"); return application.collections.get("microsub_feeds");
} }
/**
* Normalize a feed URL for duplicate comparison.
* Strips trailing slashes, normalizes protocol to https, lowercases hostname.
* @param {string} url - Feed URL
* @returns {string} Normalized URL
*/
export function normalizeUrl(url) {
try {
const parsed = new URL(url);
// Normalize protocol to https
parsed.protocol = "https:";
// Lowercase hostname
parsed.hostname = parsed.hostname.toLowerCase();
// Remove trailing slash from path (but keep "/" for root)
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
parsed.pathname = parsed.pathname.slice(0, -1);
}
return parsed.href;
} catch {
return url;
}
}
/**
* Find an existing feed across ALL channels by normalized URL
* @param {object} application - Indiekit application
* @param {string} url - Feed URL to check
* @returns {Promise<object|null>} Existing feed with channel info, or null
*/
export async function findFeedAcrossChannels(application, url) {
const collection = getCollection(application);
const normalized = normalizeUrl(url);
// Get all feeds and check normalized URLs
// We check a few common URL variants directly for efficiency
const variants = new Set();
variants.add(url);
variants.add(normalized);
// Also try with/without trailing slash
if (url.endsWith("/")) {
variants.add(url.slice(0, -1));
} else {
variants.add(url + "/");
}
// Try http/https variants
if (url.startsWith("https://")) {
variants.add(url.replace("https://", "http://"));
} else if (url.startsWith("http://")) {
variants.add(url.replace("http://", "https://"));
}
const existing = await collection.findOne({
url: { $in: [...variants] },
});
if (!existing) return null;
// Look up the channel name for a useful error message
const channelsCollection = application.collections.get("microsub_channels");
const channel = await channelsCollection.findOne({ _id: existing.channelId });
return {
feed: existing,
channelName: channel?.name || "unknown channel",
};
}
/** /**
* Create a new feed subscription * Create a new feed subscription
* @param {object} application - Indiekit application * @param {object} application - Indiekit application
@@ -32,12 +99,24 @@ export async function createFeed(
) { ) {
const collection = getCollection(application); const collection = getCollection(application);
// Check if feed already exists in channel // Check if feed already exists in this channel (exact match)
const existing = await collection.findOne({ channelId, url }); const existing = await collection.findOne({ channelId, url });
if (existing) { if (existing) {
return existing; return existing;
} }
// Check for duplicate across ALL channels (normalized URL)
const duplicate = await findFeedAcrossChannels(application, url);
if (duplicate) {
const error = new Error(
`Feed already exists in channel "${duplicate.channelName}"`,
);
error.code = "DUPLICATE_FEED";
error.existingFeed = duplicate.feed;
error.channelName = duplicate.channelName;
throw error;
}
const feed = { const feed = {
channelId, channelId,
url, url,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-microsub", "name": "@rmdes/indiekit-endpoint-microsub",
"version": "1.0.43", "version": "1.0.44",
"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",
+9
View File
@@ -10,6 +10,12 @@
<h2>{{ __("microsub.feeds.title") }}</h2> <h2>{{ __("microsub.feeds.title") }}</h2>
{% if error %}
<div class="notice notice--error" role="alert">
{{ error }}
</div>
{% endif %}
{% if feeds.length > 0 %} {% if feeds.length > 0 %}
<div class="feeds__list"> <div class="feeds__list">
{% for feed in feeds %} {% for feed in feeds %}
@@ -27,6 +33,9 @@
<div class="feeds__details"> <div class="feeds__details">
<span class="feeds__name"> <span class="feeds__name">
{{ feed.title or feed.url }} {{ feed.title or feed.url }}
{% if feed.feedType %}
<span class="badge badge--offset badge--small" title="Feed format">{{ feed.feedType | upper }}</span>
{% endif %}
{% if feed.status == 'error' %} {% if feed.status == 'error' %}
<span class="badge badge--red">Error</span> <span class="badge badge--red">Error</span>
{% elif feed.status == 'active' %} {% elif feed.status == 'active' %}