feedkeeper 0.35.4 → 0.35.6
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/package.json +3 -3
- package/server/dist/api/auth.js +1 -1
- package/server/dist/api/feeds.js +1 -1
- package/server/dist/api/folders.js +11 -8
- package/server/dist/api/onboarding.js +2 -1
- package/server/dist/api/v1/resources.js +14 -19
- package/server/dist/auth/middleware.js +2 -2
- package/server/dist/db/migrations/0041_sync_feed_metadata.sql +16 -0
- package/server/dist/feeds/archive.js +3 -2
- package/server/dist/feeds/content/noise/lazyImages.js +7 -6
- package/server/dist/feeds/extractor.js +1 -1
- package/server/dist/feeds/feedIcon.js +4 -2
- package/server/dist/feeds/imageSources.js +69 -0
- package/server/dist/feeds/inbox.js +4 -9
- package/server/dist/feeds/opml.js +122 -27
- package/server/dist/feeds/poller.js +3 -2
- package/server/dist/feeds/redirectLinks.js +22 -0
- package/server/dist/feeds/repository.js +32 -14
- package/server/dist/feeds/text.js +7 -2
- package/server/dist/mcp/server.js +10 -0
- package/server/dist/server.js +1 -1
- package/server/dist/sync/changeLog.js +1 -0
- package/server/package.json +1 -1
- package/web/dist/assets/index-CilgZLF5.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-CYi9R_Ds.js +0 -32
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Some feeds wrap every article address in a redirect through the publisher's tracker. Google Alerts links through
|
|
3
|
+
* `https://www.google.com/url?...&url=<article>`, which is not the page to read and fails the full-text fetch. The article address
|
|
4
|
+
* inside the wrapper is what an item should point to.
|
|
5
|
+
*/
|
|
6
|
+
const GOOGLE_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/i;
|
|
7
|
+
export function unwrapRedirectLink(link) {
|
|
8
|
+
if (!link)
|
|
9
|
+
return link;
|
|
10
|
+
let url;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(link);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return link;
|
|
16
|
+
}
|
|
17
|
+
if (!GOOGLE_HOST.test(url.hostname) || url.pathname !== "/url")
|
|
18
|
+
return link;
|
|
19
|
+
const target = url.searchParams.get("url") ?? url.searchParams.get("q");
|
|
20
|
+
return target && /^https?:\/\//i.test(target) ? target : link;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=redirectLinks.js.map
|
|
@@ -3,7 +3,8 @@ import { articleText } from "./articleContent.js";
|
|
|
3
3
|
import { LEAD_VERSION } from "./contentNoise.js";
|
|
4
4
|
import { largerHeroImage } from "./heroImage.js";
|
|
5
5
|
import { refreshGalleryClassification, refreshLead } from "./lead.js";
|
|
6
|
-
import { decodeEntities } from "./text.js";
|
|
6
|
+
import { decodeEntities, plainTitle } from "./text.js";
|
|
7
|
+
import { unwrapRedirectLink } from "./redirectLinks.js";
|
|
7
8
|
export function findFeedByUrl(url) {
|
|
8
9
|
return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
|
|
9
10
|
}
|
|
@@ -16,6 +17,14 @@ export function createFeed(url, pollIntervalMinutes) {
|
|
|
16
17
|
.run(url, pollIntervalMinutes);
|
|
17
18
|
return findFeedById(Number(result.lastInsertRowid));
|
|
18
19
|
}
|
|
20
|
+
export function seedFeedMetadata(feedId, data) {
|
|
21
|
+
if (!data.title && !data.siteUrl)
|
|
22
|
+
return;
|
|
23
|
+
db.prepare(`UPDATE feeds SET
|
|
24
|
+
title = COALESCE(NULLIF(TRIM(title), ''), ?),
|
|
25
|
+
site_url = COALESCE(NULLIF(TRIM(site_url), ''), ?)
|
|
26
|
+
WHERE id = ?`).run(data.title?.trim() || null, data.siteUrl?.trim() || null, feedId);
|
|
27
|
+
}
|
|
19
28
|
export function subscribe(userId, feedId, label, folderId) {
|
|
20
29
|
const maxPosRow = db
|
|
21
30
|
.prepare("SELECT MAX(position) AS max_pos FROM subscriptions WHERE user_id = ?")
|
|
@@ -170,7 +179,7 @@ export function upsertItems(feedId, items) {
|
|
|
170
179
|
refreshLead(Number(result.lastInsertRowid));
|
|
171
180
|
}
|
|
172
181
|
else {
|
|
173
|
-
const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
182
|
+
const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, published_at, author, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
174
183
|
const next = row.contentHtml;
|
|
175
184
|
// Some publishers replace complete feed bodies with short teasers on a later poll.
|
|
176
185
|
const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
|
|
@@ -185,9 +194,9 @@ export function upsertItems(feedId, items) {
|
|
|
185
194
|
(row.link !== undefined && row.link !== previous.link) ||
|
|
186
195
|
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet) ||
|
|
187
196
|
(retained !== null && retained !== previous.content_html) ||
|
|
188
|
-
(row.publishedAt !== undefined && row.publishedAt !== null) ||
|
|
197
|
+
(row.publishedAt !== undefined && row.publishedAt !== null && row.publishedAt !== previous.published_at) ||
|
|
189
198
|
(nextImage !== previous.image_url) ||
|
|
190
|
-
(row.author !== undefined);
|
|
199
|
+
(row.author !== undefined && row.author !== null && row.author !== previous.author);
|
|
191
200
|
if (changed) {
|
|
192
201
|
update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
|
|
193
202
|
const leadChanged = (retained !== null && retained !== previous.content_html) ||
|
|
@@ -210,15 +219,17 @@ export function upsertItems(feedId, items) {
|
|
|
210
219
|
return insertMany(items);
|
|
211
220
|
}
|
|
212
221
|
export function listItemsForUser(userId, opts = {}) {
|
|
213
|
-
// Saved articles stay visible
|
|
222
|
+
// Saved and annotated articles stay visible after unsubscribing.
|
|
214
223
|
const conditions = [];
|
|
224
|
+
const params = [];
|
|
215
225
|
if (opts.bookmarkedOnly) {
|
|
216
226
|
conditions.push("b.item_id IS NOT NULL");
|
|
217
227
|
}
|
|
218
228
|
else {
|
|
219
|
-
conditions.push(
|
|
229
|
+
conditions.push(`(s.id IS NOT NULL OR b.item_id IS NOT NULL OR
|
|
230
|
+
EXISTS (SELECT 1 FROM item_notes access_note WHERE access_note.item_id = i.id AND access_note.user_id = ?))`);
|
|
231
|
+
params.push(userId);
|
|
220
232
|
}
|
|
221
|
-
const params = [];
|
|
222
233
|
// Keep the stored item available to direct reads and sync; deliberate saves/notes override stream filtering.
|
|
223
234
|
conditions.push(`(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR
|
|
224
235
|
EXISTS (SELECT 1 FROM item_notes gallery_note WHERE gallery_note.item_id = i.id AND gallery_note.user_id = ?))`);
|
|
@@ -410,7 +421,9 @@ export function findFolderById(userId, id) {
|
|
|
410
421
|
}
|
|
411
422
|
export function createFolder(userId, name, iconSymbol) {
|
|
412
423
|
const trimmed = name.trim();
|
|
413
|
-
db.prepare(
|
|
424
|
+
db.prepare(`INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?)
|
|
425
|
+
ON CONFLICT (user_id, name) DO UPDATE SET icon_symbol = excluded.icon_symbol
|
|
426
|
+
WHERE ? AND folders.icon_symbol IS NOT excluded.icon_symbol`).run(userId, trimmed, iconSymbol ?? null, Number(iconSymbol !== undefined));
|
|
414
427
|
return findFolderByName(userId, trimmed);
|
|
415
428
|
}
|
|
416
429
|
export function updateFolder(userId, id, updates) {
|
|
@@ -558,19 +571,24 @@ export function moveSubscription(userId, fromFeedId, toFeedId) {
|
|
|
558
571
|
export function updateFeedIcon(feedId, iconUrl) {
|
|
559
572
|
db.prepare("UPDATE feeds SET icon_url = ?, icon_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(iconUrl, feedId);
|
|
560
573
|
}
|
|
561
|
-
/**
|
|
574
|
+
/**
|
|
575
|
+
* Fix item titles, snippets, links, feed titles and labels stored before entity decoding, title markup removal and redirect unwrapping
|
|
576
|
+
* existed; cheap once nothing matches.
|
|
577
|
+
*/
|
|
562
578
|
export function repairEncodedText() {
|
|
563
579
|
const rows = db
|
|
564
|
-
.prepare(
|
|
580
|
+
.prepare(`SELECT id, title, content_snippet, link FROM items
|
|
581
|
+
WHERE title LIKE '%&%;%' OR title LIKE '%<%>%' OR content_snippet LIKE '%&%;%' OR link LIKE 'https://www.google.%/url?%'`)
|
|
565
582
|
.all();
|
|
566
|
-
const update = db.prepare("UPDATE items SET title = ?, content_snippet = ? WHERE id = ?");
|
|
583
|
+
const update = db.prepare("UPDATE items SET title = ?, content_snippet = ?, link = ? WHERE id = ?");
|
|
567
584
|
let fixed = 0;
|
|
568
585
|
db.transaction(() => {
|
|
569
586
|
for (const row of rows) {
|
|
570
|
-
const title =
|
|
587
|
+
const title = row.title === null ? null : (plainTitle(row.title) ?? row.title);
|
|
571
588
|
const snippet = decodeEntities(row.content_snippet);
|
|
572
|
-
|
|
573
|
-
|
|
589
|
+
const link = unwrapRedirectLink(row.link);
|
|
590
|
+
if (title !== row.title || snippet !== row.content_snippet || link !== row.link) {
|
|
591
|
+
update.run(title, snippet ?? null, link ?? null, row.id);
|
|
574
592
|
refreshLead(row.id);
|
|
575
593
|
fixed++;
|
|
576
594
|
}
|
|
@@ -15,9 +15,14 @@ function decodeOnce(text) {
|
|
|
15
15
|
return NAMED_ENTITIES[name] ?? match;
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Inline formatting that some feeds put into a title, such as the `<b>` Google Alerts wraps around the search term. Only these tags are
|
|
20
|
+
* removed, so a title that really contains an angle bracket stays as it is.
|
|
21
|
+
*/
|
|
22
|
+
const TITLE_MARKUP = /<\/?(?:b|i|u|em|strong|mark|span|small|sub|sup|font|a|br)\b[^>]*>/gi;
|
|
23
|
+
/** A title as one line of text: entities decoded, inline markup removed, line breaks and runs of spaces collapsed to one space, trimmed. */
|
|
19
24
|
export function plainTitle(text) {
|
|
20
|
-
const cleaned = decodeEntities(text)?.replace(/\s+/g, " ").trim();
|
|
25
|
+
const cleaned = decodeEntities(text)?.replace(TITLE_MARKUP, tag => (/^<br/i.test(tag) ? " " : "")).replace(/\s+/g, " ").trim();
|
|
21
26
|
return cleaned || undefined;
|
|
22
27
|
}
|
|
23
28
|
export function decodeEntities(text) {
|
|
@@ -68,6 +68,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
68
68
|
folderId: z.number().int().positive().optional().describe("Optional folder/category ID"),
|
|
69
69
|
},
|
|
70
70
|
}, async ({ url, label, folderId }) => {
|
|
71
|
+
if (!hasUserCapability(userId, "sync"))
|
|
72
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
71
73
|
try {
|
|
72
74
|
const subscription = await subscribeToFeed(userId, url, label ?? null, folderId ?? null);
|
|
73
75
|
return textResult(subscription);
|
|
@@ -94,6 +96,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
94
96
|
url: mcpUrlSchema.describe("The website URL to discover feeds from"),
|
|
95
97
|
},
|
|
96
98
|
}, async ({ url }) => {
|
|
99
|
+
if (!hasUserCapability(userId, "sync"))
|
|
100
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
97
101
|
try {
|
|
98
102
|
const feeds = await discoverFeeds(url);
|
|
99
103
|
return textResult({ feeds });
|
|
@@ -193,6 +197,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
193
197
|
badge: z.boolean().optional().describe("Count this feed's unread articles in the number on the app icon"),
|
|
194
198
|
},
|
|
195
199
|
}, async ({ feedId, url, label, pollIntervalMinutes, fullText, notify, badge }) => {
|
|
200
|
+
if (url && !hasUserCapability(userId, "sync"))
|
|
201
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
196
202
|
try {
|
|
197
203
|
let targetFeedId = feedId;
|
|
198
204
|
let previousItemsKept;
|
|
@@ -429,6 +435,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
429
435
|
opml: z.string().min(1).describe("The OPML XML content to import"),
|
|
430
436
|
},
|
|
431
437
|
}, async ({ opml }) => {
|
|
438
|
+
if (!hasUserCapability(userId, "sync"))
|
|
439
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
432
440
|
try {
|
|
433
441
|
const result = await importOpmlFeeds(userId, opml);
|
|
434
442
|
return textResult(result);
|
|
@@ -445,6 +453,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
445
453
|
feedId: z.number().int().positive().describe("The ID of the feed to refresh"),
|
|
446
454
|
},
|
|
447
455
|
}, async ({ feedId }) => {
|
|
456
|
+
if (!hasUserCapability(userId, "sync"))
|
|
457
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
448
458
|
const feed = findFeedById(feedId);
|
|
449
459
|
if (!feed || !isUserSubscribed(userId, feedId)) {
|
|
450
460
|
return errorResult("Feed not found or not subscribed");
|
package/server/dist/server.js
CHANGED
|
@@ -62,7 +62,7 @@ app.use(cors({
|
|
|
62
62
|
credentials: true,
|
|
63
63
|
}));
|
|
64
64
|
app.use(express.json({ limit: "1mb" }));
|
|
65
|
-
app.use(express.text({ type: ["application/xml", "text/xml", "text/plain"], limit: "5mb" }));
|
|
65
|
+
app.use(express.text({ type: ["application/xml", "text/xml", "text/x-opml", "application/opml+xml", "text/plain"], limit: "5mb" }));
|
|
66
66
|
app.use(cookieSession({
|
|
67
67
|
name: "feedkeeper.sid",
|
|
68
68
|
secret: config.sessionSecret,
|
|
@@ -20,6 +20,7 @@ export function serializeSubscription(subscription) {
|
|
|
20
20
|
notify: Boolean(subscription.notify),
|
|
21
21
|
badge: Boolean(subscription.badge),
|
|
22
22
|
isInbox: Boolean(subscription.is_system_inbox),
|
|
23
|
+
lastError: subscription.last_error,
|
|
23
24
|
iconHash: subscription.icon_hash ?? null,
|
|
24
25
|
iconUrl: subscription.icon_hash
|
|
25
26
|
? `/api/v1/subscriptions/${subscription.id}/icon?v=${subscription.icon_hash}`
|