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

View File

@@ -67,13 +67,24 @@ export async function follow(request, response) {
throw new IndiekitError("Channel not found", { status: 404 });
}
// Create feed subscription
const feed = await createFeed(application, {
channelId: channelDocument._id,
url,
title: undefined, // Will be populated on first fetch
photo: undefined,
});
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
let feed;
try {
feed = await createFeed(application, {
channelId: channelDocument._id,
url,
title: undefined, // Will be populated on first fetch
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)
// This will also discover and subscribe to WebSub hubs

View File

@@ -319,20 +319,43 @@ export async function addFeed(request, response) {
return response.status(404).render("404");
}
// Create feed subscription
const feed = await createFeed(application, {
channelId: channelDocument._id,
url,
title: undefined,
photo: undefined,
});
try {
// Create feed subscription (throws DUPLICATE_FEED if already exists)
const feed = await createFeed(application, {
channelId: channelDocument._id,
url,
title: undefined,
photo: undefined,
});
// Trigger immediate fetch in background
refreshFeedNow(application, feed._id).catch((error) => {
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
});
// Trigger immediate fetch in background
refreshFeedNow(application, feed._id).catch((error) => {
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
});
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,20 +805,40 @@ export async function subscribe(request, response) {
}
}
// Create feed subscription
const feed = await createFeed(application, {
channelId: channelDocument._id,
url,
title: undefined,
photo: undefined,
});
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
try {
const feed = await createFeed(application, {
channelId: channelDocument._id,
url,
title: undefined,
photo: undefined,
});
// Trigger immediate fetch in background
refreshFeedNow(application, feed._id).catch((error) => {
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
});
// Trigger immediate fetch in background
refreshFeedNow(application, feed._id).catch((error) => {
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
});
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;
}
}
/**

View File

@@ -171,12 +171,14 @@ export async function fetchAndParseFeed(url, options = {}) {
// Fetch and parse the discovered feed
const feedResult = await fetchFeed(fallbackFeed.url, options);
if (!feedResult.notModified) {
const fallbackType = detectFeedType(feedResult.content, feedResult.contentType);
const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
contentType: feedResult.contentType,
});
return {
...feedResult,
...parsed,
feedType: fallbackType,
hub: feedResult.hub || parsed._hub,
discoveredFrom: url,
};
@@ -194,6 +196,7 @@ export async function fetchAndParseFeed(url, options = {}) {
return {
...result,
...parsed,
feedType: feedType,
hub: result.hub || parsed._hub,
};
}

View File

@@ -132,13 +132,16 @@ export async function processFeed(application, feed) {
lastModified: parsed.lastModified,
};
// Update feed title/photo if discovered
// Update feed title/photo/feedType if discovered
if (parsed.name && !feed.title) {
updateData.title = parsed.name;
}
if (parsed.photo && !feed.photo) {
updateData.photo = parsed.photo;
}
if (parsed.feedType && !feed.feedType) {
updateData.feedType = parsed.feedType;
}
await updateFeedAfterFetch(
application,

View File

@@ -16,6 +16,73 @@ function getCollection(application) {
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
* @param {object} application - Indiekit application
@@ -32,12 +99,24 @@ export async function createFeed(
) {
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 });
if (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 = {
channelId,
url,

View File

@@ -1,6 +1,6 @@
{
"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.",
"keywords": [
"indiekit",

View File

@@ -10,6 +10,12 @@
<h2>{{ __("microsub.feeds.title") }}</h2>
{% if error %}
<div class="notice notice--error" role="alert">
{{ error }}
</div>
{% endif %}
{% if feeds.length > 0 %}
<div class="feeds__list">
{% for feed in feeds %}
@@ -27,6 +33,9 @@
<div class="feeds__details">
<span class="feeds__name">
{{ 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' %}
<span class="badge badge--red">Error</span>
{% elif feed.status == 'active' %}