@rmdes/indiekit-endpoint-blogroll 1.0.5 → 1.0.7
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 +4 -0
- package/lib/controllers/api.js +94 -2
- package/lib/controllers/sources.js +84 -10
- package/lib/storage/items.js +113 -17
- package/lib/storage/sources.js +5 -2
- package/lib/sync/microsub.js +277 -0
- package/lib/sync/scheduler.js +21 -4
- package/package.json +1 -1
- package/views/blogroll-blog-edit.njk +2 -0
- package/views/blogroll-source-edit.njk +34 -2
package/index.js
CHANGED
|
@@ -89,6 +89,10 @@ export default class BlogrollEndpoint {
|
|
|
89
89
|
// Feed discovery (protected to prevent abuse)
|
|
90
90
|
protectedRouter.get("/api/discover", apiController.discover);
|
|
91
91
|
|
|
92
|
+
// Microsub integration (protected - internal use)
|
|
93
|
+
protectedRouter.post("/api/microsub-webhook", apiController.microsubWebhook);
|
|
94
|
+
protectedRouter.get("/api/microsub-status", apiController.microsubStatus);
|
|
95
|
+
|
|
92
96
|
return protectedRouter;
|
|
93
97
|
}
|
|
94
98
|
|
package/lib/controllers/api.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getItems, getItemsForBlog } from "../storage/items.js";
|
|
|
9
9
|
import { getSyncStatus } from "../sync/scheduler.js";
|
|
10
10
|
import { generateOpml } from "../sync/opml.js";
|
|
11
11
|
import { discoverFeeds } from "../utils/feed-discovery.js";
|
|
12
|
+
import { handleMicrosubWebhook, isMicrosubAvailable } from "../sync/microsub.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* List blogs with optional filtering
|
|
@@ -57,7 +58,9 @@ async function getBlogDetail(request, response) {
|
|
|
57
58
|
return response.status(404).json({ error: "Blog not found" });
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
// Pass blog to getItemsForBlog to avoid duplicate lookup
|
|
62
|
+
// This handles both regular and Microsub-sourced blogs transparently
|
|
63
|
+
const items = await getItemsForBlog(application, blog._id, 20, blog);
|
|
61
64
|
|
|
62
65
|
response.json({
|
|
63
66
|
...sanitizeBlog(blog),
|
|
@@ -214,7 +217,7 @@ async function discover(request, response) {
|
|
|
214
217
|
* @returns {object} Sanitized blog
|
|
215
218
|
*/
|
|
216
219
|
function sanitizeBlog(blog) {
|
|
217
|
-
|
|
220
|
+
const sanitized = {
|
|
218
221
|
id: blog._id.toString(),
|
|
219
222
|
title: blog.title,
|
|
220
223
|
description: blog.description,
|
|
@@ -230,6 +233,14 @@ function sanitizeBlog(blog) {
|
|
|
230
233
|
pinned: blog.pinned,
|
|
231
234
|
lastFetchAt: blog.lastFetchAt,
|
|
232
235
|
};
|
|
236
|
+
|
|
237
|
+
// Include Microsub metadata if applicable
|
|
238
|
+
if (blog.source === "microsub") {
|
|
239
|
+
sanitized.source = "microsub";
|
|
240
|
+
sanitized.microsubChannel = blog.microsubChannelName;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return sanitized;
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
/**
|
|
@@ -250,6 +261,85 @@ function sanitizeItem(item) {
|
|
|
250
261
|
};
|
|
251
262
|
}
|
|
252
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Microsub webhook handler
|
|
266
|
+
* Receives subscription change notifications from Microsub
|
|
267
|
+
* POST /api/microsub-webhook
|
|
268
|
+
*/
|
|
269
|
+
async function microsubWebhook(request, response) {
|
|
270
|
+
const { application } = request.app.locals;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Verify Microsub is available
|
|
274
|
+
if (!isMicrosubAvailable(application)) {
|
|
275
|
+
return response.status(503).json({
|
|
276
|
+
ok: false,
|
|
277
|
+
error: "Microsub integration not available",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { action, url, channelName, title } = request.body;
|
|
282
|
+
|
|
283
|
+
if (!action || !url) {
|
|
284
|
+
return response.status(400).json({
|
|
285
|
+
ok: false,
|
|
286
|
+
error: "Missing required fields: action and url",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = await handleMicrosubWebhook(application, {
|
|
291
|
+
action,
|
|
292
|
+
url,
|
|
293
|
+
channelName,
|
|
294
|
+
title,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
response.json(result);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
console.error("[Blogroll API] microsubWebhook error:", error);
|
|
300
|
+
response.status(500).json({
|
|
301
|
+
ok: false,
|
|
302
|
+
error: "Webhook processing failed",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Check Microsub integration status
|
|
309
|
+
* GET /api/microsub-status
|
|
310
|
+
*/
|
|
311
|
+
async function microsubStatus(request, response) {
|
|
312
|
+
const { application } = request.app.locals;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const available = isMicrosubAvailable(application);
|
|
316
|
+
|
|
317
|
+
if (!available) {
|
|
318
|
+
return response.json({
|
|
319
|
+
available: false,
|
|
320
|
+
message: "Microsub plugin not installed or collections not available",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Get count of microsub-sourced blogs
|
|
325
|
+
const db = application.getBlogrollDb();
|
|
326
|
+
const microsubBlogCount = await db.collection("blogrollBlogs").countDocuments({
|
|
327
|
+
source: { $regex: /^microsub/ },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
response.json({
|
|
331
|
+
available: true,
|
|
332
|
+
blogs: microsubBlogCount,
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error("[Blogroll API] microsubStatus error:", error);
|
|
336
|
+
response.status(500).json({
|
|
337
|
+
available: false,
|
|
338
|
+
error: error.message,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
253
343
|
export const apiController = {
|
|
254
344
|
listBlogs,
|
|
255
345
|
getBlog: getBlogDetail,
|
|
@@ -259,4 +349,6 @@ export const apiController = {
|
|
|
259
349
|
exportOpml,
|
|
260
350
|
exportOpmlCategory,
|
|
261
351
|
discover,
|
|
352
|
+
microsubWebhook,
|
|
353
|
+
microsubStatus,
|
|
262
354
|
};
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
deleteSource,
|
|
12
12
|
} from "../storage/sources.js";
|
|
13
13
|
import { syncOpmlSource } from "../sync/opml.js";
|
|
14
|
+
import {
|
|
15
|
+
syncMicrosubSource,
|
|
16
|
+
getMicrosubChannels,
|
|
17
|
+
isMicrosubAvailable,
|
|
18
|
+
} from "../sync/microsub.js";
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* List sources
|
|
@@ -50,12 +55,22 @@ async function list(request, response) {
|
|
|
50
55
|
* New source form
|
|
51
56
|
* GET /sources/new
|
|
52
57
|
*/
|
|
53
|
-
function newForm(request, response) {
|
|
58
|
+
async function newForm(request, response) {
|
|
59
|
+
const { application } = request.app.locals;
|
|
60
|
+
|
|
61
|
+
// Check if Microsub is available and get channels
|
|
62
|
+
const microsubAvailable = isMicrosubAvailable(application);
|
|
63
|
+
const microsubChannels = microsubAvailable
|
|
64
|
+
? await getMicrosubChannels(application)
|
|
65
|
+
: [];
|
|
66
|
+
|
|
54
67
|
response.render("blogroll-source-edit", {
|
|
55
68
|
title: request.__("blogroll.sources.new"),
|
|
56
69
|
source: null,
|
|
57
70
|
isNew: true,
|
|
58
71
|
baseUrl: request.baseUrl,
|
|
72
|
+
microsubAvailable,
|
|
73
|
+
microsubChannels,
|
|
59
74
|
});
|
|
60
75
|
}
|
|
61
76
|
|
|
@@ -65,7 +80,16 @@ function newForm(request, response) {
|
|
|
65
80
|
*/
|
|
66
81
|
async function create(request, response) {
|
|
67
82
|
const { application } = request.app.locals;
|
|
68
|
-
const {
|
|
83
|
+
const {
|
|
84
|
+
name,
|
|
85
|
+
type,
|
|
86
|
+
url,
|
|
87
|
+
opmlContent,
|
|
88
|
+
syncInterval,
|
|
89
|
+
enabled,
|
|
90
|
+
channelFilter,
|
|
91
|
+
categoryPrefix,
|
|
92
|
+
} = request.body;
|
|
69
93
|
|
|
70
94
|
try {
|
|
71
95
|
// Validate required fields
|
|
@@ -83,18 +107,37 @@ async function create(request, response) {
|
|
|
83
107
|
return response.redirect(`${request.baseUrl}/sources/new`);
|
|
84
108
|
}
|
|
85
109
|
|
|
86
|
-
|
|
110
|
+
if (type === "microsub" && !isMicrosubAvailable(application)) {
|
|
111
|
+
request.session.messages = [
|
|
112
|
+
{ type: "error", content: "Microsub plugin is not available" },
|
|
113
|
+
];
|
|
114
|
+
return response.redirect(`${request.baseUrl}/sources/new`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sourceData = {
|
|
87
118
|
name,
|
|
88
119
|
type,
|
|
89
120
|
url: url || null,
|
|
90
121
|
opmlContent: opmlContent || null,
|
|
91
122
|
syncInterval: Number(syncInterval) || 60,
|
|
92
123
|
enabled: enabled === "on" || enabled === true,
|
|
93
|
-
}
|
|
124
|
+
};
|
|
94
125
|
|
|
95
|
-
//
|
|
126
|
+
// Add microsub-specific fields
|
|
127
|
+
if (type === "microsub") {
|
|
128
|
+
sourceData.channelFilter = channelFilter || null;
|
|
129
|
+
sourceData.categoryPrefix = categoryPrefix || "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const source = await createSource(application, sourceData);
|
|
133
|
+
|
|
134
|
+
// Trigger initial sync based on source type
|
|
96
135
|
try {
|
|
97
|
-
|
|
136
|
+
if (type === "microsub") {
|
|
137
|
+
await syncMicrosubSource(application, source);
|
|
138
|
+
} else {
|
|
139
|
+
await syncOpmlSource(application, source);
|
|
140
|
+
}
|
|
98
141
|
request.session.messages = [
|
|
99
142
|
{ type: "success", content: request.__("blogroll.sources.created_synced") },
|
|
100
143
|
];
|
|
@@ -134,11 +177,19 @@ async function edit(request, response) {
|
|
|
134
177
|
return response.status(404).render("404");
|
|
135
178
|
}
|
|
136
179
|
|
|
180
|
+
// Check if Microsub is available and get channels
|
|
181
|
+
const microsubAvailable = isMicrosubAvailable(application);
|
|
182
|
+
const microsubChannels = microsubAvailable
|
|
183
|
+
? await getMicrosubChannels(application)
|
|
184
|
+
: [];
|
|
185
|
+
|
|
137
186
|
response.render("blogroll-source-edit", {
|
|
138
187
|
title: request.__("blogroll.sources.edit"),
|
|
139
188
|
source,
|
|
140
189
|
isNew: false,
|
|
141
190
|
baseUrl: request.baseUrl,
|
|
191
|
+
microsubAvailable,
|
|
192
|
+
microsubChannels,
|
|
142
193
|
});
|
|
143
194
|
} catch (error) {
|
|
144
195
|
console.error("[Blogroll] Edit source error:", error);
|
|
@@ -156,7 +207,16 @@ async function edit(request, response) {
|
|
|
156
207
|
async function update(request, response) {
|
|
157
208
|
const { application } = request.app.locals;
|
|
158
209
|
const { id } = request.params;
|
|
159
|
-
const {
|
|
210
|
+
const {
|
|
211
|
+
name,
|
|
212
|
+
type,
|
|
213
|
+
url,
|
|
214
|
+
opmlContent,
|
|
215
|
+
syncInterval,
|
|
216
|
+
enabled,
|
|
217
|
+
channelFilter,
|
|
218
|
+
categoryPrefix,
|
|
219
|
+
} = request.body;
|
|
160
220
|
|
|
161
221
|
try {
|
|
162
222
|
const source = await getSource(application, id);
|
|
@@ -165,14 +225,22 @@ async function update(request, response) {
|
|
|
165
225
|
return response.status(404).render("404");
|
|
166
226
|
}
|
|
167
227
|
|
|
168
|
-
|
|
228
|
+
const updateData = {
|
|
169
229
|
name,
|
|
170
230
|
type,
|
|
171
231
|
url: url || null,
|
|
172
232
|
opmlContent: opmlContent || null,
|
|
173
233
|
syncInterval: Number(syncInterval) || 60,
|
|
174
234
|
enabled: enabled === "on" || enabled === true,
|
|
175
|
-
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Add microsub-specific fields
|
|
238
|
+
if (type === "microsub") {
|
|
239
|
+
updateData.channelFilter = channelFilter || null;
|
|
240
|
+
updateData.categoryPrefix = categoryPrefix || "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await updateSource(application, id, updateData);
|
|
176
244
|
|
|
177
245
|
request.session.messages = [
|
|
178
246
|
{ type: "success", content: request.__("blogroll.sources.updated") },
|
|
@@ -234,7 +302,13 @@ async function sync(request, response) {
|
|
|
234
302
|
return response.status(404).render("404");
|
|
235
303
|
}
|
|
236
304
|
|
|
237
|
-
|
|
305
|
+
// Use appropriate sync function based on source type
|
|
306
|
+
let result;
|
|
307
|
+
if (source.type === "microsub") {
|
|
308
|
+
result = await syncMicrosubSource(application, source);
|
|
309
|
+
} else {
|
|
310
|
+
result = await syncOpmlSource(application, source);
|
|
311
|
+
}
|
|
238
312
|
|
|
239
313
|
if (result.success) {
|
|
240
314
|
request.session.messages = [
|
package/lib/storage/items.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Item storage operations
|
|
3
3
|
* @module storage/items
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: This module handles items from TWO sources:
|
|
6
|
+
* - Regular blogs: items stored in blogrollItems collection
|
|
7
|
+
* - Microsub blogs: items queried from microsub_items collection (no duplication)
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import { ObjectId } from "mongodb";
|
|
11
|
+
import { getMicrosubItemsForBlog } from "../sync/microsub.js";
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* Get collection reference
|
|
@@ -17,6 +22,7 @@ function getCollection(application) {
|
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* Get items with optional filtering
|
|
25
|
+
* Combines items from blogrollItems (regular blogs) and microsub_items (Microsub blogs)
|
|
20
26
|
* @param {object} application - Application instance
|
|
21
27
|
* @param {object} options - Query options
|
|
22
28
|
* @returns {Promise<Array>} Items with blog info
|
|
@@ -25,10 +31,21 @@ export async function getItems(application, options = {}) {
|
|
|
25
31
|
const db = application.getBlogrollDb();
|
|
26
32
|
const { blogId, category, limit = 50, offset = 0 } = options;
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
// If requesting items for a specific blog, check if it's a Microsub blog
|
|
35
|
+
if (blogId) {
|
|
36
|
+
const blog = await db.collection("blogrollBlogs").findOne({ _id: new ObjectId(blogId) });
|
|
37
|
+
if (blog?.source === "microsub" && blog.microsubFeedId) {
|
|
38
|
+
const microsubItems = await getMicrosubItemsForBlog(application, blog, limit + 1);
|
|
39
|
+
const itemsWithBlog = microsubItems.map((item) => ({ ...item, blog }));
|
|
40
|
+
const hasMore = itemsWithBlog.length > limit;
|
|
41
|
+
if (hasMore) itemsWithBlog.pop();
|
|
42
|
+
return { items: itemsWithBlog, hasMore };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get regular items from blogrollItems
|
|
47
|
+
const regularPipeline = [
|
|
29
48
|
{ $sort: { published: -1 } },
|
|
30
|
-
{ $skip: offset },
|
|
31
|
-
{ $limit: limit + 1 }, // Fetch one extra to check hasMore
|
|
32
49
|
{
|
|
33
50
|
$lookup: {
|
|
34
51
|
from: "blogrollBlogs",
|
|
@@ -38,36 +55,80 @@ export async function getItems(application, options = {}) {
|
|
|
38
55
|
},
|
|
39
56
|
},
|
|
40
57
|
{ $unwind: "$blog" },
|
|
41
|
-
|
|
58
|
+
// Exclude hidden blogs and Microsub blogs (their items come from microsub_items)
|
|
59
|
+
{ $match: { "blog.hidden": { $ne: true }, "blog.source": { $ne: "microsub" } } },
|
|
42
60
|
];
|
|
43
61
|
|
|
44
62
|
if (blogId) {
|
|
45
|
-
|
|
63
|
+
regularPipeline.unshift({ $match: { blogId: new ObjectId(blogId) } });
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
if (category) {
|
|
49
|
-
|
|
67
|
+
regularPipeline.push({ $match: { "blog.category": category } });
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
const
|
|
70
|
+
const regularItems = await db.collection("blogrollItems").aggregate(regularPipeline).toArray();
|
|
71
|
+
|
|
72
|
+
// Get items from Microsub-sourced blogs
|
|
73
|
+
const microsubBlogsQuery = {
|
|
74
|
+
source: "microsub",
|
|
75
|
+
hidden: { $ne: true },
|
|
76
|
+
};
|
|
77
|
+
if (category) {
|
|
78
|
+
microsubBlogsQuery.category = category;
|
|
79
|
+
}
|
|
53
80
|
|
|
54
|
-
const
|
|
55
|
-
if (hasMore) items.pop();
|
|
81
|
+
const microsubBlogs = await db.collection("blogrollBlogs").find(microsubBlogsQuery).toArray();
|
|
56
82
|
|
|
57
|
-
|
|
83
|
+
let microsubItems = [];
|
|
84
|
+
for (const blog of microsubBlogs) {
|
|
85
|
+
if (blog.microsubFeedId) {
|
|
86
|
+
const items = await getMicrosubItemsForBlog(application, blog, 100);
|
|
87
|
+
microsubItems.push(...items.map((item) => ({ ...item, blog })));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Combine and sort all items by published date
|
|
92
|
+
const allItems = [...regularItems, ...microsubItems];
|
|
93
|
+
allItems.sort((a, b) => {
|
|
94
|
+
const dateA = a.published ? new Date(a.published) : new Date(0);
|
|
95
|
+
const dateB = b.published ? new Date(b.published) : new Date(0);
|
|
96
|
+
return dateB - dateA;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Apply pagination
|
|
100
|
+
const paginatedItems = allItems.slice(offset, offset + limit + 1);
|
|
101
|
+
const hasMore = paginatedItems.length > limit;
|
|
102
|
+
if (hasMore) paginatedItems.pop();
|
|
103
|
+
|
|
104
|
+
return { items: paginatedItems, hasMore };
|
|
58
105
|
}
|
|
59
106
|
|
|
60
107
|
/**
|
|
61
108
|
* Get items for a specific blog
|
|
109
|
+
* Handles both regular blogs (blogrollItems) and Microsub blogs (microsub_items)
|
|
62
110
|
* @param {object} application - Application instance
|
|
63
111
|
* @param {string|ObjectId} blogId - Blog ID
|
|
64
112
|
* @param {number} limit - Max items
|
|
113
|
+
* @param {object} blog - Optional blog document (to avoid extra lookup)
|
|
65
114
|
* @returns {Promise<Array>} Items
|
|
66
115
|
*/
|
|
67
|
-
export async function getItemsForBlog(application, blogId, limit = 20) {
|
|
68
|
-
const
|
|
116
|
+
export async function getItemsForBlog(application, blogId, limit = 20, blog = null) {
|
|
117
|
+
const db = application.getBlogrollDb();
|
|
69
118
|
const objectId = typeof blogId === "string" ? new ObjectId(blogId) : blogId;
|
|
70
119
|
|
|
120
|
+
// Get blog if not provided
|
|
121
|
+
if (!blog) {
|
|
122
|
+
blog = await db.collection("blogrollBlogs").findOne({ _id: objectId });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// For Microsub-sourced blogs, query microsub_items directly
|
|
126
|
+
if (blog?.source === "microsub" && blog.microsubFeedId) {
|
|
127
|
+
return getMicrosubItemsForBlog(application, blog, limit);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// For regular blogs, query blogrollItems
|
|
131
|
+
const collection = getCollection(application);
|
|
71
132
|
return collection
|
|
72
133
|
.find({ blogId: objectId })
|
|
73
134
|
.sort({ published: -1 })
|
|
@@ -76,20 +137,55 @@ export async function getItemsForBlog(application, blogId, limit = 20) {
|
|
|
76
137
|
}
|
|
77
138
|
|
|
78
139
|
/**
|
|
79
|
-
* Count items
|
|
140
|
+
* Count items (including Microsub items)
|
|
80
141
|
* @param {object} application - Application instance
|
|
81
142
|
* @param {object} options - Query options
|
|
82
143
|
* @returns {Promise<number>} Count
|
|
83
144
|
*/
|
|
84
145
|
export async function countItems(application, options = {}) {
|
|
85
|
-
const
|
|
86
|
-
const query = {};
|
|
146
|
+
const db = application.getBlogrollDb();
|
|
87
147
|
|
|
148
|
+
// Count regular items
|
|
149
|
+
const regularQuery = {};
|
|
88
150
|
if (options.blogId) {
|
|
89
|
-
|
|
151
|
+
regularQuery.blogId = new ObjectId(options.blogId);
|
|
152
|
+
}
|
|
153
|
+
const regularCount = await db.collection("blogrollItems").countDocuments(regularQuery);
|
|
154
|
+
|
|
155
|
+
// Count Microsub items for microsub-sourced blogs
|
|
156
|
+
let microsubCount = 0;
|
|
157
|
+
const itemsCollection = application.collections?.get("microsub_items");
|
|
158
|
+
|
|
159
|
+
if (itemsCollection) {
|
|
160
|
+
if (options.blogId) {
|
|
161
|
+
// Count for specific blog
|
|
162
|
+
const blog = await db.collection("blogrollBlogs").findOne({ _id: new ObjectId(options.blogId) });
|
|
163
|
+
if (blog?.source === "microsub" && blog.microsubFeedId) {
|
|
164
|
+
microsubCount = await itemsCollection.countDocuments({
|
|
165
|
+
feedId: new ObjectId(blog.microsubFeedId),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// Count all Microsub items from blogroll-associated feeds
|
|
170
|
+
const microsubBlogs = await db
|
|
171
|
+
.collection("blogrollBlogs")
|
|
172
|
+
.find({ source: "microsub", microsubFeedId: { $exists: true } })
|
|
173
|
+
.toArray();
|
|
174
|
+
|
|
175
|
+
const feedIds = microsubBlogs
|
|
176
|
+
.map((b) => b.microsubFeedId)
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.map((id) => new ObjectId(id));
|
|
179
|
+
|
|
180
|
+
if (feedIds.length > 0) {
|
|
181
|
+
microsubCount = await itemsCollection.countDocuments({
|
|
182
|
+
feedId: { $in: feedIds },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
90
186
|
}
|
|
91
187
|
|
|
92
|
-
return
|
|
188
|
+
return regularCount + microsubCount;
|
|
93
189
|
}
|
|
94
190
|
|
|
95
191
|
/**
|
package/lib/storage/sources.js
CHANGED
|
@@ -48,10 +48,13 @@ export async function createSource(application, data) {
|
|
|
48
48
|
const now = new Date();
|
|
49
49
|
|
|
50
50
|
const source = {
|
|
51
|
-
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed"
|
|
51
|
+
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub"
|
|
52
52
|
name: data.name,
|
|
53
53
|
url: data.url || null,
|
|
54
54
|
opmlContent: data.opmlContent || null,
|
|
55
|
+
// Microsub-specific fields
|
|
56
|
+
channelFilter: data.channelFilter || null,
|
|
57
|
+
categoryPrefix: data.categoryPrefix || "",
|
|
55
58
|
enabled: data.enabled !== false,
|
|
56
59
|
syncInterval: data.syncInterval || 60, // minutes
|
|
57
60
|
lastSyncAt: null,
|
|
@@ -157,7 +160,7 @@ export async function getSourcesDueForSync(application) {
|
|
|
157
160
|
return collection
|
|
158
161
|
.find({
|
|
159
162
|
enabled: true,
|
|
160
|
-
type: { $in: ["opml_url", "json_feed"] },
|
|
163
|
+
type: { $in: ["opml_url", "json_feed", "microsub"] },
|
|
161
164
|
$or: [
|
|
162
165
|
{ lastSyncAt: null },
|
|
163
166
|
{
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsub integration - sync subscriptions from Microsub channels
|
|
3
|
+
* @module sync/microsub
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: This module uses a REFERENCE-BASED approach to avoid data duplication.
|
|
6
|
+
* - Blogs from Microsub are stored with `source: "microsub"` and `microsubFeedId`
|
|
7
|
+
* - Items are NOT copied to blogrollItems - we query microsub_items directly
|
|
8
|
+
* - The blogroll API joins data from both collections as needed
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { upsertBlog, getBlogByFeedUrl } from "../storage/blogs.js";
|
|
12
|
+
import { updateSourceSyncStatus } from "../storage/sources.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sync blogs from Microsub subscriptions
|
|
16
|
+
* Creates references to Microsub feeds, NOT copies of the data
|
|
17
|
+
* @param {object} application - Application instance
|
|
18
|
+
* @param {object} source - Source document with microsub config
|
|
19
|
+
* @returns {Promise<object>} Sync result
|
|
20
|
+
*/
|
|
21
|
+
export async function syncMicrosubSource(application, source) {
|
|
22
|
+
try {
|
|
23
|
+
// Get Microsub collections via Indiekit's collection system
|
|
24
|
+
const channelsCollection = application.collections?.get("microsub_channels");
|
|
25
|
+
const feedsCollection = application.collections?.get("microsub_feeds");
|
|
26
|
+
|
|
27
|
+
if (!channelsCollection || !feedsCollection) {
|
|
28
|
+
throw new Error("Microsub collections not available. Is the Microsub plugin installed?");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Get channels (optionally filter by specific channel)
|
|
32
|
+
const channelQuery = source.channelFilter
|
|
33
|
+
? { uid: source.channelFilter }
|
|
34
|
+
: {};
|
|
35
|
+
const channels = await channelsCollection.find(channelQuery).toArray();
|
|
36
|
+
|
|
37
|
+
if (channels.length === 0) {
|
|
38
|
+
console.log("[Blogroll] No Microsub channels found");
|
|
39
|
+
await updateSourceSyncStatus(application, source._id, { success: true });
|
|
40
|
+
return { success: true, added: 0, updated: 0, total: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let added = 0;
|
|
44
|
+
let updated = 0;
|
|
45
|
+
let total = 0;
|
|
46
|
+
|
|
47
|
+
for (const channel of channels) {
|
|
48
|
+
// Get all feeds subscribed in this channel
|
|
49
|
+
const feeds = await feedsCollection.find({ channelId: channel._id }).toArray();
|
|
50
|
+
|
|
51
|
+
for (const feed of feeds) {
|
|
52
|
+
total++;
|
|
53
|
+
|
|
54
|
+
// Store REFERENCE to Microsub feed, not a copy
|
|
55
|
+
// Items will be queried from microsub_items directly
|
|
56
|
+
const blogData = {
|
|
57
|
+
title: feed.title || extractDomainFromUrl(feed.url),
|
|
58
|
+
feedUrl: feed.url,
|
|
59
|
+
siteUrl: extractSiteUrl(feed.url),
|
|
60
|
+
feedType: "rss",
|
|
61
|
+
category: source.categoryPrefix
|
|
62
|
+
? `${source.categoryPrefix}${channel.name}`
|
|
63
|
+
: channel.name,
|
|
64
|
+
// Mark as microsub source - items come from microsub_items, not blogrollItems
|
|
65
|
+
source: "microsub",
|
|
66
|
+
sourceId: source._id,
|
|
67
|
+
// Store reference IDs for joining with Microsub data
|
|
68
|
+
microsubFeedId: feed._id.toString(),
|
|
69
|
+
microsubChannelId: channel._id.toString(),
|
|
70
|
+
microsubChannelName: channel.name,
|
|
71
|
+
// Mirror status from Microsub (don't duplicate, just reference)
|
|
72
|
+
status: feed.status === "error" ? "error" : "active",
|
|
73
|
+
lastFetchAt: feed.lastFetchedAt || null,
|
|
74
|
+
photo: feed.photo || null,
|
|
75
|
+
// Flag to skip item fetching - Microsub handles this
|
|
76
|
+
skipItemFetch: true,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const result = await upsertBlog(application, blogData);
|
|
80
|
+
|
|
81
|
+
if (result.upserted) added++;
|
|
82
|
+
else if (result.modified) updated++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Update source sync status
|
|
87
|
+
await updateSourceSyncStatus(application, source._id, { success: true });
|
|
88
|
+
|
|
89
|
+
console.log(
|
|
90
|
+
`[Blogroll] Synced Microsub source "${source.name}": ${added} added, ${updated} updated, ${total} total from ${channels.length} channels (items served from Microsub)`
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return { success: true, added, updated, total };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Update source with error status
|
|
96
|
+
await updateSourceSyncStatus(application, source._id, {
|
|
97
|
+
success: false,
|
|
98
|
+
error: error.message,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.error(`[Blogroll] Microsub sync failed for "${source.name}":`, error.message);
|
|
102
|
+
return { success: false, error: error.message };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get items for a Microsub-sourced blog
|
|
108
|
+
* Queries microsub_items directly instead of blogrollItems
|
|
109
|
+
* @param {object} application - Application instance
|
|
110
|
+
* @param {object} blog - Blog with microsubFeedId
|
|
111
|
+
* @param {number} limit - Max items to return
|
|
112
|
+
* @returns {Promise<Array>} Items from Microsub
|
|
113
|
+
*/
|
|
114
|
+
export async function getMicrosubItemsForBlog(application, blog, limit = 20) {
|
|
115
|
+
if (!blog.microsubFeedId) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const itemsCollection = application.collections?.get("microsub_items");
|
|
120
|
+
if (!itemsCollection) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { ObjectId } = await import("mongodb");
|
|
125
|
+
const feedId = new ObjectId(blog.microsubFeedId);
|
|
126
|
+
|
|
127
|
+
const items = await itemsCollection
|
|
128
|
+
.find({ feedId })
|
|
129
|
+
.sort({ published: -1 })
|
|
130
|
+
.limit(limit)
|
|
131
|
+
.toArray();
|
|
132
|
+
|
|
133
|
+
// Transform Microsub item format to Blogroll format
|
|
134
|
+
return items.map((item) => ({
|
|
135
|
+
_id: item._id,
|
|
136
|
+
blogId: blog._id,
|
|
137
|
+
url: item.url,
|
|
138
|
+
title: item.name || item.url,
|
|
139
|
+
summary: item.summary || item.content?.text?.substring(0, 300),
|
|
140
|
+
published: item.published,
|
|
141
|
+
author: item.author?.name,
|
|
142
|
+
photo: item.photo?.[0] || item.featured,
|
|
143
|
+
categories: item.category || [],
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handle Microsub subscription webhook
|
|
149
|
+
* Called when a feed is subscribed/unsubscribed in Microsub
|
|
150
|
+
* @param {object} application - Application instance
|
|
151
|
+
* @param {object} data - Webhook data
|
|
152
|
+
* @param {string} data.action - "subscribe" or "unsubscribe"
|
|
153
|
+
* @param {string} data.url - Feed URL
|
|
154
|
+
* @param {string} data.channelName - Channel name
|
|
155
|
+
* @param {string} [data.title] - Feed title
|
|
156
|
+
* @returns {Promise<object>} Result
|
|
157
|
+
*/
|
|
158
|
+
export async function handleMicrosubWebhook(application, data) {
|
|
159
|
+
const { action, url, channelName, title } = data;
|
|
160
|
+
|
|
161
|
+
if (action === "subscribe") {
|
|
162
|
+
// Check if blog already exists
|
|
163
|
+
const existing = await getBlogByFeedUrl(application, url);
|
|
164
|
+
|
|
165
|
+
if (existing) {
|
|
166
|
+
// Update category if it's from microsub
|
|
167
|
+
if (existing.source === "microsub") {
|
|
168
|
+
console.log(`[Blogroll] Webhook: Feed ${url} already exists, skipping`);
|
|
169
|
+
return { ok: true, action: "skipped", reason: "already_exists" };
|
|
170
|
+
}
|
|
171
|
+
// Don't overwrite manually added blogs
|
|
172
|
+
return { ok: true, action: "skipped", reason: "manual_entry" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Add new blog
|
|
176
|
+
await upsertBlog(application, {
|
|
177
|
+
title: title || extractDomainFromUrl(url),
|
|
178
|
+
feedUrl: url,
|
|
179
|
+
siteUrl: extractSiteUrl(url),
|
|
180
|
+
feedType: "rss",
|
|
181
|
+
category: channelName || "Microsub",
|
|
182
|
+
source: "microsub-webhook",
|
|
183
|
+
status: "pending",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
console.log(`[Blogroll] Webhook: Added feed ${url} from Microsub`);
|
|
187
|
+
return { ok: true, action: "added" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (action === "unsubscribe") {
|
|
191
|
+
// Mark as inactive rather than delete (preserve history)
|
|
192
|
+
const existing = await getBlogByFeedUrl(application, url);
|
|
193
|
+
|
|
194
|
+
if (existing && existing.source?.startsWith("microsub")) {
|
|
195
|
+
// Update status to inactive
|
|
196
|
+
const db = application.getBlogrollDb();
|
|
197
|
+
await db.collection("blogrollBlogs").updateOne(
|
|
198
|
+
{ _id: existing._id },
|
|
199
|
+
{
|
|
200
|
+
$set: {
|
|
201
|
+
status: "inactive",
|
|
202
|
+
unsubscribedAt: new Date(),
|
|
203
|
+
updatedAt: new Date(),
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
console.log(`[Blogroll] Webhook: Marked feed ${url} as inactive`);
|
|
209
|
+
return { ok: true, action: "deactivated" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { ok: true, action: "skipped", reason: "not_found_or_not_microsub" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get all Microsub channels for source configuration UI
|
|
220
|
+
* @param {object} application - Application instance
|
|
221
|
+
* @returns {Promise<Array>} Array of channels
|
|
222
|
+
*/
|
|
223
|
+
export async function getMicrosubChannels(application) {
|
|
224
|
+
const channelsCollection = application.collections?.get("microsub_channels");
|
|
225
|
+
|
|
226
|
+
if (!channelsCollection) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const channels = await channelsCollection.find({}).sort({ order: 1 }).toArray();
|
|
231
|
+
|
|
232
|
+
return channels.map((ch) => ({
|
|
233
|
+
uid: ch.uid,
|
|
234
|
+
name: ch.name,
|
|
235
|
+
_id: ch._id.toString(),
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check if Microsub plugin is available
|
|
241
|
+
* @param {object} application - Application instance
|
|
242
|
+
* @returns {boolean} True if Microsub is available
|
|
243
|
+
*/
|
|
244
|
+
export function isMicrosubAvailable(application) {
|
|
245
|
+
return !!(
|
|
246
|
+
application.collections?.get("microsub_channels") &&
|
|
247
|
+
application.collections?.get("microsub_feeds")
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract domain from URL for fallback title
|
|
253
|
+
* @param {string} url - Feed URL
|
|
254
|
+
* @returns {string} Domain name
|
|
255
|
+
*/
|
|
256
|
+
function extractDomainFromUrl(url) {
|
|
257
|
+
try {
|
|
258
|
+
const parsed = new URL(url);
|
|
259
|
+
return parsed.hostname.replace(/^www\./, "");
|
|
260
|
+
} catch {
|
|
261
|
+
return url;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Extract site URL from feed URL
|
|
267
|
+
* @param {string} feedUrl - Feed URL
|
|
268
|
+
* @returns {string} Site URL
|
|
269
|
+
*/
|
|
270
|
+
function extractSiteUrl(feedUrl) {
|
|
271
|
+
try {
|
|
272
|
+
const parsed = new URL(feedUrl);
|
|
273
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
274
|
+
} catch {
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
}
|
package/lib/sync/scheduler.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getSources } from "../storage/sources.js";
|
|
|
7
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
|
+
import { syncMicrosubSource } from "./microsub.js";
|
|
10
11
|
import { syncBlogItems } from "./feed.js";
|
|
11
12
|
|
|
12
13
|
let syncInterval = null;
|
|
@@ -38,10 +39,10 @@ export async function runFullSync(application, options = {}) {
|
|
|
38
39
|
// First, clean up old items to encourage discovery
|
|
39
40
|
const deletedItems = await deleteOldItems(application, maxItemAge);
|
|
40
41
|
|
|
41
|
-
// Sync all enabled OPML
|
|
42
|
+
// Sync all enabled sources (OPML, JSON, Microsub)
|
|
42
43
|
const sources = await getSources(application);
|
|
43
44
|
const enabledSources = sources.filter(
|
|
44
|
-
(s) => s.enabled && ["opml_url", "opml_file", "json_feed"].includes(s.type)
|
|
45
|
+
(s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub"].includes(s.type)
|
|
45
46
|
);
|
|
46
47
|
|
|
47
48
|
let sourcesSuccess = 0;
|
|
@@ -49,7 +50,12 @@ export async function runFullSync(application, options = {}) {
|
|
|
49
50
|
|
|
50
51
|
for (const source of enabledSources) {
|
|
51
52
|
try {
|
|
52
|
-
|
|
53
|
+
let result;
|
|
54
|
+
if (source.type === "microsub") {
|
|
55
|
+
result = await syncMicrosubSource(application, source);
|
|
56
|
+
} else {
|
|
57
|
+
result = await syncOpmlSource(application, source);
|
|
58
|
+
}
|
|
53
59
|
if (result.success) sourcesSuccess++;
|
|
54
60
|
else sourcesFailed++;
|
|
55
61
|
} catch (error) {
|
|
@@ -58,14 +64,21 @@ export async function runFullSync(application, options = {}) {
|
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
// Sync all non-hidden blogs
|
|
67
|
+
// Sync all non-hidden blogs (skip microsub blogs - their items come from Microsub)
|
|
62
68
|
const blogs = await getBlogs(application, { includeHidden: false, limit: 1000 });
|
|
63
69
|
|
|
64
70
|
let blogsSuccess = 0;
|
|
65
71
|
let blogsFailed = 0;
|
|
72
|
+
let blogsSkipped = 0;
|
|
66
73
|
let newItems = 0;
|
|
67
74
|
|
|
68
75
|
for (const blog of blogs) {
|
|
76
|
+
// Skip microsub blogs - items are served directly from microsub_items
|
|
77
|
+
if (blog.source === "microsub" || blog.skipItemFetch) {
|
|
78
|
+
blogsSkipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
try {
|
|
70
83
|
const result = await syncBlogItems(application, blog, {
|
|
71
84
|
maxItems: maxItemsPerBlog,
|
|
@@ -84,6 +97,10 @@ export async function runFullSync(application, options = {}) {
|
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
if (blogsSkipped > 0) {
|
|
101
|
+
console.log(`[Blogroll] Skipped ${blogsSkipped} Microsub blogs (items served from Microsub)`);
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
const duration = Date.now() - startTime;
|
|
88
105
|
|
|
89
106
|
// Update sync stats in meta collection
|
package/package.json
CHANGED
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
.br-field-inline input[type="checkbox"] {
|
|
57
|
+
appearance: auto;
|
|
57
58
|
width: auto;
|
|
59
|
+
cursor: pointer;
|
|
58
60
|
}
|
|
59
61
|
</style>
|
|
60
62
|
|
|
@@ -80,6 +82,9 @@
|
|
|
80
82
|
<select id="type" name="type" required onchange="toggleTypeFields()">
|
|
81
83
|
<option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL (auto-sync)</option>
|
|
82
84
|
<option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (one-time import)</option>
|
|
85
|
+
{% if microsubAvailable %}
|
|
86
|
+
<option value="microsub" {% if source.type == 'microsub' %}selected{% endif %}>Microsub Subscriptions</option>
|
|
87
|
+
{% endif %}
|
|
83
88
|
</select>
|
|
84
89
|
<span class="br-field-hint">{{ __("blogroll.sources.form.typeHint") }}</span>
|
|
85
90
|
</div>
|
|
@@ -96,6 +101,23 @@
|
|
|
96
101
|
<span class="br-field-hint">{{ __("blogroll.sources.form.opmlContentHint") }}</span>
|
|
97
102
|
</div>
|
|
98
103
|
|
|
104
|
+
<div class="br-field" id="microsubChannelField" style="display: none;">
|
|
105
|
+
<label for="channelFilter">{{ __("blogroll.sources.form.microsubChannel") | default("Microsub Channel") }}</label>
|
|
106
|
+
<select id="channelFilter" name="channelFilter">
|
|
107
|
+
<option value="">All channels</option>
|
|
108
|
+
{% for channel in microsubChannels %}
|
|
109
|
+
<option value="{{ channel.uid }}" {% if source.channelFilter == channel.uid %}selected{% endif %}>{{ channel.name }}</option>
|
|
110
|
+
{% endfor %}
|
|
111
|
+
</select>
|
|
112
|
+
<span class="br-field-hint">{{ __("blogroll.sources.form.microsubChannelHint") | default("Sync feeds from a specific channel, or all channels") }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div class="br-field" id="categoryPrefixField" style="display: none;">
|
|
116
|
+
<label for="categoryPrefix">{{ __("blogroll.sources.form.categoryPrefix") | default("Category Prefix") }}</label>
|
|
117
|
+
<input type="text" id="categoryPrefix" name="categoryPrefix" value="{{ source.categoryPrefix if source else '' }}" placeholder="e.g., Microsub: ">
|
|
118
|
+
<span class="br-field-hint">{{ __("blogroll.sources.form.categoryPrefixHint") | default("Optional prefix for blog categories (e.g., 'Following: ')") }}</span>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
99
121
|
<div class="br-field">
|
|
100
122
|
<label for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
|
|
101
123
|
<select id="syncInterval" name="syncInterval">
|
|
@@ -126,13 +148,23 @@ function toggleTypeFields() {
|
|
|
126
148
|
const type = document.getElementById('type').value;
|
|
127
149
|
const urlField = document.getElementById('urlField');
|
|
128
150
|
const opmlContentField = document.getElementById('opmlContentField');
|
|
151
|
+
const microsubChannelField = document.getElementById('microsubChannelField');
|
|
152
|
+
const categoryPrefixField = document.getElementById('categoryPrefixField');
|
|
153
|
+
|
|
154
|
+
// Hide all type-specific fields first
|
|
155
|
+
urlField.style.display = 'none';
|
|
156
|
+
opmlContentField.style.display = 'none';
|
|
157
|
+
if (microsubChannelField) microsubChannelField.style.display = 'none';
|
|
158
|
+
if (categoryPrefixField) categoryPrefixField.style.display = 'none';
|
|
129
159
|
|
|
160
|
+
// Show fields based on type
|
|
130
161
|
if (type === 'opml_url') {
|
|
131
162
|
urlField.style.display = 'flex';
|
|
132
|
-
opmlContentField.style.display = 'none';
|
|
133
163
|
} else if (type === 'opml_file') {
|
|
134
|
-
urlField.style.display = 'none';
|
|
135
164
|
opmlContentField.style.display = 'flex';
|
|
165
|
+
} else if (type === 'microsub') {
|
|
166
|
+
if (microsubChannelField) microsubChannelField.style.display = 'flex';
|
|
167
|
+
if (categoryPrefixField) categoryPrefixField.style.display = 'flex';
|
|
136
168
|
}
|
|
137
169
|
}
|
|
138
170
|
|