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.
@@ -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 without a subscription (e.g. after unsubscribing).
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("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
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("INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?) ON CONFLICT (user_id, name) DO NOTHING").run(userId, trimmed, iconSymbol ?? null);
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
- /** Fix item titles, snippets, feed titles and labels stored before entity decoding existed; cheap once nothing matches. */
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("SELECT id, title, content_snippet FROM items WHERE title LIKE '%&%;%' OR content_snippet LIKE '%&%;%'")
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 = decodeEntities(row.title);
587
+ const title = row.title === null ? null : (plainTitle(row.title) ?? row.title);
571
588
  const snippet = decodeEntities(row.content_snippet);
572
- if (title !== row.title || snippet !== row.content_snippet) {
573
- update.run(title ?? null, snippet ?? null, row.id);
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
- /** A title as one line of text: entities decoded, line breaks and runs of spaces collapsed to one space, trimmed. */
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");
@@ -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}`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.35.4",
3
+ "version": "0.35.6",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }