feedkeeper 0.27.1 → 0.28.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -123,10 +123,11 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
123
123
  res.status(FULL_TEXT_STATUS[result.error]).json({ error: result.error, ...(result.message ? { message: result.message } : {}) });
124
124
  return;
125
125
  }
126
- const [served] = withArchivedImages([{ id: result.id, link: findItemById(result.id)?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
126
+ const currentItem = findItemById(result.id);
127
+ const [served] = withArchivedImages([{ id: result.id, link: currentItem?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
127
128
  res.json({
128
129
  id: result.id,
129
- full_content_html: readerHtml(served.full_content_html, result.title ?? findItemById(result.id)?.title, findItemById(result.id)?.author),
130
+ full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author),
130
131
  ...(result.cached ? {} : { title: result.title, byline: result.byline }),
131
132
  cached: result.cached,
132
133
  });
@@ -27,6 +27,7 @@ const FEATURES = [
27
27
  "content.prepare",
28
28
  "item.lead",
29
29
  "item.author",
30
+ "item.empty-gallery",
30
31
  "images",
31
32
  "muted-keywords",
32
33
  "opml",
@@ -11,7 +11,7 @@ import { loadFullText } from "../../feeds/fullText.js";
11
11
  import { generateOpml, importOpmlFeeds } from "../../feeds/opml.js";
12
12
  import { pollFeed, pollFeeds } from "../../feeds/poller.js";
13
13
  import { canAccessFeed, loadFeedIcon } from "../../feeds/feedIcon.js";
14
- import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
14
+ import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemsForUser, findSubscriptionForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
15
15
  import { saveToInbox, deleteInboxItem } from "../../feeds/inbox.js";
16
16
  import { changeFeedUrl, FeedError, MultipleFeedsFoundError, subscribeToFeed, unsubscribeFromFeed, updateFeedSettings } from "../../feeds/service.js";
17
17
  import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.js";
@@ -29,7 +29,7 @@ const imageLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeader
29
29
  const urlField = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
30
30
  const invalid = (res, details) => void res.status(400).json({ error: "invalid_input", ...(details ? { details } : {}) });
31
31
  function subscriptionOf(userId, id) {
32
- const subscription = listSubscriptionsForUser(userId).find((entry) => entry.id === id);
32
+ const subscription = findSubscriptionForUser(userId, id);
33
33
  return subscription ? serializeSubscriptionDetail(subscription) : null;
34
34
  }
35
35
  // ---- Account -------------------------------------------------------------
@@ -302,9 +302,9 @@ const itemsQuery = z.object({
302
302
  images: z.enum(["original", "proxy"]).default("original"),
303
303
  });
304
304
  /** Rows ready for a native client: images served by this server where it keeps copies. */
305
- function prepare(rows, req, images) {
306
- const archived = withArchivedImages(rows, origin(req), API_BASE);
307
- return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE) : archived;
305
+ function prepare(rows, req, images, withContent = true) {
306
+ const archived = withArchivedImages(rows, origin(req), API_BASE, withContent);
307
+ return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE, withContent) : archived;
308
308
  }
309
309
  const searchQuery = z.object({
310
310
  q: z.string().trim().min(1).max(200),
@@ -340,7 +340,7 @@ resourcesRouter.get("/search", requireCapability("search.fts"), (req, res) => {
340
340
  res.json({
341
341
  query: query.q,
342
342
  total,
343
- items: prepare(rows, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
343
+ items: prepare(rows, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
344
344
  });
345
345
  });
346
346
  resourcesRouter.get("/items", (req, res) => {
@@ -374,7 +374,7 @@ resourcesRouter.get("/items", (req, res) => {
374
374
  const progress = readingPositions(req.user.id, page.map((row) => row.id));
375
375
  const hasNotes = itemsWithNotes(req.user.id, page.map((row) => row.id));
376
376
  res.json({
377
- items: prepare(page, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
377
+ items: prepare(page, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
378
378
  newestCursor: page[0] ? encodeCursor(page[0]) : null,
379
379
  nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null,
380
380
  });
@@ -385,10 +385,11 @@ resourcesRouter.get("/items/bundle", (req, res) => {
385
385
  if (!parsed.success)
386
386
  return invalid(res, "ids: up to 50 comma separated article ids");
387
387
  const ids = [...new Set(parsed.data.ids.split(",").map(Number))];
388
- const rows = ids.map((id) => findItemForUser(req.user.id, id)).filter((row) => Boolean(row));
388
+ const byId = new Map(findItemsForUser(req.user.id, ids).map((row) => [row.id, row]));
389
+ const rows = ids.map((id) => byId.get(id)).filter((row) => Boolean(row));
389
390
  const progress = readingPositions(req.user.id, rows.map((row) => row.id));
390
391
  const hasNotes = itemsWithNotes(req.user.id, rows.map((row) => row.id));
391
- res.json({ items: prepare(rows, req, parsed.data.images).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
392
+ res.json({ items: prepare(rows, req, parsed.data.images, true).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
392
393
  });
393
394
  // Explicit extraction side effect; read-only devices continue to consume the shared cache.
394
395
  resourcesRouter.post("/items/prepare", requireCapability("fulltext"), async (req, res) => {
@@ -515,12 +516,13 @@ resourcesRouter.get("/notes/export", (req, res) => {
515
516
  });
516
517
  // ---- Curated Edition ("Deine Zeitung") ------------------------------------
517
518
  function editionResponse(req, edition) {
519
+ const byId = new Map(findItemsForUser(req.user.id, edition.itemIds).map((row) => [row.id, row]));
518
520
  const rows = edition.itemIds
519
- .map((id) => findItemForUser(req.user.id, id))
521
+ .map((id) => byId.get(id))
520
522
  .filter((row) => Boolean(row));
521
523
  const progress = readingPositions(req.user.id, rows.map((r) => r.id));
522
524
  const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
523
- const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original");
525
+ const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original", false);
524
526
  return {
525
527
  ...edition,
526
528
  items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
@@ -38,9 +38,16 @@ export function resolveToken(token) {
38
38
  if (stored.length !== provided.length || !timingSafeEqual(stored, provided)) {
39
39
  return null;
40
40
  }
41
- db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
41
+ const now = Date.now();
42
+ const lastTouch = tokenLastTouch.get(row.id) ?? 0;
43
+ if (!row.last_used_at || now - lastTouch >= TOKEN_TOUCH_INTERVAL_MS) {
44
+ tokenLastTouch.set(row.id, now);
45
+ db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
46
+ }
42
47
  return { userId: row.user_id, scope: row.scope, kind: row.kind, tokenId: row.id };
43
48
  }
49
+ const tokenLastTouch = new Map();
50
+ const TOKEN_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
44
51
  /** Hand-made tokens only; paired devices are listed separately. */
45
52
  export function listTokensForUser(userId) {
46
53
  return db
@@ -49,6 +56,7 @@ export function listTokensForUser(userId) {
49
56
  .map(({ token_hash, ...rest }) => rest);
50
57
  }
51
58
  export function deleteToken(userId, tokenId) {
59
+ tokenLastTouch.delete(tokenId);
52
60
  db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'api'").run(tokenId, userId);
53
61
  }
54
62
  export function listDevicesForUser(userId) {
@@ -59,6 +67,7 @@ export function listDevicesForUser(userId) {
59
67
  }
60
68
  /** Revokes a device; its token stops working immediately. Returns whether one was removed. */
61
69
  export function deleteDevice(userId, deviceId) {
70
+ tokenLastTouch.delete(deviceId);
62
71
  return db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'device'").run(deviceId, userId).changes > 0;
63
72
  }
64
73
  //# sourceMappingURL=tokens.js.map
@@ -8,7 +8,11 @@ const MIGRATIONS_DIR = join(__dirname, "migrations");
8
8
  mkdirSync(dirname(config.databasePath), { recursive: true });
9
9
  export const db = new Database(config.databasePath);
10
10
  db.pragma("journal_mode = WAL");
11
+ db.pragma("synchronous = NORMAL");
11
12
  db.pragma("foreign_keys = ON");
13
+ db.pragma("busy_timeout = 5000");
14
+ db.pragma("temp_store = MEMORY");
15
+ db.pragma("cache_size = -65536");
12
16
  function ensureMigrationsTable() {
13
17
  db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
14
18
  name TEXT PRIMARY KEY,
@@ -0,0 +1,96 @@
1
+ -- Foreign key and cascade delete indexes
2
+ CREATE INDEX IF NOT EXISTS idx_item_reads_item ON item_reads(item_id);
3
+ CREATE INDEX IF NOT EXISTS idx_item_progress_item ON item_progress(item_id);
4
+ CREATE INDEX IF NOT EXISTS idx_item_note_revisions_item ON item_note_revisions(item_id);
5
+ CREATE INDEX IF NOT EXISTS idx_full_text_jobs_user ON full_text_jobs(user_id);
6
+ CREATE INDEX IF NOT EXISTS idx_oauth_codes_user ON oauth_codes(user_id);
7
+ CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id);
8
+ CREATE INDEX IF NOT EXISTS idx_security_challenges_user ON security_challenges(user_id);
9
+
10
+ -- Housekeeping and retention cleanup indexes
11
+ CREATE INDEX IF NOT EXISTS idx_native_requests_created ON native_requests(created_at);
12
+ CREATE INDEX IF NOT EXISTS idx_applied_mutations_applied ON applied_mutations(applied_at);
13
+ CREATE INDEX IF NOT EXISTS idx_items_feed_eff_pub ON items(
14
+ feed_id,
15
+ COALESCE(julianday(published_at), julianday(created_at)) DESC,
16
+ id DESC
17
+ );
18
+
19
+ -- Item listing, cursor pagination, lead backfill, and full-text candidate indexes
20
+ CREATE INDEX IF NOT EXISTS idx_items_feed_created ON items(feed_id, created_at DESC, id DESC);
21
+ CREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON item_bookmarks(user_id, created_at DESC, item_id DESC);
22
+ CREATE INDEX IF NOT EXISTS idx_items_lead_version ON items(lead_version);
23
+ CREATE INDEX IF NOT EXISTS idx_items_fulltext_candidates ON items(feed_id, created_at DESC)
24
+ WHERE link IS NOT NULL AND full_content_html IS NULL AND extraction_status != 'feed_only';
25
+
26
+ -- Avoid rebuilding FTS5 entries when non-indexed item columns change during polling or extraction
27
+ DROP TRIGGER IF EXISTS trg_items_fts_update;
28
+ CREATE TRIGGER trg_items_fts_update AFTER UPDATE ON items
29
+ WHEN OLD.title IS NOT NEW.title
30
+ OR OLD.content_snippet IS NOT NEW.content_snippet
31
+ OR OLD.content_html IS NOT NEW.content_html
32
+ OR OLD.full_content_html IS NOT NEW.full_content_html
33
+ BEGIN
34
+ INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
35
+ VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
36
+ INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
37
+ VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
38
+ END;
39
+
40
+ -- Monotonic per-user sync sequence high-water mark across change log pruning
41
+ ALTER TABLE sync_state ADD COLUMN last_seq INTEGER NOT NULL DEFAULT 0;
42
+ UPDATE sync_state
43
+ SET last_seq = MAX(
44
+ pruned_through,
45
+ COALESCE((SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)
46
+ );
47
+
48
+ DROP TRIGGER IF EXISTS trg_changes_seq_insert;
49
+ CREATE TRIGGER trg_changes_seq_insert
50
+ AFTER INSERT ON changes
51
+ WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
52
+ BEGIN
53
+ UPDATE changes
54
+ SET seq = MAX(
55
+ COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
56
+ COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
57
+ ) + 1
58
+ WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
59
+ END;
60
+
61
+ DROP TRIGGER IF EXISTS trg_changes_seq_update;
62
+ CREATE TRIGGER trg_changes_seq_update
63
+ AFTER UPDATE OF seq ON changes
64
+ WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
65
+ BEGIN
66
+ UPDATE changes
67
+ SET seq = MAX(
68
+ COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
69
+ COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
70
+ ) + 1
71
+ WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
72
+ END;
73
+
74
+ -- Record old feed deletion when a subscription moves to a different feed
75
+ DROP TRIGGER IF EXISTS trg_changes_subscription_move;
76
+ CREATE TRIGGER trg_changes_subscription_move
77
+ AFTER UPDATE OF feed_id ON subscriptions
78
+ WHEN OLD.feed_id IS NOT NEW.feed_id
79
+ BEGIN
80
+ INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
81
+ VALUES (OLD.user_id, 'subscription', OLD.feed_id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
82
+ ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
83
+ END;
84
+
85
+ -- Notify subscribers when a feed's URL or poll interval is updated in place
86
+ DROP TRIGGER IF EXISTS trg_changes_feed_update;
87
+ CREATE TRIGGER trg_changes_feed_update
88
+ AFTER UPDATE OF url, poll_interval_minutes ON feeds
89
+ WHEN OLD.url IS NOT NEW.url OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
90
+ BEGIN
91
+ INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
92
+ SELECT s.user_id, 'subscription', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
93
+ FROM subscriptions s
94
+ WHERE s.feed_id = NEW.id
95
+ ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
96
+ END;
@@ -0,0 +1,10 @@
1
+ -- Classification is shared publisher metadata, not a deletion or a per-user read state.
2
+ ALTER TABLE items ADD COLUMN is_empty_gallery INTEGER NOT NULL DEFAULT 0 CHECK (is_empty_gallery IN (0, 1));
3
+ -- Reuse the bounded metadata backfill; existing content and reading history stay intact.
4
+ UPDATE items SET lead_version = NULL;
5
+
6
+ CREATE TRIGGER trg_gallery_revision AFTER UPDATE OF is_empty_gallery ON items
7
+ WHEN OLD.is_empty_gallery IS NOT NEW.is_empty_gallery
8
+ BEGIN
9
+ UPDATE items SET content_revision = content_revision + 1 WHERE id = NEW.id;
10
+ END;
@@ -22,6 +22,7 @@ const archiveWrites = new Set();
22
22
  async function storeArchivedImage(destination, image) {
23
23
  const temporary = `${destination}.tmp`;
24
24
  archiveWrites.add(temporary);
25
+ archiveWrites.add(destination);
25
26
  try {
26
27
  await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
27
28
  throw error; });
@@ -164,13 +165,18 @@ export async function archiveItem(itemId) {
164
165
  continue;
165
166
  const fileName = `${itemId}-${createHash("sha256").update(url).digest("hex").slice(0, 16)}.${type.ext}`;
166
167
  const destination = join(archiveDir(), fileName);
167
- await storeArchivedImage(destination, image);
168
- // The last bookmark or article may have disappeared while the image was loading.
169
- if (!hasBookmark(itemId) || !findItemById(itemId)) {
170
- unlinkSync(destination);
171
- continue;
168
+ try {
169
+ await storeArchivedImage(destination, image);
170
+ // The last bookmark or article may have disappeared while the image was loading.
171
+ if (!hasBookmark(itemId) || !findItemById(itemId)) {
172
+ unlinkSync(destination);
173
+ continue;
174
+ }
175
+ insert.run(itemId, url, fileName, type.mime, buffer.length);
176
+ }
177
+ finally {
178
+ archiveWrites.delete(destination);
172
179
  }
173
- insert.run(itemId, url, fileName, type.mime, buffer.length);
174
180
  }
175
181
  catch (error) {
176
182
  // Leave the durable bookmark pending so startup can finish it later.
@@ -276,7 +282,7 @@ export function findArchivedImage(userId, imageId) {
276
282
  return { path: join(archiveDir(), row.file_name), mime: row.mime_type };
277
283
  }
278
284
  /** Swap image URLs of archived articles for their local copies before sending them to the browser. */
279
- export function withArchivedImages(items, origin, apiBase = "/api") {
285
+ export function withArchivedImages(items, origin, apiBase = "/api", withContent = true) {
280
286
  const archived = archivedImagesFor(items.map((item) => item.id));
281
287
  return items.map((item) => {
282
288
  const images = archived.get(item.id);
@@ -286,8 +292,8 @@ export function withArchivedImages(items, origin, apiBase = "/api") {
286
292
  return {
287
293
  ...item,
288
294
  image_url: hero && images.has(hero) ? archivedImageUrl(origin, images.get(hero), apiBase) : item.image_url,
289
- content_html: item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
290
- full_content_html: item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
295
+ content_html: withContent && item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
296
+ full_content_html: withContent && item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
291
297
  };
292
298
  });
293
299
  }
@@ -303,7 +309,7 @@ function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
303
309
  * For offline copies: point the first images of articles that are not archived at an endpoint
304
310
  * of this server, so the browser can store them without cross-origin requests.
305
311
  */
306
- export function withProxiedImages(items, origin, apiBase = "/api") {
312
+ export function withProxiedImages(items, origin, apiBase = "/api", withContent = true) {
307
313
  const own = `${origin.replace(/\/$/, "")}/api/`;
308
314
  return items.map((item) => {
309
315
  const urls = new Set(itemImageUrls(item).filter((url) => !url.startsWith(own)));
@@ -314,20 +320,26 @@ export function withProxiedImages(items, origin, apiBase = "/api") {
314
320
  return {
315
321
  ...item,
316
322
  image_url: hero && urls.has(hero) ? proxiedImageUrl(origin, item.id, hero, apiBase) : item.image_url,
317
- content_html: item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
318
- full_content_html: item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
323
+ content_html: withContent && item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
324
+ full_content_html: withContent && item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
319
325
  };
320
326
  });
321
327
  }
322
328
  /** Download one of an article's own images for the offline copy, or null when it is not one of them. */
323
329
  export async function fetchItemImage(userId, itemId, imageUrl) {
324
330
  const item = findItemForUser(userId, itemId);
325
- if (!item || !itemImageUrls(item).includes(imageUrl))
331
+ if (!item)
332
+ return "forbidden";
333
+ const isMatch = (it) => {
334
+ const hero = it.image_url ? resolveImageUrl(it.image_url, it.link) : null;
335
+ return hero === imageUrl || itemImageUrls(it).includes(imageUrl);
336
+ };
337
+ if (!isMatch(item))
326
338
  return "forbidden";
327
339
  const image = await sharedImage(item, imageUrl);
328
340
  // Access or the article's image list can change while a shared download is running.
329
341
  const current = findItemForUser(userId, itemId);
330
- if (!current || !itemImageUrls(current).includes(imageUrl))
342
+ if (!current || !isMatch(current))
331
343
  return "forbidden";
332
344
  return image;
333
345
  }
@@ -36,7 +36,7 @@ export function readerHtml(html, title, author) {
36
36
  export function contentMetadata(item) {
37
37
  const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
38
38
  return { revision: item.content_revision, source, status: item.extraction_status,
39
- attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
39
+ attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at, isEmptyGallery: Boolean(item.is_empty_gallery) };
40
40
  }
41
41
  export function sourceText(item) {
42
42
  return `${decodeEntities(item.title) ?? ""}\n${derivedText(item.full_content_html || item.content_html, item.title) || cleanSnippet(decodeEntities(item.content_snippet), item.title) || ""}`.trim();
@@ -91,8 +91,8 @@ export function runCleanup(custom) {
91
91
  WHERE i.id NOT IN (SELECT item_id FROM item_bookmarks)
92
92
  AND i.id NOT IN (SELECT item_id FROM item_notes)
93
93
  AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
94
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
95
- WHERE selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed')
94
+ AND i.id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
95
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')
96
96
  AND i.id IN (SELECT item_id FROM item_reads)
97
97
  AND NOT EXISTS (
98
98
  SELECT 1 FROM subscriptions s
@@ -109,8 +109,8 @@ export function runCleanup(custom) {
109
109
  WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
110
110
  AND id NOT IN (SELECT item_id FROM item_notes)
111
111
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
112
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
- WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
112
+ AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
113
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')
114
114
  AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
115
115
  deletedOldItems = res.changes;
116
116
  }
@@ -120,8 +120,8 @@ export function runCleanup(custom) {
120
120
  WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
121
121
  AND id NOT IN (SELECT item_id FROM item_notes)
122
122
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
123
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
124
- WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
123
+ AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
124
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')
125
125
  AND id IN (
126
126
  SELECT id FROM (
127
127
  SELECT id, ROW_NUMBER() OVER (
@@ -139,11 +139,14 @@ export function runCleanup(custom) {
139
139
  pruneArchive();
140
140
  pruneChangeLog();
141
141
  const totalDeleted = deletedReadItems + deletedOldItems + deletedPerFeedExcess;
142
- // Optimize query planning; rebuild storage only when this run freed rows.
142
+ // Optimize query planning; rebuild storage only when this run freed substantial pages.
143
143
  db.exec("PRAGMA optimize;");
144
144
  if (totalDeleted > 0) {
145
145
  try {
146
- db.exec("VACUUM;");
146
+ const freelist = db.pragma("freelist_count", { simple: true }) ?? 0;
147
+ if (freelist > 500) {
148
+ db.exec("VACUUM;");
149
+ }
147
150
  }
148
151
  catch (err) {
149
152
  console.warn("[cleanup] VACUUM skipped or could not run:", err);
@@ -139,6 +139,7 @@ function removeReaderNoise(root, title, stats, author) {
139
139
  const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
140
140
  let leading = 0;
141
141
  let reachedText = false;
142
+ let pastStart = false;
142
143
  for (const block of leafBlocks(root)) {
143
144
  const text = flat(block.textContent);
144
145
  if (!text)
@@ -161,23 +162,29 @@ function removeReaderNoise(root, title, stats, author) {
161
162
  }
162
163
  }
163
164
  // Near the start, a line that is only a date or a reading time repeats what the reader shows in its own head.
164
- if (text.length <= 60 && charsBefore(root, block) <= 800) {
165
- if (DATE_ONLY.test(text)) {
166
- drop(block, "dateline");
167
- continue;
168
- }
169
- if (READING_TIME_ONLY.test(text)) {
170
- drop(block, "reading-time");
171
- continue;
165
+ if (!pastStart && text.length <= 60) {
166
+ const before = charsBefore(root, block);
167
+ if (before > 800) {
168
+ pastStart = true;
172
169
  }
173
- // When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
174
- if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
175
- drop(block, "byline");
176
- continue;
177
- }
178
- if (BYLINE.test(text) && stripBylineDate(block)) {
179
- stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
180
- changed = true;
170
+ else {
171
+ if (DATE_ONLY.test(text)) {
172
+ drop(block, "dateline");
173
+ continue;
174
+ }
175
+ if (READING_TIME_ONLY.test(text)) {
176
+ drop(block, "reading-time");
177
+ continue;
178
+ }
179
+ // When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
180
+ if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
181
+ drop(block, "byline");
182
+ continue;
183
+ }
184
+ if (BYLINE.test(text) && stripBylineDate(block)) {
185
+ stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
186
+ changed = true;
187
+ }
181
188
  }
182
189
  }
183
190
  if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
@@ -196,7 +203,7 @@ function removeReaderNoise(root, title, stats, author) {
196
203
  drop(block, "player");
197
204
  continue;
198
205
  }
199
- if (text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
206
+ if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
200
207
  const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
201
208
  if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
202
209
  drop(block, "dateline");
@@ -151,7 +151,7 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSign
151
151
  }
152
152
  chunks.push(value);
153
153
  }
154
- const html = new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
154
+ const html = new TextDecoder().decode(Buffer.concat(chunks, total));
155
155
  return { html, url: safeUrl.toString() };
156
156
  }
157
157
  return null;
@@ -169,14 +169,12 @@ export async function extractArticle(rawUrl, fetchImpl = defaultFetch, priority
169
169
  }, priority);
170
170
  }
171
171
  async function extractArticleContent(rawUrl, fetchImpl, signal) {
172
- signal.throwIfAborted();
173
- const safeUrl = await assertPublicHttpUrl(rawUrl);
174
172
  signal.throwIfAborted();
175
173
  const dispatcher = createPublicDispatcher();
176
174
  const controller = new AbortController();
177
175
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
178
176
  try {
179
- const fetched = await fetchArticleHtml(safeUrl.toString(), AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
177
+ const fetched = await fetchArticleHtml(rawUrl, AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
180
178
  if (!fetched)
181
179
  return { status: "failed" };
182
180
  const cleanedHtml = stripHeavyTags(fetched.html);
@@ -1,4 +1,5 @@
1
1
  import { isCachedConsentSnippet } from "./extractor.js";
2
+ import { refreshLead } from "./lead.js";
2
3
  import { db } from "../db/index.js";
3
4
  import { hasUserCapability } from "../auth/capabilities.js";
4
5
  import { loadFullText, executeFullText } from "./fullText.js";
@@ -229,6 +230,8 @@ function pump() {
229
230
  if (timer)
230
231
  clearTimeout(timer);
231
232
  const pool = configureFullTextPool();
233
+ if (active.size >= pool.limits.concurrency)
234
+ return;
232
235
  if (seedTimer && db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get().n < pool.limits.concurrency * 2)
233
236
  scheduleFullTexts();
234
237
  const jobs = db.prepare(`SELECT j.*, i.link FROM full_text_jobs j JOIN items i ON i.id=j.item_id
@@ -258,8 +261,10 @@ function pump() {
258
261
  const work = execute(job, host).finally(() => { active.delete(job.item_id); wake(); });
259
262
  active.set(job.item_id, work);
260
263
  }
261
- if (db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get().n) {
262
- timer = setTimeout(wake, 250);
264
+ const nextJob = db.prepare("SELECT MIN(available_at) AS next_at FROM full_text_jobs").get();
265
+ if (nextJob?.next_at) {
266
+ const delay = Math.max(250, Math.min(30_000, Date.parse(nextJob.next_at) - Date.now()));
267
+ timer = setTimeout(wake, delay);
263
268
  timer.unref();
264
269
  }
265
270
  }
@@ -272,7 +277,7 @@ async function execute(job, host) {
272
277
  attemptErrors++;
273
278
  errorCounts[result.error] = (errorCounts[result.error] ?? 0) + 1;
274
279
  }
275
- if (!result.ok && result.error === "source_busy") {
280
+ if (!result.ok && result.error === "source_busy" && !result.deferred) {
276
281
  throttled++;
277
282
  deferFullTextHost(result.host ?? host, result.retryAt);
278
283
  }
@@ -340,6 +345,7 @@ export function startFullTextScheduler() {
340
345
  for (const item of cached)
341
346
  if (isCachedConsentSnippet(item.full_content_html)) {
342
347
  db.prepare("UPDATE items SET full_content_html=NULL,extraction_status='pending',extraction_retry_at=NULL WHERE id=?").run(item.id);
348
+ refreshLead(item.id);
343
349
  }
344
350
  })();
345
351
  tick();
@@ -0,0 +1,33 @@
1
+ import { JSDOM } from "jsdom";
2
+ const GALLERY_COMPONENTS = new Set([
3
+ "bilder", "bildergalerie", "bildergalerien", "fotogalerie", "fotogalerien", "fotostrecke", "fotostrecken",
4
+ "galerie", "galerien", "gallery", "galleries", "photogallery", "photogalleries", "slideshow", "slideshows",
5
+ "photos", "pictures", "images",
6
+ ]);
7
+ const PLACEHOLDERS = new Set([
8
+ "", "mehr", "weiterlesen", "mehr lesen", "read more", "more", "continue reading",
9
+ "zur galerie", "zur bildergalerie", "view gallery", "続きを読む", "もっと読む",
10
+ ]);
11
+ function isPlaceholder(source) {
12
+ const fragment = JSDOM.fragment(source ?? "");
13
+ fragment.querySelectorAll("script, style, noscript").forEach(node => node.remove());
14
+ fragment.querySelectorAll("p, div, br, li, h1, h2, h3, blockquote, figcaption").forEach(node => node.append(" "));
15
+ const normalized = (fragment.textContent ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
16
+ return PLACEHOLDERS.has(normalized);
17
+ }
18
+ /** A gallery path alone is not enough: even one editorial sentence or caption keeps the article visible. */
19
+ export function isEmptyGallery(item) {
20
+ if (!item.link)
21
+ return false;
22
+ let path;
23
+ try {
24
+ path = decodeURIComponent(new URL(item.link).pathname).toLowerCase();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ if (!path.split(/[^\p{L}\p{N}]+/u).some(component => GALLERY_COMPONENTS.has(component)))
30
+ return false;
31
+ return isPlaceholder(item.content_snippet) && isPlaceholder(item.full_content_html || item.content_html);
32
+ }
33
+ //# sourceMappingURL=galleryContent.js.map
@@ -36,6 +36,16 @@ export class ImageCache {
36
36
  if (this.stopping)
37
37
  throw new ImageCacheBusyError();
38
38
  const key = createHash("sha256").update(`${scope}\0${url}`).digest("hex");
39
+ const missing = this.negative.get(key);
40
+ if (missing) {
41
+ if (missing.expires > this.now()) {
42
+ this.counters.negativeHits++;
43
+ if (missing.result === "unsupported")
44
+ return "unsupported";
45
+ throw new FeedHttpError(missing.result, undefined, "");
46
+ }
47
+ this.negative.delete(key);
48
+ }
39
49
  const shared = this.running.get(key);
40
50
  if (shared) {
41
51
  this.counters.shared++;