feedkeeper 0.25.1 → 0.26.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.25.1",
3
+ "version": "0.26.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",
@@ -38,6 +38,7 @@ const FEATURES = [
38
38
  "native-preferences",
39
39
  "native-preferences.edition-size",
40
40
  "edition.overview",
41
+ "edition.layout",
41
42
  "search.fts",
42
43
  "account-capabilities",
43
44
  "mcp",
@@ -0,0 +1,2 @@
1
+ -- Optional presentation plan for native editions; null for automatic and unstructured issues.
2
+ ALTER TABLE editions ADD COLUMN layout TEXT CHECK (layout IS NULL OR json_valid(layout));
@@ -500,7 +500,7 @@ 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.",
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.",
504
504
  inputSchema: publishEditionFields,
505
505
  }, async (input) => capabilityAction("editions", () => publishCuratedEdition(userId, input)));
506
506
  if (scope === "write")
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { NativeError } from "./requests.js";
3
+ /**
4
+ * A presentation plan for an edition. It says what the issue's writer considers important and what belongs together,
5
+ * never how a screen draws it: clients map the roles onto their own columns, sizes and type.
6
+ *
7
+ * Versioned and strict. A client that meets a block type it does not know sets that block's stories as plain ones.
8
+ */
9
+ const itemId = z.number().int().positive();
10
+ /** A short label above a headline: a classification, not a statement. */
11
+ const kicker = z.string().trim().min(1).max(40)
12
+ .refine((value) => !/[\r\n]/.test(value) && !/[.!?:;,]$/.test(value), "A kicker is one short phrase without closing punctuation");
13
+ const story = { itemId, kicker: kicker.optional() };
14
+ const block = z.discriminatedUnion("type", [
15
+ /** The story of the day; only once and only first. */
16
+ z.object({ type: z.literal("lead"), ...story }).strict(),
17
+ /** One overview topic (1-based position in `overview`) with the stories it is about. */
18
+ z.object({ type: z.literal("dossier"), topic: z.number().int().min(1).max(5), itemIds: z.array(itemId).min(1).max(4) }).strict(),
19
+ z.object({ type: z.literal("feature"), ...story }).strict(),
20
+ z.object({ type: z.literal("standard"), stories: z.array(z.object(story).strict()).min(1).max(3) }).strict(),
21
+ z.object({ type: z.literal("briefs"), itemIds: z.array(itemId).min(2).max(8), title: z.string().trim().min(1).max(60).optional() }).strict(),
22
+ z.object({ type: z.literal("photo"), itemId }).strict(),
23
+ /** A statement of an article set large. The issuer vouches that the text is quoted from the article. */
24
+ z.object({ type: z.literal("quote"), itemId, text: z.string().trim().min(1).max(160) }).strict(),
25
+ ]);
26
+ export const editionLayoutSchema = z.object({
27
+ version: z.literal(1),
28
+ character: z.enum(["dominant", "calm", "briefing"]),
29
+ blocks: z.array(block).min(1).max(24),
30
+ }).strict();
31
+ function blockItemIds(block) {
32
+ switch (block.type) {
33
+ case "standard": return block.stories.map((entry) => entry.itemId);
34
+ case "dossier":
35
+ case "briefs": return block.itemIds;
36
+ default: return [block.itemId];
37
+ }
38
+ }
39
+ /**
40
+ * Checks a well-formed plan against the issue it is published with: every story is in the issue and appears once,
41
+ * the lead stands first, and every topic exists and has one dossier at most.
42
+ */
43
+ export function checkEditionLayout(layout, itemIds, topicCount) {
44
+ const allowed = new Set(itemIds);
45
+ const used = new Set();
46
+ const topics = new Set();
47
+ layout.blocks.forEach((block, index) => {
48
+ if (block.type === "lead" && index !== 0)
49
+ throw new NativeError("invalid_layout");
50
+ if (block.type === "dossier") {
51
+ if (block.topic > topicCount)
52
+ throw new NativeError("invalid_layout_reference");
53
+ if (topics.has(block.topic))
54
+ throw new NativeError("invalid_layout");
55
+ topics.add(block.topic);
56
+ }
57
+ for (const id of blockItemIds(block)) {
58
+ if (!allowed.has(id))
59
+ throw new NativeError("invalid_layout_reference");
60
+ if (used.has(id))
61
+ throw new NativeError("invalid_layout");
62
+ used.add(id);
63
+ }
64
+ });
65
+ }
66
+ //# sourceMappingURL=editionLayout.js.map
@@ -7,6 +7,7 @@ import { canAccessItem } from "../feeds/repository.js";
7
7
  import { decodeEntities } from "../feeds/text.js";
8
8
  import { editionPeriod, nextEditionBoundary, selectEdition } from "./editionSelection.js";
9
9
  import { getNativePreferences } from "./preferences.js";
10
+ import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
10
11
  import { NativeError, nativeRequest } from "./requests.js";
