feat: add FeedLand source type for blogroll

Adds FeedLand (feedland.com or self-hosted) as a new source type alongside
OPML and Microsub. Syncs subscriptions via FeedLand's public OPML endpoint
with optional category filtering and AJAX category discovery in the admin UI.
This commit is contained in:
Ricardo
2026-02-17 13:54:19 +01:00
parent 9a8cb669d1
commit 129dc78e09
23 changed files with 463 additions and 23 deletions

View File

@@ -5,8 +5,9 @@
`@rmdes/indiekit-endpoint-blogroll` is an Indiekit plugin that provides a comprehensive blogroll management system. It aggregates blog feeds from multiple sources (OPML files/URLs, Microsub subscriptions), fetches and caches recent items, and exposes both an admin UI and public JSON API.
**Key Capabilities:**
- Aggregates blogs from OPML (URL or file), JSON feeds, or manual entry
- Aggregates blogs from OPML (URL or file), JSON feeds, FeedLand, or manual entry
- Integrates with Microsub plugin to mirror subscriptions
- FeedLand integration (feedland.com or self-hosted) with category discovery
- Background feed fetching with configurable intervals
- Admin UI for managing sources, blogs, and viewing recent items
- Public read-only JSON API for frontend integration
@@ -21,13 +22,13 @@
### Data Flow
```
Sources (OPML/Microsub) → Blogs → Items
Sources (OPML/Microsub/FeedLand) → Blogs → Items
↓ ↓ ↓
blogrollSources blogrollBlogs blogrollItems
microsub_items (reference)
```
1. **Sources** define where blogs come from (OPML URL, OPML file, Microsub channels)
1. **Sources** define where blogs come from (OPML URL, OPML file, Microsub channels, FeedLand)
2. **Blogs** are individual feed subscriptions with metadata
3. **Items** are recent posts/articles from blogs (cached for 7 days by default)
@@ -42,13 +43,17 @@ Sources (OPML/Microsub) → Blogs → Items
```javascript
{
_id: ObjectId,
type: "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub",
type: "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub" | "feedland",
name: String, // Display name
url: String | null, // For opml_url, json_feed
opmlContent: String | null, // For opml_file
// Microsub-specific
channelFilter: String | null, // Specific channel UID or null for all
categoryPrefix: String, // Prefix for blog categories
// FeedLand-specific
feedlandInstance: String | null, // e.g., "https://feedland.com"
feedlandUsername: String | null, // FeedLand screen name
feedlandCategory: String | null, // Category filter (or null for all)
enabled: Boolean,
syncInterval: Number, // Minutes between syncs
lastSyncAt: String | null, // ISO 8601
@@ -141,6 +146,7 @@ Sources (OPML/Microsub) → Blogs → Items
- **lib/sync/scheduler.js** - Background sync, interval management
- **lib/sync/opml.js** - OPML parsing, fetch from URL, export
- **lib/sync/microsub.js** - Microsub channel/feed sync, webhook handler
- **lib/sync/feedland.js** - FeedLand sync, category discovery, OPML URL builder
- **lib/sync/feed.js** - RSS/Atom/JSON Feed parsing, item fetching
### Utilities

View File

@@ -93,6 +93,9 @@ export default class BlogrollEndpoint {
protectedRouter.post("/api/microsub-webhook", apiController.microsubWebhook);
protectedRouter.get("/api/microsub-status", apiController.microsubStatus);
// FeedLand integration (protected - category discovery)
protectedRouter.get("/api/feedland-categories", sourcesController.feedlandCategories);
return protectedRouter;
}

View File

@@ -16,6 +16,10 @@ import {
getMicrosubChannels,
isMicrosubAvailable,
} from "../sync/microsub.js";
import {
syncFeedlandSource,
fetchFeedlandCategories,
} from "../sync/feedland.js";
/**
* List sources
@@ -95,6 +99,9 @@ async function create(request, response) {
enabled,
channelFilter,
categoryPrefix,
feedlandInstance,
feedlandUsername,
feedlandCategory,
} = request.body;
try {
@@ -120,6 +127,13 @@ async function create(request, response) {
return response.redirect(`${request.baseUrl}/sources/new`);
}
if (type === "feedland" && (!feedlandInstance || !feedlandUsername)) {
request.session.messages = [
{ type: "error", content: request.__("blogroll.sources.form.feedlandRequired") },
];
return response.redirect(`${request.baseUrl}/sources/new`);
}
const sourceData = {
name,
type,
@@ -135,12 +149,21 @@ async function create(request, response) {
sourceData.categoryPrefix = categoryPrefix || "";
}
// Add feedland-specific fields
if (type === "feedland") {
sourceData.feedlandInstance = feedlandInstance.replace(/\/+$/, "");
sourceData.feedlandUsername = feedlandUsername;
sourceData.feedlandCategory = feedlandCategory || null;
}
const source = await createSource(application, sourceData);
// Trigger initial sync based on source type
try {
if (type === "microsub") {
await syncMicrosubSource(application, source);
} else if (type === "feedland") {
await syncFeedlandSource(application, source);
} else {
await syncOpmlSource(application, source);
}
@@ -223,6 +246,9 @@ async function update(request, response) {
enabled,
channelFilter,
categoryPrefix,
feedlandInstance,
feedlandUsername,
feedlandCategory,
} = request.body;
try {
@@ -247,6 +273,15 @@ async function update(request, response) {
updateData.categoryPrefix = categoryPrefix || "";
}
// Add feedland-specific fields
if (type === "feedland") {
updateData.feedlandInstance = feedlandInstance
? feedlandInstance.replace(/\/+$/, "")
: null;
updateData.feedlandUsername = feedlandUsername || null;
updateData.feedlandCategory = feedlandCategory || null;
}
await updateSource(application, id, updateData);
request.session.messages = [
@@ -313,6 +348,8 @@ async function sync(request, response) {
let result;
if (source.type === "microsub") {
result = await syncMicrosubSource(application, source);
} else if (source.type === "feedland") {
result = await syncFeedlandSource(application, source);
} else {
result = await syncOpmlSource(application, source);
}
@@ -358,6 +395,26 @@ function consumeFlashMessage(request) {
return result;
}
/**
* Fetch FeedLand categories (AJAX endpoint)
* GET /api/feedland-categories?instance=...&username=...
*/
async function feedlandCategories(request, response) {
const { instance, username } = request.query;
if (!instance || !username) {
return response.status(400).json({ error: "instance and username are required" });
}
try {
const data = await fetchFeedlandCategories(instance, username);
response.json(data);
} catch (error) {
console.error("[Blogroll] FeedLand categories fetch error:", error.message);
response.status(502).json({ error: error.message });
}
}
export const sourcesController = {
list,
newForm,
@@ -366,4 +423,5 @@ export const sourcesController = {
update,
remove,
sync,
feedlandCategories,
};

View File

@@ -48,13 +48,17 @@ export async function createSource(application, data) {
const now = new Date().toISOString();
const source = {
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub"
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub" | "feedland"
name: data.name,
url: data.url || null,
opmlContent: data.opmlContent || null,
// Microsub-specific fields
channelFilter: data.channelFilter || null,
categoryPrefix: data.categoryPrefix || "",
// FeedLand-specific fields
feedlandInstance: data.feedlandInstance || null,
feedlandUsername: data.feedlandUsername || null,
feedlandCategory: data.feedlandCategory || null,
enabled: data.enabled !== false,
syncInterval: data.syncInterval || 60, // minutes
lastSyncAt: null,
@@ -160,7 +164,7 @@ export async function getSourcesDueForSync(application) {
return collection
.find({
enabled: true,
type: { $in: ["opml_url", "json_feed", "microsub"] },
type: { $in: ["opml_url", "json_feed", "microsub", "feedland"] },
$or: [
{ lastSyncAt: null },
{

139
lib/sync/feedland.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* FeedLand synchronization
* @module sync/feedland
*/
import { fetchAndParseOpml } from "./opml.js";
import { upsertBlog } from "../storage/blogs.js";
import { updateSourceSyncStatus } from "../storage/sources.js";
/**
* Fetch user categories from a FeedLand instance
* @param {string} instanceUrl - FeedLand instance URL (e.g., https://feedland.com)
* @param {string} username - FeedLand username
* @param {number} timeout - Fetch timeout in ms
* @returns {Promise<object>} Category data { categories: string[], homePageCategories: string[] }
*/
export async function fetchFeedlandCategories(instanceUrl, username, timeout = 10000) {
const baseUrl = instanceUrl.replace(/\/+$/, "");
const url = `${baseUrl}/getusercategories?screenname=${encodeURIComponent(username)}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "Indiekit-Blogroll/1.0",
Accept: "application/json",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// FeedLand returns comma-separated strings
const categories = data.categories
? data.categories.split(",").map((c) => c.trim()).filter(Boolean)
: [];
const homePageCategories = data.homePageCategories
? data.homePageCategories.split(",").map((c) => c.trim()).filter(Boolean)
: [];
return { categories, homePageCategories, screenname: data.screenname };
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error("Request timed out");
}
throw error;
}
}
/**
* Build the OPML URL for a FeedLand source
* @param {object} source - Source document with feedland fields
* @returns {string} OPML URL
*/
export function buildFeedlandOpmlUrl(source) {
const baseUrl = source.feedlandInstance.replace(/\/+$/, "");
let url = `${baseUrl}/opml?screenname=${encodeURIComponent(source.feedlandUsername)}`;
if (source.feedlandCategory) {
url += `&catname=${encodeURIComponent(source.feedlandCategory)}`;
}
return url;
}
/**
* Build the FeedLand river URL for linking back
* @param {object} source - Source document with feedland fields
* @returns {string} River URL
*/
export function buildFeedlandRiverUrl(source) {
const baseUrl = source.feedlandInstance.replace(/\/+$/, "");
return `${baseUrl}/?river=true&screenname=${encodeURIComponent(source.feedlandUsername)}`;
}
/**
* Sync blogs from a FeedLand source
* @param {object} application - Application instance
* @param {object} source - Source document
* @returns {Promise<object>} Sync result
*/
export async function syncFeedlandSource(application, source) {
try {
const opmlUrl = buildFeedlandOpmlUrl(source);
const blogs = await fetchAndParseOpml(opmlUrl);
let added = 0;
let updated = 0;
for (const blog of blogs) {
// FeedLand OPML includes a category attribute with comma-separated categories.
// Use the first category, or fall back to the source's feedlandCategory filter,
// or use the FeedLand username as a category grouping.
const category = blog.category
|| source.feedlandCategory
|| source.feedlandUsername
|| "";
const result = await upsertBlog(application, {
...blog,
category,
sourceId: source._id,
});
if (result.upserted) added++;
else if (result.modified) updated++;
}
// Update source sync status
await updateSourceSyncStatus(application, source._id, { success: true });
console.log(
`[Blogroll] Synced FeedLand source "${source.name}" (${source.feedlandUsername}@${source.feedlandInstance}): ${added} added, ${updated} updated, ${blogs.length} total`
);
return { success: true, added, updated, total: blogs.length };
} catch (error) {
// Update source with error status
await updateSourceSyncStatus(application, source._id, {
success: false,
error: error.message,
});
console.error(
`[Blogroll] FeedLand sync failed for "${source.name}":`,
error.message
);
return { success: false, error: error.message };
}
}

