mirror of
https://github.com/svemagie/indiekit-endpoint-blogroll.git
synced 2026-04-02 07:24:57 +02:00
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:
14
CLAUDE.md
14
CLAUDE.md
@@ -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
|
||||
|
||||
3
index.js
3
index.js
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
139
lib/sync/feedland.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user