feedkeeper 0.26.1 → 0.26.2
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 +1 -1
- package/server/dist/api/items.js +1 -1
- package/server/dist/api/v1/index.js +0 -1
- package/server/dist/api/v1/resources.js +10 -5
- package/server/dist/db/migrations/0036_curated_only_editions.sql +5 -0
- package/server/dist/feeds/cleanup.js +3 -3
- package/server/dist/feeds/contentNoise.js +23 -1
- package/server/dist/feeds/repository.js +4 -4
- package/server/dist/mcp/items.js +3 -2
- package/server/dist/mcp/promptsAndResources.js +1 -1
- package/server/dist/mcp/server.js +12 -8
- package/server/dist/native/editions.js +17 -63
- package/server/dist/native/requests.js +8 -0
- package/server/dist/server.js +2 -2
- package/server/package.json +1 -1
package/package.json
CHANGED
package/server/dist/api/items.js
CHANGED
|
@@ -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),
|
|
@@ -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,
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
@@ -147,6 +147,14 @@ const PRESENTATIONAL_TAGS = new Set(["FONT", "BIG", "SMALL", "CENTER", "TT", "U"
|
|
|
147
147
|
const PRESENTATIONAL_ATTRIBUTES = ["style", "class", "color", "face", "size", "bgcolor", "align", "valign", "border", "cellpadding",
|
|
148
148
|
"cellspacing", "background", "hspace", "vspace", "nowrap"];
|
|
149
149
|
const SIZED_TAGS = new Set(["IMG", "VIDEO", "CANVAS", "IFRAME", "EMBED", "OBJECT"]);
|
|
150
|
+
// Tracking and scripting hooks no client uses. Lazy-load sources stay: they may be an image's only address.
|
|
151
|
+
const LAZY_SOURCES = new Set(["data-src", "data-srcset", "data-lazy-src", "data-original"]);
|
|
152
|
+
const INERT_ATTRIBUTES = new Set(["role", "tabindex", "itemprop", "itemscope", "itemtype", "itemid", "draggable", "contenteditable"]);
|
|
153
|
+
function isInertAttribute(name) {
|
|
154
|
+
if (LAZY_SOURCES.has(name))
|
|
155
|
+
return false;
|
|
156
|
+
return name.startsWith("data-") || name.startsWith("aria-") || name.startsWith("on") || INERT_ATTRIBUTES.has(name);
|
|
157
|
+
}
|
|
150
158
|
const EMPHASIS_EXEMPT = "h1, h2, h3, h4, h5, h6, th, strong, b";
|
|
151
159
|
function wrapChildren(element, tag) {
|
|
152
160
|
const wrapper = element.ownerDocument.createElement(tag);
|
|
@@ -174,6 +182,12 @@ function removeTypography(root, stats) {
|
|
|
174
182
|
changed = true;
|
|
175
183
|
}
|
|
176
184
|
}
|
|
185
|
+
for (const name of element.getAttributeNames()) {
|
|
186
|
+
if (isInertAttribute(name)) {
|
|
187
|
+
element.removeAttribute(name);
|
|
188
|
+
changed = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
177
191
|
if (!SIZED_TAGS.has(element.tagName)) {
|
|
178
192
|
for (const name of ["width", "height"]) {
|
|
179
193
|
if (element.hasAttribute(name)) {
|
|
@@ -187,6 +201,14 @@ function removeTypography(root, stats) {
|
|
|
187
201
|
if (plain && italic && !element.closest("em, i"))
|
|
188
202
|
wrapChildren(element, "em");
|
|
189
203
|
const anchorTarget = element.id && targets.has(element.id);
|
|
204
|
+
if (element.id && !anchorTarget) {
|
|
205
|
+
element.removeAttribute("id");
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
if (element.tagName === "A" && element.hasAttribute("name") && !targets.has(element.getAttribute("name") ?? "")) {
|
|
209
|
+
element.removeAttribute("name");
|
|
210
|
+
changed = true;
|
|
211
|
+
}
|
|
190
212
|
if (PRESENTATIONAL_TAGS.has(element.tagName) && !anchorTarget) {
|
|
191
213
|
element.replaceWith(...element.childNodes);
|
|
192
214
|
changed = true;
|
|
@@ -384,7 +406,7 @@ const SNIPPET_INLINE_PATTERNS = [
|
|
|
384
406
|
export function cleanSnippet(snippet, title) {
|
|
385
407
|
if (snippet === null || snippet === undefined)
|
|
386
408
|
return null;
|
|
387
|
-
let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t
|
|
409
|
+
let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t\u00a0]+/g, " ");
|
|
388
410
|
for (const pattern of SNIPPET_INLINE_PATTERNS)
|
|
389
411
|
text = text.replace(pattern, "");
|
|
390
412
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|
package/server/dist/mcp/items.js
CHANGED
|
@@ -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
|
-
...
|
|
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,
|
|
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.
|
|
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: "
|
|
509
|
-
description: "
|
|
510
|
-
inputSchema:
|
|
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 =
|
|
512
|
+
const parsed = ensureEditionSchema.safeParse(input);
|
|
513
513
|
if (!parsed.success)
|
|
514
514
|
throw new NativeError("invalid_input");
|
|
515
|
-
|
|
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,
|
|
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:
|
|
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(":")
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
package/server/dist/server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) {
|