@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.57
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/assets/reader.js +408 -0
- package/index.js +37 -36
- package/lib/cache/redis.js +12 -3
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- package/lib/controllers/reader.js +0 -1562
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck view
|
|
3
|
+
* @module controllers/reader/deck
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getChannelsWithColors } from "../../storage/channels.js";
|
|
7
|
+
import { getTimelineItems } from "../../storage/items.js";
|
|
8
|
+
import { getDeckConfig, saveDeckConfig } from "../../storage/deck.js";
|
|
9
|
+
import { getUserId } from "../../utils/auth.js";
|
|
10
|
+
import { proxyItemImages } from "../../media/proxy.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deck view - TweetDeck-style columns
|
|
14
|
+
* @param {object} request - Express request
|
|
15
|
+
* @param {object} response - Express response
|
|
16
|
+
*/
|
|
17
|
+
export async function deck(request, response) {
|
|
18
|
+
const { application } = request.app.locals;
|
|
19
|
+
const userId = getUserId(request);
|
|
20
|
+
|
|
21
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
22
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
23
|
+
|
|
24
|
+
// Determine which channels to show as columns
|
|
25
|
+
let columnChannels;
|
|
26
|
+
if (deckConfig?.columns?.length > 0) {
|
|
27
|
+
// Use saved config order
|
|
28
|
+
const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
|
|
29
|
+
columnChannels = deckConfig.columns
|
|
30
|
+
.map((col) => channelMap.get(col.channelId.toString()))
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
} else {
|
|
33
|
+
// Default: all channels except notifications
|
|
34
|
+
columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch items for each column (limited to 10 per column for performance)
|
|
38
|
+
// Batch in groups of 4 to avoid overwhelming MongoDB with parallel queries
|
|
39
|
+
const proxyBaseUrl = application.url;
|
|
40
|
+
const columns = [];
|
|
41
|
+
for (let i = 0; i < columnChannels.length; i += 4) {
|
|
42
|
+
const batch = columnChannels.slice(i, i + 4);
|
|
43
|
+
const batchResults = await Promise.all(
|
|
44
|
+
batch.map(async (channel) => {
|
|
45
|
+
const result = await getTimelineItems(application, channel._id, {
|
|
46
|
+
userId,
|
|
47
|
+
limit: 10,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (proxyBaseUrl && result.items) {
|
|
51
|
+
result.items = result.items.map((item) =>
|
|
52
|
+
proxyItemImages(item, proxyBaseUrl),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
channel,
|
|
58
|
+
items: result.items,
|
|
59
|
+
paging: result.paging,
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
columns.push(...batchResults);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set view preference cookie
|
|
67
|
+
if (request.session) request.session.microsubView = "deck";
|
|
68
|
+
|
|
69
|
+
response.render("deck", {
|
|
70
|
+
title: "Deck",
|
|
71
|
+
columns,
|
|
72
|
+
baseUrl: request.baseUrl,
|
|
73
|
+
readerBaseUrl: request.baseUrl,
|
|
74
|
+
activeView: "deck",
|
|
75
|
+
breadcrumbs: [
|
|
76
|
+
{ text: "Reader", href: request.baseUrl },
|
|
77
|
+
{ text: "Deck" },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Deck settings page
|
|
84
|
+
* @param {object} request - Express request
|
|
85
|
+
* @param {object} response - Express response
|
|
86
|
+
*/
|
|
87
|
+
export async function deckSettings(request, response) {
|
|
88
|
+
const { application } = request.app.locals;
|
|
89
|
+
const userId = getUserId(request);
|
|
90
|
+
|
|
91
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
92
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
93
|
+
|
|
94
|
+
const selectedIds = deckConfig?.columns
|
|
95
|
+
? deckConfig.columns.map((col) => col.channelId.toString())
|
|
96
|
+
: channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
|
|
97
|
+
|
|
98
|
+
response.render("deck-settings", {
|
|
99
|
+
title: "Deck settings",
|
|
100
|
+
channels: channelList,
|
|
101
|
+
selectedIds,
|
|
102
|
+
baseUrl: request.baseUrl,
|
|
103
|
+
readerBaseUrl: request.baseUrl,
|
|
104
|
+
activeView: "deck",
|
|
105
|
+
breadcrumbs: [
|
|
106
|
+
{ text: "Reader", href: request.baseUrl },
|
|
107
|
+
{ text: "Deck", href: `${request.baseUrl}/deck` },
|
|
108
|
+
{ text: "Settings" },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Save deck settings
|
|
115
|
+
* @param {object} request - Express request
|
|
116
|
+
* @param {object} response - Express response
|
|
117
|
+
*/
|
|
118
|
+
export async function saveDeckSettings(request, response) {
|
|
119
|
+
const { application } = request.app.locals;
|
|
120
|
+
const userId = getUserId(request);
|
|
121
|
+
|
|
122
|
+
let { columns } = request.body;
|
|
123
|
+
if (!columns) columns = [];
|
|
124
|
+
if (!Array.isArray(columns)) columns = [columns];
|
|
125
|
+
|
|
126
|
+
await saveDeckConfig(application, userId, columns);
|
|
127
|
+
|
|
128
|
+
response.redirect(`${request.baseUrl}/deck`);
|
|
129
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed repair operations (rediscover + force refresh)
|
|
3
|
+
* @module controllers/reader/feed-repair
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { discoverAndValidateFeeds, getBestFeed } from "../../feeds/discovery.js";
|
|
7
|
+
import { refreshFeedNow } from "../../polling/scheduler.js";
|
|
8
|
+
import { getChannel } from "../../storage/channels.js";
|
|
9
|
+
import { getFeedById, updateFeed, updateFeedStatus } from "../../storage/feeds.js";
|
|
10
|
+
import { getUserId } from "../../utils/auth.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rediscover feed - run discovery on URL to find actual RSS feed
|
|
14
|
+
* @param {object} request - Express request
|
|
15
|
+
* @param {object} response - Express response
|
|
16
|
+
* @returns {Promise<void>}
|
|
17
|
+
*/
|
|
18
|
+
export async function rediscoverFeed(request, response) {
|
|
19
|
+
const { application } = request.app.locals;
|
|
20
|
+
const userId = getUserId(request);
|
|
21
|
+
const { uid, feedId } = request.params;
|
|
22
|
+
|
|
23
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
24
|
+
if (!channelDocument) {
|
|
25
|
+
return response.status(404).render("404");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const feed = await getFeedById(application, feedId);
|
|
29
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
30
|
+
return response.status(404).render("404");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Run feed discovery on the current URL
|
|
34
|
+
try {
|
|
35
|
+
const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
|
|
36
|
+
const bestFeed = getBestFeed(discoveredFeeds);
|
|
37
|
+
|
|
38
|
+
if (bestFeed && bestFeed.url !== feed.url) {
|
|
39
|
+
// Found a different (better) feed URL - update the record
|
|
40
|
+
await updateFeed(application, feedId, {
|
|
41
|
+
url: bestFeed.url,
|
|
42
|
+
title: bestFeed.title || feed.title,
|
|
43
|
+
status: "active",
|
|
44
|
+
lastError: undefined,
|
|
45
|
+
lastErrorAt: undefined,
|
|
46
|
+
consecutiveErrors: 0,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
console.info(
|
|
50
|
+
`[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Trigger immediate fetch
|
|
54
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
55
|
+
console.error(
|
|
56
|
+
`[Microsub] Error refreshing rediscovered feed:`,
|
|
57
|
+
error.message,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
} else if (bestFeed) {
|
|
61
|
+
// Same URL but valid - just reset error state and refresh
|
|
62
|
+
await updateFeedStatus(application, feedId, { success: true });
|
|
63
|
+
await updateFeed(application, feedId, {
|
|
64
|
+
status: "active",
|
|
65
|
+
lastError: undefined,
|
|
66
|
+
lastErrorAt: undefined,
|
|
67
|
+
consecutiveErrors: 0,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
71
|
+
console.error(`[Microsub] Error refreshing feed:`, error.message);
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
// No valid feed found
|
|
75
|
+
await updateFeedStatus(application, feedId, {
|
|
76
|
+
success: false,
|
|
77
|
+
error: "No valid feed found at this URL",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await updateFeedStatus(application, feedId, {
|
|
82
|
+
success: false,
|
|
83
|
+
error: error.message,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Force refresh a feed
|
|
92
|
+
* @param {object} request - Express request
|
|
93
|
+
* @param {object} response - Express response
|
|
94
|
+
* @returns {Promise<void>}
|
|
95
|
+
*/
|
|
96
|
+
export async function refreshFeed(request, response) {
|
|
97
|
+
const { application } = request.app.locals;
|
|
98
|
+
const userId = getUserId(request);
|
|
99
|
+
const { uid, feedId } = request.params;
|
|
100
|
+
|
|
101
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
102
|
+
if (!channelDocument) {
|
|
103
|
+
return response.status(404).render("404");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const feed = await getFeedById(application, feedId);
|
|
107
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
108
|
+
return response.status(404).render("404");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Trigger immediate fetch
|
|
112
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
113
|
+
console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
117
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed management
|
|
3
|
+
* @module controllers/reader/feed
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { validateFeedUrl } from "../../feeds/validator.js";
|
|
7
|
+
import { refreshFeedNow } from "../../polling/scheduler.js";
|
|
8
|
+
import { getChannel } from "../../storage/channels.js";
|
|
9
|
+
import {
|
|
10
|
+
getFeedsForChannel,
|
|
11
|
+
getFeedById,
|
|
12
|
+
createFeed,
|
|
13
|
+
deleteFeed,
|
|
14
|
+
updateFeed,
|
|
15
|
+
} from "../../storage/feeds.js";
|
|
16
|
+
import { getUserId } from "../../utils/auth.js";
|
|
17
|
+
|
|
18
|
+
export { rediscoverFeed, refreshFeed } from "./feed-repair.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* View feeds for a channel
|
|
22
|
+
* @param {object} request - Express request
|
|
23
|
+
* @param {object} response - Express response
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
export async function feeds(request, response) {
|
|
27
|
+
const { application } = request.app.locals;
|
|
28
|
+
const userId = getUserId(request);
|
|
29
|
+
const { uid } = request.params;
|
|
30
|
+
|
|
31
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
32
|
+
if (!channelDocument) {
|
|
33
|
+
return response.status(404).render("404");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const feedList = await getFeedsForChannel(application, channelDocument._id);
|
|
37
|
+
|
|
38
|
+
response.render("feeds", {
|
|
39
|
+
title: request.__("microsub.feeds.title"),
|
|
40
|
+
channel: channelDocument,
|
|
41
|
+
feeds: feedList,
|
|
42
|
+
baseUrl: request.baseUrl,
|
|
43
|
+
readerBaseUrl: request.baseUrl,
|
|
44
|
+
activeView: "channels",
|
|
45
|
+
breadcrumbs: [
|
|
46
|
+
{ text: "Reader", href: request.baseUrl },
|
|
47
|
+
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
|
48
|
+
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
49
|
+
{ text: "Feeds" },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Add feed to channel
|
|
56
|
+
* @param {object} request - Express request
|
|
57
|
+
* @param {object} response - Express response
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
60
|
+
export async function addFeed(request, response) {
|
|
61
|
+
const { application } = request.app.locals;
|
|
62
|
+
const userId = getUserId(request);
|
|
63
|
+
const { uid } = request.params;
|
|
64
|
+
const { url } = request.body;
|
|
65
|
+
|
|
66
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
67
|
+
if (!channelDocument) {
|
|
68
|
+
return response.status(404).render("404");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Create feed subscription (throws DUPLICATE_FEED if already exists)
|
|
73
|
+
const feed = await createFeed(application, {
|
|
74
|
+
channelId: channelDocument._id,
|
|
75
|
+
url,
|
|
76
|
+
title: undefined,
|
|
77
|
+
photo: undefined,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Trigger immediate fetch in background
|
|
81
|
+
refreshFeedNow(application, feed._id).catch((error) => {
|
|
82
|
+
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code === "DUPLICATE_FEED") {
|
|
88
|
+
// Re-render feeds page with error message
|
|
89
|
+
const feedList = await getFeedsForChannel(application, channelDocument._id);
|
|
90
|
+
return response.render("feeds", {
|
|
91
|
+
title: request.__("microsub.feeds.title"),
|
|
92
|
+
channel: channelDocument,
|
|
93
|
+
feeds: feedList,
|
|
94
|
+
baseUrl: request.baseUrl,
|
|
95
|
+
readerBaseUrl: request.baseUrl,
|
|
96
|
+
activeView: "channels",
|
|
97
|
+
error: `This feed already exists in channel "${error.channelName}"`,
|
|
98
|
+
breadcrumbs: [
|
|
99
|
+
{ text: "Reader", href: request.baseUrl },
|
|
100
|
+
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
|
101
|
+
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
102
|
+
{ text: "Feeds" },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove feed from channel
|
|
112
|
+
* @param {object} request - Express request
|
|
113
|
+
* @param {object} response - Express response
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
export async function removeFeed(request, response) {
|
|
117
|
+
const { application } = request.app.locals;
|
|
118
|
+
const userId = getUserId(request);
|
|
119
|
+
const { uid } = request.params;
|
|
120
|
+
const { url } = request.body;
|
|
121
|
+
|
|
122
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
123
|
+
if (!channelDocument) {
|
|
124
|
+
return response.status(404).render("404");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await deleteFeed(application, channelDocument._id, url);
|
|
128
|
+
|
|
129
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* View single feed details with status - redirects to edit form
|
|
134
|
+
* @param {object} request - Express request
|
|
135
|
+
* @param {object} response - Express response
|
|
136
|
+
* @returns {Promise<void>}
|
|
137
|
+
*/
|
|
138
|
+
export async function feedDetails(request, response) {
|
|
139
|
+
const { uid, feedId } = request.params;
|
|
140
|
+
// Redirect to edit form which shows all details
|
|
141
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Edit feed URL form
|
|
146
|
+
* @param {object} request - Express request
|
|
147
|
+
* @param {object} response - Express response
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
export async function editFeedForm(request, response) {
|
|
151
|
+
const { application } = request.app.locals;
|
|
152
|
+
const userId = getUserId(request);
|
|
153
|
+
const { uid, feedId } = request.params;
|
|
154
|
+
|
|
155
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
156
|
+
if (!channelDocument) {
|
|
157
|
+
return response.status(404).render("404");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const feed = await getFeedById(application, feedId);
|
|
161
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
162
|
+
return response.status(404).render("404");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
response.render("feed-edit", {
|
|
166
|
+
title: request.__("microsub.feeds.edit"),
|
|
167
|
+
channel: channelDocument,
|
|
168
|
+
feed,
|
|
169
|
+
baseUrl: request.baseUrl,
|
|
170
|
+
readerBaseUrl: request.baseUrl,
|
|
171
|
+
activeView: "channels",
|
|
172
|
+
breadcrumbs: [
|
|
173
|
+
{ text: "Reader", href: request.baseUrl },
|
|
174
|
+
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
|
175
|
+
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
176
|
+
{ text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
|
|
177
|
+
{ text: "Edit" },
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Update feed URL
|
|
184
|
+
* @param {object} request - Express request
|
|
185
|
+
* @param {object} response - Express response
|
|
186
|
+
* @returns {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
export async function updateFeedUrl(request, response) {
|
|
189
|
+
const { application } = request.app.locals;
|
|
190
|
+
const userId = getUserId(request);
|
|
191
|
+
const { uid, feedId } = request.params;
|
|
192
|
+
const { url: newUrl } = request.body;
|
|
193
|
+
|
|
194
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
195
|
+
if (!channelDocument) {
|
|
196
|
+
return response.status(404).render("404");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const feed = await getFeedById(application, feedId);
|
|
200
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
201
|
+
return response.status(404).render("404");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Validate the new URL is a valid feed
|
|
205
|
+
const validation = await validateFeedUrl(newUrl);
|
|
206
|
+
|
|
207
|
+
if (!validation.valid) {
|
|
208
|
+
return response.render("feed-edit", {
|
|
209
|
+
title: request.__("microsub.feeds.edit"),
|
|
210
|
+
channel: channelDocument,
|
|
211
|
+
feed,
|
|
212
|
+
error: validation.error,
|
|
213
|
+
baseUrl: request.baseUrl,
|
|
214
|
+
readerBaseUrl: request.baseUrl,
|
|
215
|
+
activeView: "channels",
|
|
216
|
+
breadcrumbs: [
|
|
217
|
+
{ text: "Reader", href: request.baseUrl },
|
|
218
|
+
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
|
219
|
+
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
220
|
+
{ text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
|
|
221
|
+
{ text: "Edit" },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Update the feed URL and reset error state
|
|
227
|
+
await updateFeed(application, feedId, {
|
|
228
|
+
url: newUrl,
|
|
229
|
+
title: validation.title || feed.title,
|
|
230
|
+
status: "active",
|
|
231
|
+
lastError: undefined,
|
|
232
|
+
lastErrorAt: undefined,
|
|
233
|
+
consecutiveErrors: 0,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Trigger immediate fetch
|
|
237
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
238
|
+
console.error(
|
|
239
|
+
`[Microsub] Error refreshing updated feed ${newUrl}:`,
|
|
240
|
+
error.message,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
245
|
+
}
|
|
246
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader controller - barrel re-export
|
|
3
|
+
* @module controllers/reader
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
index,
|
|
8
|
+
channels,
|
|
9
|
+
newChannel,
|
|
10
|
+
createChannelAction,
|
|
11
|
+
channel,
|
|
12
|
+
channelHtml,
|
|
13
|
+
settings,
|
|
14
|
+
updateSettings,
|
|
15
|
+
deleteChannelAction,
|
|
16
|
+
} from "./channel.js";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
feeds,
|
|
20
|
+
addFeed,
|
|
21
|
+
removeFeed,
|
|
22
|
+
feedDetails,
|
|
23
|
+
editFeedForm,
|
|
24
|
+
updateFeedUrl,
|
|
25
|
+
rediscoverFeed,
|
|
26
|
+
refreshFeed,
|
|
27
|
+
} from "./feed.js";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
timeline,
|
|
31
|
+
timelineHtml,
|
|
32
|
+
markAllRead,
|
|
33
|
+
markViewRead,
|
|
34
|
+
item,
|
|
35
|
+
} from "./timeline.js";
|
|
36
|
+
|
|
37
|
+
export { compose, submitCompose } from "./compose.js";
|
|
38
|
+
|
|
39
|
+
export { searchPage, searchFeeds, subscribe } from "./search.js";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
actorProfile,
|
|
43
|
+
followActorAction,
|
|
44
|
+
unfollowActorAction,
|
|
45
|
+
} from "./actor.js";
|
|
46
|
+
|
|
47
|
+
export { deck, deckSettings, saveDeckSettings } from "./deck.js";
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
index,
|
|
51
|
+
channels,
|
|
52
|
+
newChannel,
|
|
53
|
+
createChannelAction,
|
|
54
|
+
channel,
|
|
55
|
+
channelHtml,
|
|
56
|
+
settings,
|
|
57
|
+
updateSettings,
|
|
58
|
+
deleteChannelAction,
|
|
59
|
+
} from "./channel.js";
|
|
60
|
+
|
|
61
|
+
import {
|
|
62
|
+
feeds,
|
|
63
|
+
addFeed,
|
|
64
|
+
removeFeed,
|
|
65
|
+
feedDetails,
|
|
66
|
+
editFeedForm,
|
|
67
|
+
updateFeedUrl,
|
|
68
|
+
rediscoverFeed,
|
|
69
|
+
refreshFeed,
|
|
70
|
+
} from "./feed.js";
|
|
71
|
+
|
|
72
|
+
import {
|
|
73
|
+
timeline,
|
|
74
|
+
timelineHtml,
|
|
75
|
+
markAllRead,
|
|
76
|
+
markViewRead,
|
|
77
|
+
item,
|
|
78
|
+
} from "./timeline.js";
|
|
79
|
+
|
|
80
|
+
import { compose, submitCompose } from "./compose.js";
|
|
81
|
+
|
|
82
|
+
import { searchPage, searchFeeds, subscribe } from "./search.js";
|
|
83
|
+
|
|
84
|
+
import {
|
|
85
|
+
actorProfile,
|
|
86
|
+
followActorAction,
|
|
87
|
+
unfollowActorAction,
|
|
88
|
+
} from "./actor.js";
|
|
89
|
+
|
|
90
|
+
import { deck, deckSettings, saveDeckSettings } from "./deck.js";
|
|
91
|
+
|
|
92
|
+
export const readerController = {
|
|
93
|
+
index,
|
|
94
|
+
channels,
|
|
95
|
+
newChannel,
|
|
96
|
+
createChannel: createChannelAction,
|
|
97
|
+
channel,
|
|
98
|
+
channelHtml,
|
|
99
|
+
settings,
|
|
100
|
+
updateSettings,
|
|
101
|
+
markAllRead,
|
|
102
|
+
markViewRead,
|
|
103
|
+
deleteChannel: deleteChannelAction,
|
|
104
|
+
feeds,
|
|
105
|
+
addFeed,
|
|
106
|
+
removeFeed,
|
|
107
|
+
feedDetails,
|
|
108
|
+
editFeedForm,
|
|
109
|
+
updateFeedUrl,
|
|
110
|
+
rediscoverFeed,
|
|
111
|
+
refreshFeed,
|
|
112
|
+
item,
|
|
113
|
+
compose,
|
|
114
|
+
submitCompose,
|
|
115
|
+
searchPage,
|
|
116
|
+
searchFeeds,
|
|
117
|
+
subscribe,
|
|
118
|
+
actorProfile,
|
|
119
|
+
followActorAction,
|
|
120
|
+
unfollowActorAction,
|
|
121
|
+
timeline,
|
|
122
|
+
timelineHtml,
|
|
123
|
+
deck,
|
|
124
|
+
deckSettings,
|
|
125
|
+
saveDeckSettings,
|
|
126
|
+
};
|