feedkeeper 0.26.1 → 0.26.3

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.26.1",
3
+ "version": "0.26.3",
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",
@@ -46,7 +46,7 @@ itemsRouter.get("/", (req, res) => {
46
46
  const archived = withArchivedImages(listItemsForUser(req.user.id, parsed.data), origin);
47
47
  // Offline copies also get the images of articles that are not archived, through this server.
48
48
  const items = parsed.data.offline ? withProxiedImages(archived, origin) : archived;
49
- res.json(items.map((item) => ({
49
+ res.json(items.map(({ content_lead: _lead, lead_version: _leadVersion, ...item }) => ({
50
50
  ...item,
51
51
  content_snippet: cleanSnippet(item.content_snippet, item.title),
52
52
  content_html: readerHtml(item.content_html, item.title),
@@ -34,7 +34,6 @@ const FEATURES = [
34
34
  "folder-icons",
35
35
  "notes",
36
36
  "edition",
37
- "edition.automatic",
38
37
  "edition.revisions",
39
38
  "native-preferences",
40
39
  "native-preferences.edition-size",
@@ -18,7 +18,7 @@ import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.j
18
18
  import { buildOverview } from "../../mcp/digest.js";
19
19
  import { decodeCursor, encodeCursor } from "../../mcp/items.js";
20
20
  import { readingPositions, serializeItem, serializeSubscriptionDetail } from "./serialize.js";
21
- import { dismissEdition, editionCandidates, generateEdition, generateEditionSchema, getEdition, getEditionState } from "../../native/editions.js";
21
+ import { dismissEdition, editionCandidates, ensureEditionSchema, getEdition, getEditionState } from "../../native/editions.js";
22
22
  import { getNativePreferences, patchNativePreferences } from "../../native/preferences.js";
23
23
  import { NativeError } from "../../native/requests.js";
24
24
  import { capabilityDenial, hasUserCapability, requireCapability } from "../../auth/capabilities.js";
@@ -520,7 +520,7 @@ function editionResponse(req, edition) {
520
520
  .filter((row) => Boolean(row));
521
521
  const progress = readingPositions(req.user.id, rows.map((r) => r.id));
522
522
  const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
523
- const prepared = prepare(rows, req, "original");
523
+ const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original");
524
524
  return {
525
525
  ...edition,
526
526
  items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
@@ -563,14 +563,19 @@ resourcesRouter.get("/edition/candidates", requireCapability("editions"), (req,
563
563
  res.setHeader("Cache-Control", "private, no-store");
564
564
  res.json({ preferences: getNativePreferences(req.user.id), current: getEditionState(req.user.id), candidates: editionCandidates(req.user.id, new Date(), parsed.data.limit) });
565
565
  });
566
+ // Older clients may still ensure an edition before reading. Keep that request read-only.
566
567
  resourcesRouter.post("/edition/generate", requireCapability("editions"), (req, res) => {
567
- const parsed = generateEditionSchema.safeParse(req.body);
568
+ const parsed = ensureEditionSchema.safeParse(req.body);
568
569
  if (!parsed.success)
569
570
  return invalid(res, parsed.error.flatten());
570
571
  nativeAction(res, () => {
571
- const edition = generateEdition(req.user.id, parsed.data);
572
+ const current = getEditionState(req.user.id);
573
+ if (parsed.data.expectedRevision !== undefined && parsed.data.expectedRevision !== current.revision) {
574
+ throw new NativeError("revision_conflict", 409, current);
575
+ }
576
+ const edition = getEdition(req.user.id);
572
577
  if (!edition)
573
- return void res.status(404).json({ error: "no_edition", current: getEditionState(req.user.id) });
578
+ return void res.status(404).json({ error: "no_edition", current });
574
579
  res.json(editionResponse(req, edition));
575
580
  });
576
581
  });
@@ -0,0 +1,5 @@
1
+ -- Retire legacy automatic slots without dismissing the current curation period.
2
+ -- Keep the revision watermark so a late publication cannot silently overwrite newer state.
3
+ UPDATE editions SET status = 'expired', revision = revision + 1,
4
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
5
+ WHERE source = 'automatic' AND status = 'active';
@@ -92,7 +92,7 @@ export function runCleanup(custom) {
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
94
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
95
- WHERE selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
95
+ WHERE selected.value = i.id AND 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
@@ -110,7 +110,7 @@ export function runCleanup(custom) {
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
112
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
- WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
113
+ WHERE selected.value = items.id AND 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
  }
@@ -121,7 +121,7 @@ export function runCleanup(custom) {
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
123
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
124
- WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
124
+ WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
125
125
  AND id IN (
126
126
  SELECT id FROM (
127
127
  SELECT id, ROW_NUMBER() OVER (
@@ -76,12 +76,28 @@ function removeReaderNoise(root, title, stats) {
76
76
  const headline = normalized(title ?? "");
77
77
  const seen = new Set();
78
78
  let leading = 0;
79
+ let reachedText = false;
79
80
  for (const block of leafBlocks(root)) {
80
81
  const text = flat(block.textContent);
81
82
  if (!text)
82
83
  continue;
83
84
  const position = leading++;
84
85
  const heading = /^H[1-3]$/.test(block.tagName);
86
+ // Before the first real paragraph: a standalone date and a section chip repeat what the page head says.
87
+ if (!reachedText && text.length >= 80)
88
+ reachedText = true;
89
+ if (!reachedText && position < 12) {
90
+ const anchorsInBlock = block.querySelectorAll("a");
91
+ const chip = anchorsInBlock.length === 1 && flat(anchorsInBlock[0].textContent) === text && text.length <= 30 && text.split(" ").length <= 3;
92
+ if (chip) {
93
+ drop(block, "section-chip");
94
+ continue;
95
+ }
96
+ if (/^(\d{1,2}\.\s?\d{1,2}\.\s?\d{2,4}|[A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})([,\s•·|-]+\d{1,2}[:.]\d{2}( Uhr)?)?$/.test(text)) {
97
+ drop(block, "dateline");
98
+ continue;
99
+ }
100
+ }
85
101
  if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
86
102
  drop(block, "title-repeat");
87
103
  continue;
@@ -147,6 +163,14 @@ const PRESENTATIONAL_TAGS = new Set(["FONT", "BIG", "SMALL", "CENTER", "TT", "U"
147
163
  const PRESENTATIONAL_ATTRIBUTES = ["style", "class", "color", "face", "size", "bgcolor", "align", "valign", "border", "cellpadding",
148
164
  "cellspacing", "background", "hspace", "vspace", "nowrap"];
149
165
  const SIZED_TAGS = new Set(["IMG", "VIDEO", "CANVAS", "IFRAME", "EMBED", "OBJECT"]);
166
+ // Tracking and scripting hooks no client uses. Lazy-load sources stay: they may be an image's only address.
167
+ const LAZY_SOURCES = new Set(["data-src", "data-srcset", "data-lazy-src", "data-original"]);
168
+ const INERT_ATTRIBUTES = new Set(["role", "tabindex", "itemprop", "itemscope", "itemtype", "itemid", "draggable", "contenteditable"]);
169
+ function isInertAttribute(name) {
170
+ if (LAZY_SOURCES.has(name))
171
+ return false;
172
+ return name.startsWith("data-") || name.startsWith("aria-") || name.startsWith("on") || INERT_ATTRIBUTES.has(name);
173
+ }
150
174
  const EMPHASIS_EXEMPT = "h1, h2, h3, h4, h5, h6, th, strong, b";
151
175
  function wrapChildren(element, tag) {
152
176
  const wrapper = element.ownerDocument.createElement(tag);
@@ -174,6 +198,12 @@ function removeTypography(root, stats) {
174
198
  changed = true;
175
199
  }
176
200
  }
201
+ for (const name of element.getAttributeNames()) {
202
+ if (isInertAttribute(name)) {
203
+ element.removeAttribute(name);
204
+ changed = true;
205
+ }
206
+ }
177
207
  if (!SIZED_TAGS.has(element.tagName)) {
178
208
  for (const name of ["width", "height"]) {
179
209
  if (element.hasAttribute(name)) {
@@ -187,6 +217,14 @@ function removeTypography(root, stats) {
187
217
  if (plain && italic && !element.closest("em, i"))
188
218
  wrapChildren(element, "em");
189
219
  const anchorTarget = element.id && targets.has(element.id);
220
+ if (element.id && !anchorTarget) {
221
+ element.removeAttribute("id");
222
+ changed = true;
223
+ }
224
+ if (element.tagName === "A" && element.hasAttribute("name") && !targets.has(element.getAttribute("name") ?? "")) {
225
+ element.removeAttribute("name");
226
+ changed = true;
227
+ }
190
228
  if (PRESENTATIONAL_TAGS.has(element.tagName) && !anchorTarget) {
191
229
  element.replaceWith(...element.childNodes);
192
230
  changed = true;
@@ -384,7 +422,7 @@ const SNIPPET_INLINE_PATTERNS = [
384
422
  export function cleanSnippet(snippet, title) {
385
423
  if (snippet === null || snippet === undefined)
386
424
  return null;
387
- let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t ]+/g, " ");
425
+ let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t\u00a0]+/g, " ");
388
426
  for (const pattern of SNIPPET_INLINE_PATTERNS)
389
427
  text = text.replace(pattern, "");
390
428
  const headline = normalized(title ?? "");
@@ -43,7 +43,7 @@ export function removeFeedIfUnused(feedId) {
43
43
  return;
44
44
  db.prepare(`DELETE FROM items WHERE feed_id = ? AND id NOT IN (SELECT item_id FROM item_bookmarks) AND id NOT IN (SELECT item_id FROM item_notes)
45
45
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
46
- WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))`).run(feedId);
46
+ WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')`).run(feedId);
47
47
  db.prepare("DELETE FROM feeds WHERE id = ? AND is_system_inbox = 0 AND NOT EXISTS (SELECT 1 FROM items WHERE feed_id = ?)").run(feedId, feedId);
48
48
  }
49
49
  /** Feeds kept only for saved articles or annotated articles whose last bookmark and note are gone. */
@@ -54,7 +54,7 @@ export function removeUnusedFeeds() {
54
54
  AND NOT EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = feeds.id)
55
55
  AND NOT EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = feeds.id)
56
56
  AND NOT EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected
57
- WHERE i.feed_id = feeds.id AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))`).run().changes;
57
+ WHERE i.feed_id = feeds.id AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed')`).run().changes;
58
58
  }
59
59
  /** Subscribers can open every item of their feeds; anyone can open what they saved or added a note to. */
60
60
  export function canAccessItem(userId, itemId) {
@@ -64,7 +64,7 @@ export function canAccessItem(userId, itemId) {
64
64
  OR EXISTS (SELECT 1 FROM item_bookmarks b WHERE b.item_id = i.id AND b.user_id = ?)
65
65
  OR EXISTS (SELECT 1 FROM item_notes n WHERE n.item_id = i.id AND n.user_id = ?)
66
66
  OR EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
67
- AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')))`).get(itemId, userId, userId, userId, userId));
67
+ AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(itemId, userId, userId, userId, userId));
68
68
  }
69
69
  export function listSubscriptionsForUser(userId) {
70
70
  return db
@@ -292,7 +292,7 @@ export function findItemForUser(userId, itemId) {
292
292
  LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
293
293
  WHERE i.id = ? AND (s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.item_id IS NOT NULL OR
294
294
  EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
295
- AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')))`).get(userId, userId, userId, userId, itemId, userId);
295
+ AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(userId, userId, userId, userId, itemId, userId);
296
296
  }
297
297
  export function updateSubscriptionLabel(userId, feedId, label) {
298
298
  db.prepare("UPDATE subscriptions SET label = ? WHERE user_id = ? AND feed_id = ?").run(label?.trim() || null, userId, feedId);
@@ -37,7 +37,7 @@ export function listItemsPage(userId, options) {
37
37
  });
38
38
  const hasMore = rows.length > limit;
39
39
  const page = rows.slice(0, limit);
40
- const items = page.map(({ content_html: _contentHtml, full_content_html: _fullContentHtml, ...item }) => item);
40
+ const items = page.map(({ content_html: _contentHtml, full_content_html: _fullContentHtml, content_lead: _lead, lead_version: _leadVersion, ...item }) => item);
41
41
  return {
42
42
  items,
43
43
  newestCursor: page[0] ? encodeCursor(page[0]) : null,
@@ -58,8 +58,9 @@ export function getItemForMcp(userId, itemId, maxChars = MAX_CONTENT_LENGTH, off
58
58
  end--;
59
59
  if (end === offset && offset < body.length)
60
60
  end = Math.min(offset + 2, body.length);
61
+ const { content_lead: _lead, lead_version: _leadVersion, ...visible } = item;
61
62
  return {
62
- ...item,
63
+ ...visible,
63
64
  content_html: format === "html" ? body.slice(offset, end) : undefined,
64
65
  content_truncated: end < body.length,
65
66
  full_content_html: undefined,
@@ -29,7 +29,7 @@ export function registerPromptsAndResources(server, userId, scope) {
29
29
  server.registerResource("feeds", "feedkeeper://feeds", { title: "Subscribed feeds", description: "All subscriptions with unread counts and health.", mimeType: "application/json" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(listSubscriptionsForUser(userId), null, 2) }] }));
30
30
  server.registerResource("opml", "feedkeeper://opml", { title: "Subscriptions as OPML", description: "The subscription list in OPML 2.0, for backup or migration.", mimeType: "text/x-opml" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/x-opml", text: generateOpml(userId) }] }));
31
31
  server.registerResource("saved", "feedkeeper://saved", { title: "Saved articles", description: "The newest 100 saved articles, without their content.", mimeType: "application/json" }, (uri) => {
32
- const items = listItemsForUser(userId, { bookmarkedOnly: true, limit: 100 }).map(({ content_html: _html, full_content_html: _full, ...item }) => item);
32
+ const items = listItemsForUser(userId, { bookmarkedOnly: true, limit: 100 }).map(({ content_html: _html, full_content_html: _full, content_lead: _lead, lead_version: _leadVersion, ...item }) => item);
33
33
  return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(items, null, 2) }] };
34
34
  });
35
35
  server.registerResource("digest", "feedkeeper://digest", { title: "Unread digest", description: "Unread articles of the last 24 hours, grouped by feed.", mimeType: "application/json" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(buildDigest(userId, { hours: 24 }), null, 2) }] }));
@@ -15,7 +15,7 @@ import { discoverFeeds } from "../feeds/discovery.js";
15
15
  import { findUserById } from "../auth/users.js";
16
16
  import { getDatabaseStats, runCleanup } from "../feeds/cleanup.js";
17
17
  import { APP_VERSION } from "../version.js";
18
- import { dismissEdition, editionCandidates, generateEdition, generateEditionSchema, getEditionState, publishCuratedEdition, publishEditionFields } from "../native/editions.js";
18
+ import { dismissEdition, editionCandidates, ensureEditionSchema, getEdition, getEditionState, publishCuratedEdition, publishEditionFields } from "../native/editions.js";
19
19
  import { getNativePreferences, patchNativePreferences, preferencePatchSchema } from "../native/preferences.js";
20
20
  import { NativeError } from "../native/requests.js";
21
21
  import { capabilityDenial, hasUserCapability } from "../auth/capabilities.js";
@@ -254,7 +254,7 @@ export function createMcpServerForUser(userId, scope) {
254
254
  },
255
255
  }, async ({ feedId, folderId, search, bookmarkedOnly, since, until, limit, includeContent, snippetChars }) => textResult(listItemsForUser(userId, {
256
256
  feedId, folderId, search, bookmarkedOnly, limit, unreadOnly: true, publishedSince: since, publishedUntil: until,
257
- }).map(({ content_html, full_content_html, ...item }) => ({
257
+ }).map(({ content_html, full_content_html, content_lead: _lead, lead_version: _leadVersion, ...item }) => ({
258
258
  ...item,
259
259
  ...(includeContent === false ? {} : { content_html, full_content_html }),
260
260
  content_snippet: snippetChars ? truncate(item.content_snippet, snippetChars) : item.content_snippet,
@@ -500,19 +500,23 @@ export function createMcpServerForUser(userId, scope) {
500
500
  if (scope === "write")
501
501
  server.registerTool("publish_edition", {
502
502
  title: "Publish curated edition",
503
- description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged. Optional layout (version 1) says how the issue is set: a character (dominant, calm or briefing) and blocks (lead, dossier, feature, standard, briefs, photo, quote) that reference the issue's article IDs at most once each; it never changes the articles.",
503
+ description: "Publishes an ordered list of 1 to 24 accessible article IDs. The latest curated edition stays readable until another is published; expiry (default 24 hours, maximum seven days) controls when a new publication is due. App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged. Optional layout (version 1) says how the issue is set: a character (dominant, calm or briefing) and blocks (lead, dossier, feature, standard, briefs, photo, quote) that reference the issue's article IDs at most once each; it never changes the articles.",
504
504
  inputSchema: publishEditionFields,
505
505
  }, async (input) => capabilityAction("editions", () => publishCuratedEdition(userId, input)));
506
506
  if (scope === "write")
507
507
  server.registerTool("generate_edition", {
508
- title: "Generate an automatic edition",
509
- description: "Ensures an automatic edition exists without disturbing a valid current issue. force=true requests a new automatic selection and requires expectedRevision. A valid curated edition must be explicitly dismissed first. Use a stable UUID requestId for retries.",
510
- inputSchema: generateEditionSchema.shape,
508
+ title: "Get the latest curated edition",
509
+ description: "Compatibility alias for reading the latest curated publication. It never selects articles or publishes an edition. force is accepted for older clients but does not create a replacement; use publish_edition to publish a new curated selection.",
510
+ inputSchema: ensureEditionSchema.shape,
511
511
  }, async (input) => capabilityAction("editions", () => {
512
- const parsed = generateEditionSchema.safeParse(input);
512
+ const parsed = ensureEditionSchema.safeParse(input);
513
513
  if (!parsed.success)
514
514
  throw new NativeError("invalid_input");
515
- return { edition: generateEdition(userId, parsed.data), current: getEditionState(userId) };
515
+ const current = getEditionState(userId);
516
+ if (parsed.data.expectedRevision !== undefined && parsed.data.expectedRevision !== current.revision) {
517
+ throw new NativeError("revision_conflict", 409, current);
518
+ }
519
+ return { edition: getEdition(userId), current };
516
520
  }));
517
521
  if (scope === "write")
518
522
  server.registerTool("dismiss_edition", {
@@ -1,11 +1,9 @@
1
- import { hasUserCapability } from "../auth/capabilities.js";
2
1
  import { randomUUID } from "node:crypto";
3
- import cron from "node-cron";
4
2
  import { z } from "zod";
5
3
  import { db } from "../db/index.js";
6
4
  import { canAccessItem } from "../feeds/repository.js";
7
5
  import { decodeEntities } from "../feeds/text.js";
8
- import { editionPeriod, nextEditionBoundary, selectEdition } from "./editionSelection.js";
6
+ import { editionPeriod, selectEdition } from "./editionSelection.js";
9
7
  import { getNativePreferences } from "./preferences.js";
10
8
  import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
11
9
  import { NativeError, nativeRequest } from "./requests.js";
@@ -22,9 +20,9 @@ function storedEdition(userId) {
22
20
  function serialize(row) {
23
21
  const prefs = getNativePreferences(row.user_id);
24
22
  return {
25
- id: row.edition_id, revision: row.revision, source: row.source,
23
+ id: row.edition_id, revision: row.revision, source: "curated",
26
24
  createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
27
- period: row.period?.split(":")[1] ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
25
+ period: row.period?.split(":").pop() ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
28
26
  title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, layout: row.layout ? JSON.parse(row.layout) : null,
29
27
  preferencesRevision: row.preferences_revision,
30
28
  itemIds: JSON.parse(row.item_ids),
@@ -34,11 +32,15 @@ export function getEditionState(userId, now = new Date()) {
34
32
  const row = storedEdition(userId);
35
33
  if (!row)
36
34
  return { revision: 0, status: "none", edition: null };
35
+ if (row.source !== "curated")
36
+ return { revision: row.revision, status: "expired", edition: null };
37
37
  const status = row.status === "active" && Date.parse(row.expires_at) <= now.getTime() ? "expired" : row.status;
38
38
  return { revision: row.revision, status, edition: status === "active" ? serialize(row) : null };
39
39
  }
40
- export function getEdition(userId, now = new Date()) {
41
- return getEditionState(userId, now).edition;
40
+ /** Expiry schedules the next publication; it does not take the last issue away from readers. */
41
+ export function getEdition(userId, _now = new Date()) {
42
+ const row = storedEdition(userId);
43
+ return row?.source === "curated" && row.status !== "dismissed" ? serialize(row) : null;
42
44
  }
43
45
  function checkRevision(userId, expectedRevision, now) {
44
46
  const current = getEditionState(userId, now);
@@ -77,7 +79,13 @@ export const publishEditionFields = {
77
79
  layout: editionLayoutSchema.optional(),
78
80
  };
79
81
  const publishSchema = z.object(publishEditionFields).strict().refine((input) => !(input.expiresAt && input.durationHours), "Use expiresAt or durationHours, not both");
80
- /** Both automatic and MCP editions obey the app's hard exclusions. Read/saved articles
82
+ /** Legacy ensure requests read the latest publication; they never select or publish articles. */
83
+ export const ensureEditionSchema = z.object({
84
+ requestId: z.uuid(),
85
+ expectedRevision: z.number().int().min(0).optional(),
86
+ force: z.boolean().default(false),
87
+ }).strict().refine((input) => !input.force || input.expectedRevision !== undefined, "force requires expectedRevision");
88
+ /** Curated publications obey the app's hard exclusions. Read/saved articles
81
89
  * may be selected deliberately by an agent, but foreign and muted items cannot be published. */
82
90
  export function publishCuratedEdition(userId, input, now = new Date()) {
83
91
  if (input.layout !== undefined && !editionLayoutSchema.safeParse(input.layout).success)
@@ -135,7 +143,7 @@ export function deleteEdition(userId) {
135
143
  dismissEdition(userId);
136
144
  return existed;
137
145
  }
138
- /** The automatic edition looks at every candidate; smaller limits are shortlists for an agent or the curator. */
146
+ /** Bound candidate gathering before building a shortlist for an agent or the curator. */
139
147
  const AUTOMATIC_EDITION_LIMIT = 4000;
140
148
  /** How often each feed appeared in the editions of the last 72 hours, so a feed that was shown a lot waits its turn. */
141
149
  function recentSourceUse(userId, itemIds) {
@@ -237,58 +245,4 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
237
245
  }
238
246
  return scored.sort((a, b) => b.score - a.score || b.id - a.id);
239
247
  }
240
- export const generateEditionSchema = z.object({
241
- requestId: z.uuid(),
242
- expectedRevision: z.number().int().min(0).optional(),
243
- force: z.boolean().default(false),
244
- }).strict().refine((input) => !input.force || input.expectedRevision !== undefined, "force requires expectedRevision");
245
- export function generateEdition(userId, input = {}, now = new Date()) {
246
- return nativeRequest(userId, input.requestId, "edition.generate", { expectedRevision: input.expectedRevision, force: input.force ?? false }, () => {
247
- checkRevision(userId, input.expectedRevision, now);
248
- const prefs = getNativePreferences(userId);
249
- const period = editionPeriod(now, prefs.timeZone).key;
250
- const state = getEditionState(userId, now);
251
- if (state.edition) {
252
- if (state.edition.source === "curated") {
253
- if (input.force)
254
- throw new NativeError("curated_edition_active", 409, state);
255
- return state.edition;
256
- }
257
- if (!input.force)
258
- return state.edition;
259
- }
260
- const row = storedEdition(userId);
261
- if (!input.force && row?.status === "dismissed" && row.period === period)
262
- return null;
263
- if (row?.status === "active" && !state.edition) {
264
- db.prepare("UPDATE editions SET status = 'expired', revision = revision + 1, updated_at = ? WHERE user_id = ?").run(now.toISOString(), userId);
265
- }
266
- const chosen = selectEdition(editionCandidates(userId, now, AUTOMATIC_EDITION_LIMIT), prefs);
267
- if (!chosen.length)
268
- return state.edition;
269
- if (state.edition && JSON.stringify(state.edition.itemIds) === JSON.stringify(chosen.map((item) => item.id)))
270
- return state.edition;
271
- return writeEdition(userId, chosen.map((item) => item.id), "automatic", nextEditionBoundary(now, prefs.timeZone), now);
272
- }, now);
273
- }
274
- /** Only users who opted in by storing preferences or creating an edition are scheduled. */
275
- export function maintainEditions(now = new Date()) {
276
- const users = db.prepare("SELECT user_id FROM native_preferences UNION SELECT user_id FROM editions").all();
277
- for (const user of users) {
278
- if (!hasUserCapability(user.user_id, "editions"))
279
- continue;
280
- try {
281
- generateEdition(user.user_id, {}, now);
282
- }
283
- catch (error) {
284
- console.error("[edition] could not refresh an edition", error instanceof Error ? error.message : "unknown error");
285
- }
286
- }
287
- // Receipts remain valid for at least 35 days, beyond the native offline window.
288
- db.prepare("DELETE FROM native_requests WHERE created_at < ?").run(new Date(now.getTime() - 35 * 86_400_000).toISOString());
289
- }
290
- export function startEditionScheduler() {
291
- maintainEditions();
292
- cron.schedule("* * * * *", () => maintainEditions());
293
- }
294
248
  //# sourceMappingURL=editions.js.map
@@ -1,3 +1,4 @@
1
+ import cron from "node-cron";
1
2
  import { createHash } from "node:crypto";
2
3
  import { db } from "../db/index.js";
3
4
  export class NativeError extends Error {
@@ -32,4 +33,11 @@ export function nativeRequest(userId, requestId, operation, input, action, now =
32
33
  return result;
33
34
  })();
34
35
  }
36
+ /** Receipts outlive the native offline window; publication metadata stays in the edition slot. */
37
+ export function startNativeRequestCleanup() {
38
+ cron.schedule("0 * * * *", () => {
39
+ db.prepare("DELETE FROM native_requests WHERE created_at < ?")
40
+ .run(new Date(Date.now() - 35 * 86_400_000).toISOString());
41
+ });
42
+ }
35
43
  //# sourceMappingURL=requests.js.map
@@ -18,7 +18,7 @@ import { installPollingShutdown, startPollingScheduler } from "./feeds/poller.js
18
18
  import { startCleanupScheduler } from "./feeds/cleanup.js";
19
19
  import { repairEncodedText } from "./feeds/repository.js";
20
20
  import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
21
- import { startEditionScheduler } from "./native/editions.js";
21
+ import { startNativeRequestCleanup } from "./native/requests.js";
22
22
  import { startLeadBackfill } from "./feeds/lead.js";
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const WEB_DIST = join(__dirname, "../../web/dist");
@@ -34,7 +34,7 @@ if (pendingArchives > 0)
34
34
  startPollingScheduler();
35
35
  startFullTextScheduler();
36
36
  startCleanupScheduler();
37
- startEditionScheduler();
37
+ startNativeRequestCleanup();
38
38
  const app = express();
39
39
  // Express validates the addresses here, so a mistyped TRUST_PROXY list stops the server at startup.
40
40
  if (config.trustProxy !== false) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }