feedkeeper 0.35.4 → 0.35.5

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.
@@ -16,6 +16,14 @@ export function createFeed(url, pollIntervalMinutes) {
16
16
  .run(url, pollIntervalMinutes);
17
17
  return findFeedById(Number(result.lastInsertRowid));
18
18
  }
19
+ export function seedFeedMetadata(feedId, data) {
20
+ if (!data.title && !data.siteUrl)
21
+ return;
22
+ db.prepare(`UPDATE feeds SET
23
+ title = COALESCE(NULLIF(TRIM(title), ''), ?),
24
+ site_url = COALESCE(NULLIF(TRIM(site_url), ''), ?)
25
+ WHERE id = ?`).run(data.title?.trim() || null, data.siteUrl?.trim() || null, feedId);
26
+ }
19
27
  export function subscribe(userId, feedId, label, folderId) {
20
28
  const maxPosRow = db
21
29
  .prepare("SELECT MAX(position) AS max_pos FROM subscriptions WHERE user_id = ?")
@@ -170,7 +178,7 @@ export function upsertItems(feedId, items) {
170
178
  refreshLead(Number(result.lastInsertRowid));
171
179
  }
172
180
  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);
181
+ 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
182
  const next = row.contentHtml;
175
183
  // Some publishers replace complete feed bodies with short teasers on a later poll.
176
184
  const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
@@ -185,9 +193,9 @@ export function upsertItems(feedId, items) {
185
193
  (row.link !== undefined && row.link !== previous.link) ||
186
194
  (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet) ||
187
195
  (retained !== null && retained !== previous.content_html) ||
188
- (row.publishedAt !== undefined && row.publishedAt !== null) ||
196
+ (row.publishedAt !== undefined && row.publishedAt !== null && row.publishedAt !== previous.published_at) ||
189
197
  (nextImage !== previous.image_url) ||
190
- (row.author !== undefined);
198
+ (row.author !== undefined && row.author !== null && row.author !== previous.author);
191
199
  if (changed) {
192
200
  update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
193
201
  const leadChanged = (retained !== null && retained !== previous.content_html) ||
@@ -210,15 +218,17 @@ export function upsertItems(feedId, items) {
210
218
  return insertMany(items);
211
219
  }
212
220
  export function listItemsForUser(userId, opts = {}) {
213
- // Saved articles stay visible without a subscription (e.g. after unsubscribing).
221
+ // Saved and annotated articles stay visible after unsubscribing.
214
222
  const conditions = [];
223
+ const params = [];
215
224
  if (opts.bookmarkedOnly) {
216
225
  conditions.push("b.item_id IS NOT NULL");
217
226
  }
218
227
  else {
219
- conditions.push("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
228
+ conditions.push(`(s.id IS NOT NULL OR b.item_id IS NOT NULL OR
229
+ EXISTS (SELECT 1 FROM item_notes access_note WHERE access_note.item_id = i.id AND access_note.user_id = ?))`);
230
+ params.push(userId);
220
231
  }
221
- const params = [];
222
232
  // Keep the stored item available to direct reads and sync; deliberate saves/notes override stream filtering.
223
233
  conditions.push(`(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR
224
234
  EXISTS (SELECT 1 FROM item_notes gallery_note WHERE gallery_note.item_id = i.id AND gallery_note.user_id = ?))`);
@@ -410,7 +420,9 @@ export function findFolderById(userId, id) {
410
420
  }
411
421
  export function createFolder(userId, name, iconSymbol) {
412
422
  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);
423
+ db.prepare(`INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?)
424
+ ON CONFLICT (user_id, name) DO UPDATE SET icon_symbol = excluded.icon_symbol
425
+ WHERE ? AND folders.icon_symbol IS NOT excluded.icon_symbol`).run(userId, trimmed, iconSymbol ?? null, Number(iconSymbol !== undefined));
414
426
  return findFolderByName(userId, trimmed);
415
427
  }
416
428
  export function updateFolder(userId, id, updates) {
@@ -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.5",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }