@rmdes/indiekit-endpoint-blogroll 1.0.20 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -93,6 +93,9 @@ export default class BlogrollEndpoint {
93
93
  protectedRouter.post("/api/microsub-webhook", apiController.microsubWebhook);
94
94
  protectedRouter.get("/api/microsub-status", apiController.microsubStatus);
95
95
 
96
+ // FeedLand integration (protected - category discovery)
97
+ protectedRouter.get("/api/feedland-categories", sourcesController.feedlandCategories);
98
+
96
99
  return protectedRouter;
97
100
  }
98
101
 
@@ -16,6 +16,10 @@ import {
16
16
  getMicrosubChannels,
17
17
  isMicrosubAvailable,
18
18
  } from "../sync/microsub.js";
19
+ import {
20
+ syncFeedlandSource,
21
+ fetchFeedlandCategories,
22
+ } from "../sync/feedland.js";
19
23
 
20
24
  /**
21
25
  * List sources
@@ -95,6 +99,9 @@ async function create(request, response) {
95
99
  enabled,
96
100
  channelFilter,
97
101
  categoryPrefix,
102
+ feedlandInstance,
103
+ feedlandUsername,
104
+ feedlandCategory,
98
105
  } = request.body;
99
106
 
100
107
  try {
@@ -120,6 +127,13 @@ async function create(request, response) {
120
127
  return response.redirect(`${request.baseUrl}/sources/new`);
121
128
  }
122
129
 
130
+ if (type === "feedland" && (!feedlandInstance || !feedlandUsername)) {
131
+ request.session.messages = [
132
+ { type: "error", content: request.__("blogroll.sources.form.feedlandRequired") },
133
+ ];
134
+ return response.redirect(`${request.baseUrl}/sources/new`);
135
+ }
136
+
123
137
  const sourceData = {
124
138
  name,
125
139
  type,
@@ -135,12 +149,21 @@ async function create(request, response) {
135
149
  sourceData.categoryPrefix = categoryPrefix || "";
136
150
  }
137
151
 
152
+ // Add feedland-specific fields
153
+ if (type === "feedland") {
154
+ sourceData.feedlandInstance = feedlandInstance.replace(/\/+$/, "");
155
+ sourceData.feedlandUsername = feedlandUsername;
156
+ sourceData.feedlandCategory = feedlandCategory || null;
157
+ }
158
+
138
159
  const source = await createSource(application, sourceData);
139
160
 
140
161
  // Trigger initial sync based on source type
141
162
  try {
142
163
  if (type === "microsub") {
143
164
  await syncMicrosubSource(application, source);
165
+ } else if (type === "feedland") {
166
+ await syncFeedlandSource(application, source);
144
167
  } else {
145
168
  await syncOpmlSource(application, source);
146
169
  }
@@ -223,6 +246,9 @@ async function update(request, response) {
223
246
  enabled,
224
247
  channelFilter,
225
248
  categoryPrefix,
249
+ feedlandInstance,
250
+ feedlandUsername,
251
+ feedlandCategory,
226
252
  } = request.body;
227
253
 
228
254
  try {
@@ -247,6 +273,15 @@ async function update(request, response) {
247
273
  updateData.categoryPrefix = categoryPrefix || "";
248
274
  }
249
275
 
276
+ // Add feedland-specific fields
277
+ if (type === "feedland") {
278
+ updateData.feedlandInstance = feedlandInstance
279
+ ? feedlandInstance.replace(/\/+$/, "")
280
+ : null;
281
+ updateData.feedlandUsername = feedlandUsername || null;
282
+ updateData.feedlandCategory = feedlandCategory || null;
283
+ }
284
+
250
285
  await updateSource(application, id, updateData);
251
286
 
252
287
  request.session.messages = [
@@ -313,6 +348,8 @@ async function sync(request, response) {
313
348
  let result;
314
349
  if (source.type === "microsub") {
315
350
  result = await syncMicrosubSource(application, source);
351
+ } else if (source.type === "feedland") {
352
+ result = await syncFeedlandSource(application, source);
316
353
  } else {
317
354
  result = await syncOpmlSource(application, source);
318
355
  }
@@ -358,6 +395,26 @@ function consumeFlashMessage(request) {
358
395
  return result;
359
396
  }
360
397
 
398
+ /**
399
+ * Fetch FeedLand categories (AJAX endpoint)
400
+ * GET /api/feedland-categories?instance=...&username=...
401
+ */
402
+ async function feedlandCategories(request, response) {
403
+ const { instance, username } = request.query;
404
+
405
+ if (!instance || !username) {
406
+ return response.status(400).json({ error: "instance and username are required" });
407
+ }
408
+
409
+ try {
410
+ const data = await fetchFeedlandCategories(instance, username);
411
+ response.json(data);
412
+ } catch (error) {
413
+ console.error("[Blogroll] FeedLand categories fetch error:", error.message);
414
+ response.status(502).json({ error: error.message });
415
+ }
416
+ }
417
+
361
418
  export const sourcesController = {
362
419
  list,
363
420
  newForm,
@@ -366,4 +423,5 @@ export const sourcesController = {
366
423
  update,
367
424
  remove,
368
425
  sync,
426
+ feedlandCategories,
369
427
  };
@@ -48,13 +48,17 @@ export async function createSource(application, data) {
48
48
  const now = new Date().toISOString();
49
49
 
50
50
  const source = {
51
- type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub"
51
+ type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub" | "feedland"
52
52
  name: data.name,
53
53
  url: data.url || null,
54
54
  opmlContent: data.opmlContent || null,
55
55
  // Microsub-specific fields
56
56
  channelFilter: data.channelFilter || null,
57
57
  categoryPrefix: data.categoryPrefix || "",
58
+ // FeedLand-specific fields
59
+ feedlandInstance: data.feedlandInstance || null,
60
+ feedlandUsername: data.feedlandUsername || null,
61
+ feedlandCategory: data.feedlandCategory || null,
58
62
  enabled: data.enabled !== false,
59
63
  syncInterval: data.syncInterval || 60, // minutes
60
64
  lastSyncAt: null,
@@ -160,7 +164,7 @@ export async function getSourcesDueForSync(application) {
160
164
  return collection
161
165
  .find({
162
166
  enabled: true,
163
- type: { $in: ["opml_url", "json_feed", "microsub"] },
167
+ type: { $in: ["opml_url", "json_feed", "microsub", "feedland"] },
164
168
  $or: [
165
169
  { lastSyncAt: null },
166
170
  {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * FeedLand synchronization
3
+ * @module sync/feedland
4
+ */
5
+
6
+ import { fetchAndParseOpml } from "./opml.js";
7
+ import { upsertBlog } from "../storage/blogs.js";
8
+ import { updateSourceSyncStatus } from "../storage/sources.js";
9
+
10
+ /**
11
+ * Fetch user categories from a FeedLand instance
12
+ * @param {string} instanceUrl - FeedLand instance URL (e.g., https://feedland.com)
13
+ * @param {string} username - FeedLand username
14
+ * @param {number} timeout - Fetch timeout in ms
15
+ * @returns {Promise<object>} Category data { categories: string[], homePageCategories: string[] }
16
+ */
17
+ export async function fetchFeedlandCategories(instanceUrl, username, timeout = 10000) {
18
+ const baseUrl = instanceUrl.replace(/\/+$/, "");
19
+ const url = `${baseUrl}/getusercategories?screenname=${encodeURIComponent(username)}`;
20
+
21
+ const controller = new AbortController();
22
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
23
+
24
+ try {
25
+ const response = await fetch(url, {
26
+ signal: controller.signal,
27
+ headers: {
28
+ "User-Agent": "Indiekit-Blogroll/1.0",
29
+ Accept: "application/json",
30
+ },
31
+ });
32
+
33
+ clearTimeout(timeoutId);
34
+
35
+ if (!response.ok) {
36
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
37
+ }
38
+
39
+ const data = await response.json();
40
+
41
+ // FeedLand returns comma-separated strings
42
+ const categories = data.categories
43
+ ? data.categories.split(",").map((c) => c.trim()).filter(Boolean)
44
+ : [];
45
+ const homePageCategories = data.homePageCategories
46
+ ? data.homePageCategories.split(",").map((c) => c.trim()).filter(Boolean)
47
+ : [];
48
+
49
+ return { categories, homePageCategories, screenname: data.screenname };
50
+ } catch (error) {
51
+ clearTimeout(timeoutId);
52
+ if (error.name === "AbortError") {
53
+ throw new Error("Request timed out");
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Build the OPML URL for a FeedLand source
61
+ * @param {object} source - Source document with feedland fields
62
+ * @returns {string} OPML URL
63
+ */
64
+ export function buildFeedlandOpmlUrl(source) {
65
+ const baseUrl = source.feedlandInstance.replace(/\/+$/, "");
66
+ let url = `${baseUrl}/opml?screenname=${encodeURIComponent(source.feedlandUsername)}`;
67
+
68
+ if (source.feedlandCategory) {
69
+ url += `&catname=${encodeURIComponent(source.feedlandCategory)}`;
70
+ }
71
+
72
+ return url;
73
+ }
74
+
75
+ /**
76
+ * Build the FeedLand river URL for linking back
77
+ * @param {object} source - Source document with feedland fields
78
+ * @returns {string} River URL
79
+ */
80
+ export function buildFeedlandRiverUrl(source) {
81
+ const baseUrl = source.feedlandInstance.replace(/\/+$/, "");
82
+ return `${baseUrl}/?river=true&screenname=${encodeURIComponent(source.feedlandUsername)}`;
83
+ }
84
+
85
+ /**
86
+ * Sync blogs from a FeedLand source
87
+ * @param {object} application - Application instance
88
+ * @param {object} source - Source document
89
+ * @returns {Promise<object>} Sync result
90
+ */
91
+ export async function syncFeedlandSource(application, source) {
92
+ try {
93
+ const opmlUrl = buildFeedlandOpmlUrl(source);
94
+ const blogs = await fetchAndParseOpml(opmlUrl);
95
+
96
+ let added = 0;
97
+ let updated = 0;
98
+
99
+ for (const blog of blogs) {
100
+ // FeedLand OPML includes a category attribute with comma-separated categories.
101
+ // Use the first category, or fall back to the source's feedlandCategory filter,
102
+ // or use the FeedLand username as a category grouping.
103
+ const category = blog.category
104
+ || source.feedlandCategory
105
+ || source.feedlandUsername
106
+ || "";
107
+
108
+ const result = await upsertBlog(application, {
109
+ ...blog,
110
+ category,
111
+ sourceId: source._id,
112
+ });
113
+
114
+ if (result.upserted) added++;
115
+ else if (result.modified) updated++;
116
+ }
117
+
118
+ // Update source sync status
119
+ await updateSourceSyncStatus(application, source._id, { success: true });
120
+
121
+ console.log(
122
+ `[Blogroll] Synced FeedLand source "${source.name}" (${source.feedlandUsername}@${source.feedlandInstance}): ${added} added, ${updated} updated, ${blogs.length} total`
123
+ );
124
+
125
+ return { success: true, added, updated, total: blogs.length };
126
+ } catch (error) {
127
+ // Update source with error status
128
+ await updateSourceSyncStatus(application, source._id, {
129
+ success: false,
130
+ error: error.message,
131
+ });
132
+
133
+ console.error(
134
+ `[Blogroll] FeedLand sync failed for "${source.name}":`,
135
+ error.message
136
+ );
137
+ return { success: false, error: error.message };
138
+ }
139
+ }
@@ -8,6 +8,7 @@ import { getBlogs, countBlogs } from "../storage/blogs.js";
8
8
  import { countItems, deleteOldItems } from "../storage/items.js";
9
9
  import { syncOpmlSource } from "./opml.js";
10
10
  import { syncMicrosubSource } from "./microsub.js";
11
+ import { syncFeedlandSource } from "./feedland.js";
11
12
  import { syncBlogItems } from "./feed.js";
12
13
 
13
14
  let syncInterval = null;
@@ -42,7 +43,7 @@ export async function runFullSync(application, options = {}) {
42
43
  // Sync all enabled sources (OPML, JSON, Microsub)
43
44
  const sources = await getSources(application);
44
45
  const enabledSources = sources.filter(
45
- (s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub"].includes(s.type)
46
+ (s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub", "feedland"].includes(s.type)
46
47
  );
47
48
 
48
49
  let sourcesSuccess = 0;
@@ -53,6 +54,8 @@ export async function runFullSync(application, options = {}) {
53
54
  let result;
54
55
  if (source.type === "microsub") {
55
56
  result = await syncMicrosubSource(application, source);
57
+ } else if (source.type === "feedland") {
58
+ result = await syncFeedlandSource(application, source);
56
59
  } else {
57
60
  result = await syncOpmlSource(application, source);
58
61
  }
package/locales/de.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/en.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/es.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/fr.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/hi.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/id.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/it.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/nl.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/pl.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/pt.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/sr.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/locales/sv.json CHANGED
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
@@ -70,7 +70,16 @@
70
70
  "microsubChannel": "Microsub Channel",
71
71
  "microsubChannelHint": "Sync feeds from a specific channel, or all channels",
72
72
  "categoryPrefix": "Category Prefix",
73
- "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')"
73
+ "categoryPrefixHint": "Optional prefix for blog categories (e.g., 'Following: ')",
74
+ "feedlandInstance": "FeedLand Instance URL",
75
+ "feedlandInstanceHint": "FeedLand instance URL (feedland.com or self-hosted)",
76
+ "feedlandUsername": "FeedLand Username",
77
+ "feedlandUsernameHint": "Your FeedLand screen name",
78
+ "feedlandCategory": "FeedLand Category",
79
+ "feedlandCategoryAll": "All subscriptions",
80
+ "feedlandCategoryHint": "Optional: sync only feeds from a specific category",
81
+ "feedlandLoadCategories": "Load",
82
+ "feedlandRequired": "FeedLand instance URL and username are required"
74
83
  }
75
84
  },
76
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-blogroll",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -15,6 +15,7 @@
15
15
  {% if microsubAvailable %}
16
16
  <option value="microsub" {% if source.type == 'microsub' %}selected{% endif %}>Microsub Subscriptions</option>
17
17
  {% endif %}
18
+ <option value="feedland" {% if source.type == 'feedland' %}selected{% endif %}>FeedLand</option>
18
19
  </select>
19
20
  <span class="hint">{{ __("blogroll.sources.form.typeHint") }}</span>
20
21
  </div>
@@ -48,6 +49,32 @@
48
49
  <span class="hint">{{ __("blogroll.sources.form.categoryPrefixHint") | default("Optional prefix for blog categories (e.g., 'Following: ')") }}</span>
49
50
  </div>
50
51
 
52
+ <div class="blogroll-field" id="feedlandInstanceField" style="display: none;">
53
+ <label class="label" for="feedlandInstance">{{ __("blogroll.sources.form.feedlandInstance") | default("FeedLand Instance URL") }}</label>
54
+ <input class="input" type="url" id="feedlandInstance" name="feedlandInstance" value="{{ source.feedlandInstance if source else 'https://feedland.com' }}" placeholder="https://feedland.com">
55
+ <span class="hint">{{ __("blogroll.sources.form.feedlandInstanceHint") | default("FeedLand instance URL (feedland.com or self-hosted)") }}</span>
56
+ </div>
57
+
58
+ <div class="blogroll-field" id="feedlandUsernameField" style="display: none;">
59
+ <label class="label" for="feedlandUsername">{{ __("blogroll.sources.form.feedlandUsername") | default("FeedLand Username") }}</label>
60
+ <input class="input" type="text" id="feedlandUsername" name="feedlandUsername" value="{{ source.feedlandUsername if source else '' }}" placeholder="e.g., davewiner">
61
+ <span class="hint">{{ __("blogroll.sources.form.feedlandUsernameHint") | default("Your FeedLand screen name") }}</span>
62
+ </div>
63
+
64
+ <div class="blogroll-field" id="feedlandCategoryField" style="display: none;">
65
+ <label class="label" for="feedlandCategory">{{ __("blogroll.sources.form.feedlandCategory") | default("FeedLand Category") }}</label>
66
+ <div style="display: flex; gap: 0.5rem; align-items: flex-start;">
67
+ <select class="select" id="feedlandCategory" name="feedlandCategory" style="flex: 1;">
68
+ <option value="">{{ __("blogroll.sources.form.feedlandCategoryAll") | default("All subscriptions") }}</option>
69
+ {% if source.feedlandCategory %}
70
+ <option value="{{ source.feedlandCategory }}" selected>{{ source.feedlandCategory }}</option>
71
+ {% endif %}
72
+ </select>
73
+ <button type="button" class="button button--secondary" onclick="loadFeedlandCategories()" id="feedlandLoadBtn">{{ __("blogroll.sources.form.feedlandLoadCategories") | default("Load") }}</button>
74
+ </div>
75
+ <span class="hint" id="feedlandCategoryHint">{{ __("blogroll.sources.form.feedlandCategoryHint") | default("Optional: sync only feeds from a specific category") }}</span>
76
+ </div>
77
+
51
78
  <div class="blogroll-field">
52
79
  <label class="label" for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
53
80
  <select class="select" id="syncInterval" name="syncInterval">
@@ -78,12 +105,18 @@ function toggleTypeFields() {
78
105
  const opmlContentField = document.getElementById('opmlContentField');
79
106
  const microsubChannelField = document.getElementById('microsubChannelField');
80
107
  const categoryPrefixField = document.getElementById('categoryPrefixField');
108
+ const feedlandInstanceField = document.getElementById('feedlandInstanceField');
109
+ const feedlandUsernameField = document.getElementById('feedlandUsernameField');
110
+ const feedlandCategoryField = document.getElementById('feedlandCategoryField');
81
111
 
82
112
  // Hide all type-specific fields first
83
113
  urlField.style.display = 'none';
84
114
  opmlContentField.style.display = 'none';
85
115
  if (microsubChannelField) microsubChannelField.style.display = 'none';
86
116
  if (categoryPrefixField) categoryPrefixField.style.display = 'none';
117
+ if (feedlandInstanceField) feedlandInstanceField.style.display = 'none';
118
+ if (feedlandUsernameField) feedlandUsernameField.style.display = 'none';
119
+ if (feedlandCategoryField) feedlandCategoryField.style.display = 'none';
87
120
 
88
121
  // Show fields based on type
89
122
  if (type === 'opml_url') {
@@ -93,7 +126,66 @@ function toggleTypeFields() {
93
126
  } else if (type === 'microsub') {
94
127
  if (microsubChannelField) microsubChannelField.style.display = 'flex';
95
128
  if (categoryPrefixField) categoryPrefixField.style.display = 'flex';
129
+ } else if (type === 'feedland') {
130
+ if (feedlandInstanceField) feedlandInstanceField.style.display = 'flex';
131
+ if (feedlandUsernameField) feedlandUsernameField.style.display = 'flex';
132
+ if (feedlandCategoryField) feedlandCategoryField.style.display = 'flex';
133
+ }
134
+ }
135
+
136
+ function loadFeedlandCategories() {
137
+ const instance = document.getElementById('feedlandInstance').value;
138
+ const username = document.getElementById('feedlandUsername').value;
139
+ const select = document.getElementById('feedlandCategory');
140
+ const btn = document.getElementById('feedlandLoadBtn');
141
+ const hint = document.getElementById('feedlandCategoryHint');
142
+ const currentValue = select.value;
143
+
144
+ if (!instance || !username) {
145
+ hint.textContent = 'Please enter instance URL and username first';
146
+ return;
96
147
  }
148
+
149
+ btn.disabled = true;
150
+ btn.textContent = '...';
151
+ hint.textContent = 'Loading categories...';
152
+
153
+ const baseUrl = '{{ baseUrl }}';
154
+ const url = baseUrl + '/api/feedland-categories?instance=' + encodeURIComponent(instance) + '&username=' + encodeURIComponent(username);
155
+
156
+ fetch(url, { credentials: 'same-origin' })
157
+ .then(function(r) { return r.json(); })
158
+ .then(function(data) {
159
+ if (data.error) {
160
+ hint.textContent = 'Error: ' + data.error;
161
+ return;
162
+ }
163
+
164
+ // Clear existing options and rebuild safely using DOM methods
165
+ while (select.options.length > 0) select.remove(0);
166
+ var allOpt = document.createElement('option');
167
+ allOpt.value = '';
168
+ allOpt.textContent = '{{ __("blogroll.sources.form.feedlandCategoryAll") | default("All subscriptions") }}';
169
+ select.appendChild(allOpt);
170
+
171
+ var cats = data.categories || [];
172
+ cats.forEach(function(cat) {
173
+ var opt = document.createElement('option');
174
+ opt.value = cat;
175
+ opt.textContent = cat;
176
+ if (cat === currentValue) opt.selected = true;
177
+ select.appendChild(opt);
178
+ });
179
+
180
+ hint.textContent = cats.length + ' categories found' + (data.screenname ? ' for ' + data.screenname : '');
181
+ })
182
+ .catch(function(err) {
183
+ hint.textContent = 'Failed to load: ' + err.message;
184
+ })
185
+ .finally(function() {
186
+ btn.disabled = false;
187
+ btn.textContent = '{{ __("blogroll.sources.form.feedlandLoadCategories") | default("Load") }}';
188
+ });
97
189
  }
98
190
 
99
191
  // Initialize on load