View File

@@ -8,6 +8,7 @@ import { getBlogs, countBlogs } from "../storage/blogs.js";
import { countItems, deleteOldItems } from "../storage/items.js";
import { syncOpmlSource } from "./opml.js";
import { syncMicrosubSource } from "./microsub.js";
import { syncFeedlandSource } from "./feedland.js";
import { syncBlogItems } from "./feed.js";
let syncInterval = null;
@@ -42,7 +43,7 @@ export async function runFullSync(application, options = {}) {
// Sync all enabled sources (OPML, JSON, Microsub)
const sources = await getSources(application);
const enabledSources = sources.filter(
(s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub"].includes(s.type)
(s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub", "feedland"].includes(s.type)
);
let sourcesSuccess = 0;
@@ -53,6 +54,8 @@ export async function runFullSync(application, options = {}) {
let result;
if (source.type === "microsub") {
result = await syncMicrosubSource(application, source);
} else if (source.type === "feedland") {
result = await syncFeedlandSource(application, source);
} else {
result = await syncOpmlSource(application, source);
}

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -70,7 +70,16 @@
"microsubChannel": "Microsub Channel",
"microsubChannelHint": "Sync feeds from a specific channel, or all channels",
"categoryPrefix": "Category Prefix",
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
"categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
"feedlandInstance": "FeedLand Instance URL",
"feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
"feedlandUsername": "FeedLand Username",
"feedlandUsernameHint": "Your FeedLand screen name",
"feedlandCategory": "FeedLand Category",
"feedlandCategoryAll": "All subscriptions",
"feedlandCategoryHint": "Optional: sync only feeds from a specific category",
"feedlandLoadCategories": "Load",
"feedlandRequired": "FeedLand instance URL and username are required"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-blogroll",
"version": "1.0.20",
"version": "1.0.21",
"description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.",
"keywords": [
"indiekit",

View File

@@ -15,6 +15,7 @@
{% if microsubAvailable %}
<option value="microsub" {% if source.type == 'microsub' %}selected{% endif %}>Microsub Subscriptions</option>
{% endif %}
<option value="feedland" {% if source.type == 'feedland' %}selected{% endif %}>FeedLand</option>
</select>
<span class="hint">{{ __("blogroll.sources.form.typeHint") }}</span>
</div>
@@ -48,6 +49,32 @@
<span class="hint">{{ __("blogroll.sources.form.categoryPrefixHint") | default("Optional prefix for blog categories (e.g., 'Following: ')") }}</span>
</div>
<div class="blogroll-field" id="feedlandInstanceField" style="display: none;">
<label class="label" for="feedlandInstance">{{ __("blogroll.sources.form.feedlandInstance") | default("FeedLand Instance URL") }}</label>
<input class="input" type="url" id="feedlandInstance" name="feedlandInstance" value="{{ source.feedlandInstance if source else 'https://feedland.com' }}" placeholder="https://feedland.com">
<span class="hint">{{ __("blogroll.sources.form.feedlandInstanceHint") | default("FeedLand instance URL (feedland.com or self-hosted)") }}</span>
</div>
<div class="blogroll-field" id="feedlandUsernameField" style="display: none;">
<label class="label" for="feedlandUsername">{{ __("blogroll.sources.form.feedlandUsername") | default("FeedLand Username") }}</label>
<input class="input" type="text" id="feedlandUsername" name="feedlandUsername" value="{{ source.feedlandUsername if source else '' }}" placeholder="e.g., davewiner">
<span class="hint">{{ __("blogroll.sources.form.feedlandUsernameHint") | default("Your FeedLand screen name") }}</span>
</div>
<div class="blogroll-field" id="feedlandCategoryField" style="display: none;">
<label class="label" for="feedlandCategory">{{ __("blogroll.sources.form.feedlandCategory") | default("FeedLand Category") }}</label>
<div style="display: flex; gap: 0.5rem; align-items: flex-start;">
<select class="select" id="feedlandCategory" name="feedlandCategory" style="flex: 1;">
<option value="">{{ __("blogroll.sources.form.feedlandCategoryAll") | default("All subscriptions") }}</option>
{% if source.feedlandCategory %}
<option value="{{ source.feedlandCategory }}" selected>{{ source.feedlandCategory }}</option>
{% endif %}
</select>
<button type="button" class="button button--secondary" onclick="loadFeedlandCategories()" id="feedlandLoadBtn">{{ __("blogroll.sources.form.feedlandLoadCategories") | default("Load") }}</button>
</div>
<span class="hint" id="feedlandCategoryHint">{{ __("blogroll.sources.form.feedlandCategoryHint") | default("Optional: sync only feeds from a specific category") }}</span>
</div>
<div class="blogroll-field">
<label class="label" for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
<select class="select" id="syncInterval" name="syncInterval">
@@ -78,12 +105,18 @@ function toggleTypeFields() {
const opmlContentField = document.getElementById('opmlContentField');
const microsubChannelField = document.getElementById('microsubChannelField');
const categoryPrefixField = document.getElementById('categoryPrefixField');
const feedlandInstanceField = document.getElementById('feedlandInstanceField');
const feedlandUsernameField = document.getElementById('feedlandUsernameField');
const feedlandCategoryField = document.getElementById('feedlandCategoryField');
// Hide all type-specific fields first
urlField.style.display = 'none';
opmlContentField.style.display = 'none';
if (microsubChannelField) microsubChannelField.style.display = 'none';
if (categoryPrefixField) categoryPrefixField.style.display = 'none';
if (feedlandInstanceField) feedlandInstanceField.style.display = 'none';
if (feedlandUsernameField) feedlandUsernameField.style.display = 'none';
if (feedlandCategoryField) feedlandCategoryField.style.display = 'none';
// Show fields based on type
if (type === 'opml_url') {
@@ -93,9 +126,68 @@ function toggleTypeFields() {
} else if (type === 'microsub') {
if (microsubChannelField) microsubChannelField.style.display = 'flex';
if (categoryPrefixField) categoryPrefixField.style.display = 'flex';
} else if (type === 'feedland') {
if (feedlandInstanceField) feedlandInstanceField.style.display = 'flex';
if (feedlandUsernameField) feedlandUsernameField.style.display = 'flex';
if (feedlandCategoryField) feedlandCategoryField.style.display = 'flex';
}
}
function loadFeedlandCategories() {
const instance = document.getElementById('feedlandInstance').value;
const username = document.getElementById('feedlandUsername').value;
const select = document.getElementById('feedlandCategory');
const btn = document.getElementById('feedlandLoadBtn');
const hint = document.getElementById('feedlandCategoryHint');
const currentValue = select.value;
if (!instance || !username) {
hint.textContent = 'Please enter instance URL and username first';
return;
}
btn.disabled = true;
btn.textContent = '...';
hint.textContent = 'Loading categories...';
const baseUrl = '{{ baseUrl }}';
const url = baseUrl + '/api/feedland-categories?instance=' + encodeURIComponent(instance) + '&username=' + encodeURIComponent(username);
fetch(url, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
hint.textContent = 'Error: ' + data.error;
return;
}
// Clear existing options and rebuild safely using DOM methods
while (select.options.length > 0) select.remove(0);
var allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = '{{ __("blogroll.sources.form.feedlandCategoryAll") | default("All subscriptions") }}';
select.appendChild(allOpt);
var cats = data.categories || [];
cats.forEach(function(cat) {
var opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
if (cat === currentValue) opt.selected = true;
select.appendChild(opt);
});
hint.textContent = cats.length + ' categories found' + (data.screenname ? ' for ' + data.screenname : '');
})
.catch(function(err) {
hint.textContent = 'Failed to load: ' + err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = '{{ __("blogroll.sources.form.feedlandLoadCategories") | default("Load") }}';
});
}
// Initialize on load
toggleTypeFields();
</script>