11
12
  export const overviewSchema = z.array(z.object({
12
13
  heading: z.string().trim().min(1).max(160),
@@ -24,7 +25,8 @@ function serialize(row) {
24
25
  id: row.edition_id, revision: row.revision, source: row.source,
25
26
  createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
26
27
  period: row.period?.split(":")[1] ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
27
- title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, preferencesRevision: row.preferences_revision,
28
+ title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, layout: row.layout ? JSON.parse(row.layout) : null,
29
+ preferencesRevision: row.preferences_revision,
28
30
  itemIds: JSON.parse(row.item_ids),
29
31
  };
30
32
  }
@@ -43,18 +45,19 @@ function checkRevision(userId, expectedRevision, now) {
43
45
  if (expectedRevision !== undefined && current.revision !== expectedRevision)
44
46
  throw new NativeError("revision_conflict", 409, current);
45
47
  }
46
- function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null) {
48
+ function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null, layout = null) {
47
49
  const prefs = getNativePreferences(userId);
48
50
  const id = randomUUID();
49
51
  const revision = (storedEdition(userId)?.revision ?? 0) + 1;
50
52
  const created = now.toISOString();
51
- db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview)
52
- VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
53
+ db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview, layout)
54
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
53
55
  ON CONFLICT(user_id) DO UPDATE SET edition_id = excluded.edition_id, item_ids = excluded.item_ids,
54
56
  revision = excluded.revision, source = excluded.source, status = 'active', created_at = excluded.created_at,
55
57
  updated_at = excluded.updated_at, expires_at = excluded.expires_at, period = excluded.period,
56
- title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview`)
57
- .run(userId, id, JSON.stringify(itemIds), revision, source, created, created, expiry.toISOString(), editionPeriod(now, prefs.timeZone).key, title, summary, prefs.revision, overview ? JSON.stringify(overview.map((block, index) => ({ id: `topic-${index + 1}`, ...block }))) : null);
58
+ title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview,
59
+ layout = excluded.layout`)
60
+ .run(userId, id, JSON.stringify(itemIds), revision, source, created, created, expiry.toISOString(), editionPeriod(now, prefs.timeZone).key, title, summary, prefs.revision, overview ? JSON.stringify(overview.map((block, index) => ({ id: `topic-${index + 1}`, ...block }))) : null, layout ? JSON.stringify(layout) : null);
58
61
  db.prepare("INSERT INTO edition_history (user_id, edition_id, item_ids, revision, created_at) VALUES (?, ?, ?, ?, ?)")
59
62
  .run(userId, id, JSON.stringify(itemIds), revision, created);
60
63
  db.prepare(`DELETE FROM edition_history WHERE user_id = ? AND edition_id NOT IN
@@ -71,11 +74,14 @@ export const publishEditionFields = {
71
74
  title: z.string().trim().min(1).max(200).optional(),
72
75
  summary: z.string().trim().min(1).max(2000).optional(),
73
76
  overview: overviewSchema.optional(),
77
+ layout: editionLayoutSchema.optional(),
74
78
  };
75
79
  const publishSchema = z.object(publishEditionFields).strict().refine((input) => !(input.expiresAt && input.durationHours), "Use expiresAt or durationHours, not both");
76
80
  /** Both automatic and MCP editions obey the app's hard exclusions. Read/saved articles
77
81
  * may be selected deliberately by an agent, but foreign and muted items cannot be published. */
78
82
  export function publishCuratedEdition(userId, input, now = new Date()) {
83
+ if (input.layout !== undefined && !editionLayoutSchema.safeParse(input.layout).success)
84
+ throw new NativeError("invalid_layout");
79
85
  const parsed = publishSchema.safeParse(input);
80
86
  if (!parsed.success)
81
87
  throw new NativeError("invalid_input");
@@ -88,6 +94,8 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
88
94
  block.segments.some((segment) => segment.itemId !== undefined && !data.itemIds.includes(segment.itemId)))) {
89
95
  throw new NativeError("invalid_overview_reference");
90
96
  }
97
+ if (data.layout)
98
+ checkEditionLayout(data.layout, data.itemIds, data.overview?.length ?? 0);
91
99
  const prefs = getNativePreferences(userId);
92
100
  for (const id of data.itemIds) {
93
101
  if (!canAccessItem(userId, id))
@@ -103,7 +111,7 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
103
111
  const expiry = data.expiresAt ? new Date(data.expiresAt) : new Date(now.getTime() + (data.durationHours ?? 24) * 3_600_000);
104
112
  if (expiry.getTime() <= now.getTime() || expiry.getTime() > now.getTime() + 168 * 3_600_000)
105
113
  throw new NativeError("invalid_expiry");
106
- return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null);
114
+ return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null, data.layout ?? null);
107
115
  }, now);
108
116
  }
109
117
  /** Compatibility entry point for existing internal callers. New agents supply a
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }