@omnicoreos/planka-mcp 0.2.0 → 0.3.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.
Files changed (195) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +26 -0
  3. package/.claude-plugin/plugin.json +45 -0
  4. package/.codex-plugin/mcp.json +16 -0
  5. package/.codex-plugin/plugin.json +27 -0
  6. package/.mcp.json +17 -0
  7. package/CHANGELOG.md +510 -0
  8. package/README.es.md +294 -55
  9. package/README.md +293 -55
  10. package/dist/cli/init.d.ts +101 -0
  11. package/dist/cli/init.d.ts.map +1 -0
  12. package/dist/cli/init.js +481 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/client.d.ts +32 -4
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +106 -32
  17. package/dist/client.js.map +1 -1
  18. package/dist/config/policy.d.ts +82 -0
  19. package/dist/config/policy.d.ts.map +1 -0
  20. package/dist/config/policy.js +226 -0
  21. package/dist/config/policy.js.map +1 -0
  22. package/dist/errors.d.ts +5 -1
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +62 -5
  25. package/dist/errors.js.map +1 -1
  26. package/dist/identity.generated.d.ts +2 -1
  27. package/dist/identity.generated.d.ts.map +1 -1
  28. package/dist/identity.generated.js +2 -1
  29. package/dist/identity.generated.js.map +1 -1
  30. package/dist/index.js +85 -10
  31. package/dist/index.js.map +1 -1
  32. package/dist/instructions.d.ts +21 -0
  33. package/dist/instructions.d.ts.map +1 -0
  34. package/dist/instructions.js +37 -0
  35. package/dist/instructions.js.map +1 -0
  36. package/dist/operations/actions.d.ts +654 -0
  37. package/dist/operations/actions.d.ts.map +1 -0
  38. package/dist/operations/actions.js +154 -0
  39. package/dist/operations/actions.js.map +1 -0
  40. package/dist/operations/archive.d.ts +28 -0
  41. package/dist/operations/archive.d.ts.map +1 -0
  42. package/dist/operations/archive.js +74 -0
  43. package/dist/operations/archive.js.map +1 -0
  44. package/dist/operations/attachments.d.ts +1 -1
  45. package/dist/operations/attachments.d.ts.map +1 -1
  46. package/dist/operations/attachments.js +3 -1
  47. package/dist/operations/attachments.js.map +1 -1
  48. package/dist/operations/board-id.d.ts +1 -1
  49. package/dist/operations/board-id.d.ts.map +1 -1
  50. package/dist/operations/board-id.js +13 -7
  51. package/dist/operations/board-id.js.map +1 -1
  52. package/dist/operations/boards.d.ts +96 -19
  53. package/dist/operations/boards.d.ts.map +1 -1
  54. package/dist/operations/boards.js +377 -93
  55. package/dist/operations/boards.js.map +1 -1
  56. package/dist/operations/card-brief.d.ts +91 -0
  57. package/dist/operations/card-brief.d.ts.map +1 -0
  58. package/dist/operations/card-brief.js +79 -0
  59. package/dist/operations/card-brief.js.map +1 -0
  60. package/dist/operations/cards.d.ts +34 -9
  61. package/dist/operations/cards.d.ts.map +1 -1
  62. package/dist/operations/cards.js +60 -14
  63. package/dist/operations/cards.js.map +1 -1
  64. package/dist/operations/comments.d.ts +61 -4
  65. package/dist/operations/comments.d.ts.map +1 -1
  66. package/dist/operations/comments.js +91 -8
  67. package/dist/operations/comments.js.map +1 -1
  68. package/dist/operations/duplicate.d.ts +16 -0
  69. package/dist/operations/duplicate.d.ts.map +1 -0
  70. package/dist/operations/duplicate.js +43 -0
  71. package/dist/operations/duplicate.js.map +1 -0
  72. package/dist/operations/labels.d.ts +1 -1
  73. package/dist/operations/labels.d.ts.map +1 -1
  74. package/dist/operations/labels.js +7 -4
  75. package/dist/operations/labels.js.map +1 -1
  76. package/dist/operations/lists.d.ts +63 -1
  77. package/dist/operations/lists.d.ts.map +1 -1
  78. package/dist/operations/lists.js +97 -2
  79. package/dist/operations/lists.js.map +1 -1
  80. package/dist/operations/members.d.ts +39 -0
  81. package/dist/operations/members.d.ts.map +1 -0
  82. package/dist/operations/members.js +107 -0
  83. package/dist/operations/members.js.map +1 -0
  84. package/dist/operations/projects.d.ts +16 -0
  85. package/dist/operations/projects.d.ts.map +1 -1
  86. package/dist/operations/projects.js +54 -9
  87. package/dist/operations/projects.js.map +1 -1
  88. package/dist/operations/tasks.d.ts +1 -1
  89. package/dist/operations/tasks.d.ts.map +1 -1
  90. package/dist/operations/tasks.js +5 -3
  91. package/dist/operations/tasks.js.map +1 -1
  92. package/dist/operations/users.d.ts +123 -0
  93. package/dist/operations/users.d.ts.map +1 -0
  94. package/dist/operations/users.js +180 -0
  95. package/dist/operations/users.js.map +1 -0
  96. package/dist/operations/verify.d.ts +84 -0
  97. package/dist/operations/verify.d.ts.map +1 -0
  98. package/dist/operations/verify.js +124 -0
  99. package/dist/operations/verify.js.map +1 -0
  100. package/dist/prompts.d.ts +48 -0
  101. package/dist/prompts.d.ts.map +1 -0
  102. package/dist/prompts.js +155 -0
  103. package/dist/prompts.js.map +1 -0
  104. package/dist/resources.d.ts +38 -0
  105. package/dist/resources.d.ts.map +1 -0
  106. package/dist/resources.js +127 -0
  107. package/dist/resources.js.map +1 -0
  108. package/dist/schemas/entities.d.ts +115 -24
  109. package/dist/schemas/entities.d.ts.map +1 -1
  110. package/dist/schemas/entities.js +48 -0
  111. package/dist/schemas/entities.js.map +1 -1
  112. package/dist/schemas/requests.d.ts +121 -46
  113. package/dist/schemas/requests.d.ts.map +1 -1
  114. package/dist/schemas/requests.js +57 -12
  115. package/dist/schemas/requests.js.map +1 -1
  116. package/dist/schemas/responses.d.ts +541 -186
  117. package/dist/schemas/responses.d.ts.map +1 -1
  118. package/dist/schemas/responses.js +13 -2
  119. package/dist/schemas/responses.js.map +1 -1
  120. package/dist/tools/activity.d.ts +150 -0
  121. package/dist/tools/activity.d.ts.map +1 -0
  122. package/dist/tools/activity.js +198 -0
  123. package/dist/tools/activity.js.map +1 -0
  124. package/dist/tools/annotations.d.ts +52 -0
  125. package/dist/tools/annotations.d.ts.map +1 -0
  126. package/dist/tools/annotations.js +214 -0
  127. package/dist/tools/annotations.js.map +1 -0
  128. package/dist/tools/attachments.d.ts +28 -4
  129. package/dist/tools/attachments.d.ts.map +1 -1
  130. package/dist/tools/attachments.js +53 -34
  131. package/dist/tools/attachments.js.map +1 -1
  132. package/dist/tools/card-ops.d.ts +232 -0
  133. package/dist/tools/card-ops.d.ts.map +1 -0
  134. package/dist/tools/card-ops.js +333 -0
  135. package/dist/tools/card-ops.js.map +1 -0
  136. package/dist/tools/cards.d.ts +90 -8
  137. package/dist/tools/cards.d.ts.map +1 -1
  138. package/dist/tools/cards.js +411 -128
  139. package/dist/tools/cards.js.map +1 -1
  140. package/dist/tools/comments.d.ts +226 -22
  141. package/dist/tools/comments.d.ts.map +1 -1
  142. package/dist/tools/comments.js +163 -134
  143. package/dist/tools/comments.js.map +1 -1
  144. package/dist/tools/dispatch.d.ts +47 -0
  145. package/dist/tools/dispatch.d.ts.map +1 -0
  146. package/dist/tools/dispatch.js +63 -0
  147. package/dist/tools/dispatch.js.map +1 -0
  148. package/dist/tools/guard.d.ts +9 -0
  149. package/dist/tools/guard.d.ts.map +1 -0
  150. package/dist/tools/guard.js +20 -0
  151. package/dist/tools/guard.js.map +1 -0
  152. package/dist/tools/index.d.ts +748 -450
  153. package/dist/tools/index.d.ts.map +1 -1
  154. package/dist/tools/index.js +136 -17
  155. package/dist/tools/index.js.map +1 -1
  156. package/dist/tools/labels.d.ts +213 -18
  157. package/dist/tools/labels.d.ts.map +1 -1
  158. package/dist/tools/labels.js +218 -203
  159. package/dist/tools/labels.js.map +1 -1
  160. package/dist/tools/lists.d.ts +222 -15
  161. package/dist/tools/lists.d.ts.map +1 -1
  162. package/dist/tools/lists.js +175 -156
  163. package/dist/tools/lists.js.map +1 -1
  164. package/dist/tools/members.d.ts +128 -0
  165. package/dist/tools/members.d.ts.map +1 -0
  166. package/dist/tools/members.js +150 -0
  167. package/dist/tools/members.js.map +1 -0
  168. package/dist/tools/navigation.d.ts +22 -2
  169. package/dist/tools/navigation.d.ts.map +1 -1
  170. package/dist/tools/navigation.js +60 -15
  171. package/dist/tools/navigation.js.map +1 -1
  172. package/dist/tools/queries.d.ts +196 -166
  173. package/dist/tools/queries.d.ts.map +1 -1
  174. package/dist/tools/queries.js +125 -155
  175. package/dist/tools/queries.js.map +1 -1
  176. package/dist/tools/tasks.d.ts +26 -6
  177. package/dist/tools/tasks.d.ts.map +1 -1
  178. package/dist/tools/tasks.js +110 -55
  179. package/dist/tools/tasks.js.map +1 -1
  180. package/dist/tools/users.d.ts +130 -0
  181. package/dist/tools/users.d.ts.map +1 -0
  182. package/dist/tools/users.js +165 -0
  183. package/dist/tools/users.js.map +1 -0
  184. package/docs/planka-2x-gotchas.md +121 -5
  185. package/docs/tools.md +771 -187
  186. package/docs/troubleshooting.md +137 -5
  187. package/hooks/hooks.json +15 -0
  188. package/hooks/preflight.mjs +100 -0
  189. package/package.json +6 -1
  190. package/scripts/setup.sh +8 -26
  191. package/scripts/sync-identity.mjs +55 -1
  192. package/server.json +87 -6
  193. package/tests/smoke/planka-smoke.mjs +512 -72
  194. package/workflow/skills/planka-close-card/SKILL.md +18 -5
  195. package/workflow/skills/planka-orchestrator/SKILL.md +36 -7
@@ -2,12 +2,20 @@
2
2
  * Board operations for PLANKA API.
3
3
  */
4
4
  import { plankaClient } from "../client.js";
5
- import { parseInput } from "../errors.js";
5
+ import { parseInput, PlankaError } from "../errors.js";
6
+ import { assertBoardAllowed, getPolicy } from "../config/policy.js";
6
7
  import { withBoardId } from "./board-id.js";
7
- import { BoardResponse, BoardIncludedSchema, CardsResponse, } from "../schemas/responses.js";
8
- import { BoardIdSchema, BoardSummarySchema, FindCardsSchema, GetCardsForListSchema, ListCardsSchema, } from "../schemas/requests.js";
8
+ import { BoardResponse, BoardIncludedSchema, CardsResponse, ListResponse, } from "../schemas/responses.js";
9
+ import { BoardIdSchema, BoardSummarySchema, FindCardsSchema, GetCardsForListSchema, ListCardsSchema, DEFAULT_DESCRIPTION_CHARS, } from "../schemas/requests.js";
9
10
  /**
10
11
  * Get a board by ID with all included entities.
12
+ *
13
+ * ⚠️ `GET /api/boards/:id` returns the cards of the FINITE lists only
14
+ * (`active` + `closed`). The `archive` and `trash` lists come back nameless,
15
+ * positionless and WITHOUT their cards. Everything derived from this call —
16
+ * counts, searches, summaries — is therefore a view of the visible board, not
17
+ * of everything Planka stores. Reads built on it flag it as
18
+ * `excludesArchived: true`. See docs/planka-2x-gotchas.md.
11
19
  */
12
20
  export async function getBoard(input) {
13
21
  const { boardId } = parseInput(BoardIdSchema, input);
@@ -21,8 +29,10 @@ export async function getBoard(input) {
21
29
  cards: included.cards || [],
22
30
  labels: (included.labels || []).sort((a, b) => a.position - b.position),
23
31
  cardLabels: included.cardLabels || [],
32
+ cardMemberships: included.cardMemberships || [],
24
33
  taskLists: (included.taskLists || []).sort((a, b) => a.position - b.position),
25
34
  tasks: included.tasks || [],
35
+ users: included.users || [],
26
36
  };
27
37
  });
28
38
  }
@@ -72,14 +82,17 @@ export async function getBoardWithTaskCounts(input) {
72
82
  };
73
83
  }
74
84
  /**
75
- * Page size of GET /api/lists/:id/cards in Planka 2.x. It is not
76
- * configurable: `limit` is ignored and `before` is a composite cursor
77
- * (`before[id]` + `before[listChangedAt]`). Verified against the live API.
85
+ * Page size of GET /api/lists/:id/cards in Planka 2.x — 50 at the time of
86
+ * writing. It is not configurable: `limit` is ignored and `before` is a
87
+ * composite cursor (`before[id]` + `before[listChangedAt]`).
78
88
  *
79
- * Without following that cursor, a column of 77 cards answers 50 and says
80
- * nothing about the other 27.
89
+ * It is NOT assumed: the first response defines it (see below). Hardcoding 50
90
+ * against an instance that answers 20 stops the loop on page one and reports
91
+ * `truncated: false` over a third of the column — the exact class of lie this
92
+ * module exists to avoid. The constant is kept only as the documented
93
+ * expectation.
81
94
  */
82
- const LIST_CARDS_PAGE_SIZE = 50;
95
+ export const LIST_CARDS_EXPECTED_PAGE_SIZE = 50;
83
96
  /** Safety ceiling: 20 pages = 1000 cards. */
84
97
  const LIST_CARDS_MAX_PAGES = 20;
85
98
  export async function getCardsForList(input) {
@@ -90,6 +103,8 @@ export async function getCardsForList(input) {
90
103
  const tasks = [];
91
104
  let cursor = null;
92
105
  let truncated = false;
106
+ // Derived from the first response, never assumed.
107
+ let pageSize = null;
93
108
  for (let page = 0; page < LIST_CARDS_MAX_PAGES; page++) {
94
109
  let path = `/api/lists/${listId}/cards`;
95
110
  if (cursor) {
@@ -106,7 +121,12 @@ export async function getCardsForList(input) {
106
121
  cardLabels.push(...(included.cardLabels || []));
107
122
  taskLists.push(...(included.taskLists || []));
108
123
  tasks.push(...(included.tasks || []));
109
- if (parsed.items.length < LIST_CARDS_PAGE_SIZE)
124
+ // An empty answer ends the list whatever the page size is.
125
+ if (parsed.items.length === 0)
126
+ break;
127
+ if (pageSize === null)
128
+ pageSize = parsed.items.length;
129
+ if (parsed.items.length < pageSize)
110
130
  break;
111
131
  const last = parsed.items[parsed.items.length - 1];
112
132
  if (!last.listChangedAt) {
@@ -160,6 +180,32 @@ export function normalizeText(text) {
160
180
  .replace(/[\u0300-\u036f]/g, "")
161
181
  .toLowerCase();
162
182
  }
183
+ /**
184
+ * Text matching, same semantics as Planka's own `?search=`:
185
+ * - a leading "/" makes the rest a case-insensitive regular expression;
186
+ * - otherwise every whitespace-separated token must appear (AND), ignoring
187
+ * case and accents.
188
+ * An invalid regex is reported to the caller instead of matching nothing.
189
+ */
190
+ export function makeMatcher(query) {
191
+ if (query.startsWith("/")) {
192
+ const source = query.slice(1);
193
+ let regex;
194
+ try {
195
+ regex = new RegExp(source, "i");
196
+ }
197
+ catch (error) {
198
+ throw new PlankaError(`Invalid regular expression after "/": ${source} (${error.message}). ` +
199
+ `Drop the leading "/" to search for it as plain text.`, "INVALID_QUERY", 400);
200
+ }
201
+ return (haystack) => regex.test(haystack);
202
+ }
203
+ const tokens = normalizeText(query).split(/\s+/).filter(Boolean);
204
+ return (haystack) => {
205
+ const normalized = normalizeText(haystack);
206
+ return tokens.every((token) => normalized.includes(token));
207
+ };
208
+ }
163
209
  /**
164
210
  * A board seen without its cards: just the visible lists and how many cards
165
211
  * each one holds. One GET, a handful of lines of output.
@@ -186,37 +232,15 @@ export async function getBoardListCounts(input) {
186
232
  totalCards: details.cards.length,
187
233
  };
188
234
  }
189
- /** Clips the description so a preview does not eat the size advantage. */
190
- const DESCRIPTION_PREVIEW = 200;
191
- function digest(card, listName, labelNames, withDescription) {
192
- const description = card.description ?? null;
193
- return {
194
- id: card.id,
195
- name: card.name,
196
- listId: card.listId,
197
- list: listName,
198
- labels: labelNames,
199
- ...(withDescription && description
200
- ? {
201
- description: description.length > DESCRIPTION_PREVIEW
202
- ? `${description.slice(0, DESCRIPTION_PREVIEW)}…`
203
- : description,
204
- }
205
- : {}),
206
- ...(card.dueDate ? { dueDate: card.dueDate } : {}),
207
- ...(card.isCompleted ? { isCompleted: true } : {}),
208
- };
209
- }
210
- /**
211
- * Indexes derived from a single GET on the board: list names, label names per
212
- * card, and the cards ordered the way the board shows them.
213
- */
214
- async function indexBoard(boardId) {
215
- const details = await getBoard({ boardId });
216
- const listById = new Map(details.lists.map((l) => [l.id, l]));
217
- const labelById = new Map(details.labels.map((l) => [l.id, l]));
235
+ function indexBundle(bundle) {
236
+ const labelById = new Map(bundle.labels.map((l) => [l.id, l]));
237
+ const userById = new Map(bundle.users.map((u) => [u.id, u]));
218
238
  const labelNamesByCard = new Map();
219
- for (const cl of details.cardLabels) {
239
+ const labelIdsByCard = new Map();
240
+ for (const cl of bundle.cardLabels) {
241
+ const ids = labelIdsByCard.get(cl.cardId) ?? [];
242
+ ids.push(cl.labelId);
243
+ labelIdsByCard.set(cl.cardId, ids);
220
244
  const label = labelById.get(cl.labelId);
221
245
  if (!label)
222
246
  continue;
@@ -224,13 +248,170 @@ async function indexBoard(boardId) {
224
248
  names.push(label.name ?? label.color);
225
249
  labelNamesByCard.set(cl.cardId, names);
226
250
  }
227
- // Same order a human sees: by list position, then by card position.
251
+ const memberNamesByCard = new Map();
252
+ const memberIdsByCard = new Map();
253
+ for (const cm of bundle.cardMemberships) {
254
+ const ids = memberIdsByCard.get(cm.cardId) ?? new Set();
255
+ ids.add(cm.userId);
256
+ memberIdsByCard.set(cm.cardId, ids);
257
+ const names = memberNamesByCard.get(cm.cardId) ?? [];
258
+ names.push(userById.get(cm.userId)?.name ?? cm.userId);
259
+ memberNamesByCard.set(cm.cardId, names);
260
+ }
261
+ const taskListToCard = new Map(bundle.taskLists.map((tl) => [tl.id, tl.cardId]));
262
+ const taskCounts = new Map();
263
+ for (const task of bundle.tasks) {
264
+ const cardId = taskListToCard.get(task.taskListId);
265
+ if (!cardId)
266
+ continue;
267
+ const counts = taskCounts.get(cardId) ?? { total: 0, completed: 0 };
268
+ counts.total++;
269
+ if (task.isCompleted)
270
+ counts.completed++;
271
+ taskCounts.set(cardId, counts);
272
+ // Planka's own userIds filter matches members OR task assignees.
273
+ if (task.assigneeUserId) {
274
+ const ids = memberIdsByCard.get(cardId) ?? new Set();
275
+ ids.add(task.assigneeUserId);
276
+ memberIdsByCard.set(cardId, ids);
277
+ }
278
+ }
279
+ return {
280
+ listNameById: new Map(bundle.lists.map((l) => [l.id, l.name ?? ""])),
281
+ labelNamesByCard,
282
+ labelIdsByCard,
283
+ memberNamesByCard,
284
+ memberIdsByCard,
285
+ taskCounts,
286
+ };
287
+ }
288
+ /** The whole board as a bundle: one GET, every visible card. */
289
+ async function boardBundle(boardId) {
290
+ const details = await getBoard({ boardId });
228
291
  const listPosition = new Map(details.lists.map((l) => [l.id, l.position ?? Number.MAX_SAFE_INTEGER]));
292
+ // Same order a human sees: by list position, then by card position.
229
293
  const cards = [...details.cards].sort((a, b) => {
230
294
  const byList = (listPosition.get(a.listId) ?? 0) - (listPosition.get(b.listId) ?? 0);
231
295
  return byList !== 0 ? byList : a.position - b.position;
232
296
  });
233
- return { details, listById, labelById, labelNamesByCard, cards };
297
+ return {
298
+ details,
299
+ bundle: {
300
+ boardId: details.board.id,
301
+ cards,
302
+ labels: details.labels,
303
+ cardLabels: details.cardLabels,
304
+ cardMemberships: details.cardMemberships,
305
+ taskLists: details.taskLists,
306
+ tasks: details.tasks,
307
+ users: details.users,
308
+ lists: details.lists,
309
+ labelsResolved: true,
310
+ },
311
+ };
312
+ }
313
+ /**
314
+ * ONE list as a bundle, through `GET /api/lists/:id`: every card of the
315
+ * column in a single response, no cursor, no page ceiling. Label names need
316
+ * the board, so they cost one extra GET and only when asked for.
317
+ */
318
+ async function listBundle(listId, withLabels) {
319
+ const response = await plankaClient.get(`/api/lists/${listId}`);
320
+ const list = ListResponse.parse(response).item;
321
+ const included = BoardIncludedSchema.parse(response.included || {});
322
+ // The list names its board: check that board against the allowlist before
323
+ // handing any of its content back.
324
+ assertBoardAllowed(list.boardId);
325
+ const cards = [...(included.cards || [])].sort((a, b) => a.position - b.position);
326
+ let labels = [];
327
+ if (withLabels) {
328
+ const board = await getBoard({ boardId: list.boardId });
329
+ labels = board.labels;
330
+ }
331
+ return {
332
+ list,
333
+ bundle: {
334
+ boardId: list.boardId,
335
+ cards,
336
+ labels,
337
+ cardLabels: included.cardLabels || [],
338
+ cardMemberships: included.cardMemberships || [],
339
+ taskLists: included.taskLists || [],
340
+ tasks: included.tasks || [],
341
+ users: included.users || [],
342
+ lists: [list],
343
+ labelsResolved: withLabels,
344
+ },
345
+ };
346
+ }
347
+ function clip(text, chars) {
348
+ return text.length > chars ? `${text.slice(0, chars)}…` : text;
349
+ }
350
+ function toDigest(card, index, options) {
351
+ const full = options.detail === "full";
352
+ const counts = index.taskCounts.get(card.id);
353
+ const members = index.memberNamesByCard.get(card.id) ?? [];
354
+ const description = card.description ?? null;
355
+ const digest = {
356
+ id: card.id,
357
+ name: card.name,
358
+ };
359
+ if (options.withListName) {
360
+ // Same names and same meanings as planka_get_card's `card.listId` /
361
+ // `card.listName`: one vocabulary across the two digests, so a match can be
362
+ // moved without a third call to find out where it lives. Omitted on the
363
+ // single-column path only because the response root already carries
364
+ // `list: {id, name}` — the id is never actually missing.
365
+ digest.listId = card.listId;
366
+ digest.listName = index.listNameById.get(card.listId) ?? "";
367
+ }
368
+ if (options.withLabels) {
369
+ // Empty arrays are omitted; the response says once, at its root, whether
370
+ // label names were resolved at all — so a missing `labels` is never
371
+ // ambiguous while still costing nothing per card.
372
+ const labelNames = index.labelNamesByCard.get(card.id) ?? [];
373
+ if (labelNames.length > 0)
374
+ digest.labels = labelNames;
375
+ }
376
+ // IDs travel with the names, in digest too: planka_set_card_labels takes ids
377
+ // and only ids, and find_cards used to publish the names alone — which sent
378
+ // the model back to planka_board_summary for data it already had.
379
+ const labelIds = index.labelIdsByCard.get(card.id) ?? [];
380
+ if (labelIds.length > 0)
381
+ digest.labelIds = labelIds;
382
+ if (members.length > 0)
383
+ digest.members = members;
384
+ if (counts && counts.total > 0) {
385
+ digest.tasks = `${counts.completed}/${counts.total}`;
386
+ }
387
+ if (card.dueDate)
388
+ digest.dueDate = card.dueDate;
389
+ if (card.commentsTotal)
390
+ digest.commentsTotal = card.commentsTotal;
391
+ if (card.updatedAt)
392
+ digest.updatedAt = card.updatedAt;
393
+ if (description !== null) {
394
+ if (full)
395
+ digest.description = description;
396
+ else if (options.descriptionChars > 0) {
397
+ digest.description = clip(description, options.descriptionChars);
398
+ }
399
+ }
400
+ if (full) {
401
+ digest.listId = card.listId;
402
+ digest.boardId = card.boardId;
403
+ digest.position = card.position;
404
+ digest.type = card.type;
405
+ digest.createdAt = card.createdAt;
406
+ if (card.isClosed)
407
+ digest.isClosed = true;
408
+ if (card.isDueCompleted ?? card.isDueDateCompleted) {
409
+ digest.isDueCompleted = true;
410
+ }
411
+ }
412
+ if (card.isCompleted)
413
+ digest.isCompleted = true;
414
+ return digest;
234
415
  }
235
416
  /**
236
417
  * The lists whose cards the caller asked for. Each entry matches an exact id
@@ -248,39 +429,68 @@ function selectLists(lists, wanted) {
248
429
  }
249
430
  return selected;
250
431
  }
251
- /**
252
- * Default for `cardsFrom`: the column where cards waiting on a decision pile
253
- * up. Matched by name so it works on any board, whatever the language.
254
- */
255
- const DECISION_LIST_HINTS = ["decision", "decide", "blocked", "bloquead"];
432
+ /** Which `cardsFrom`/`listName` entries matched nothing at all. */
433
+ function unmatchedHints(lists, wanted) {
434
+ return wanted.filter((w) => !lists.some((l) => l.name !== null &&
435
+ (w === l.id || normalizeText(l.name).includes(normalizeText(w)))));
436
+ }
256
437
  /**
257
438
  * Everything needed to open a working session, in one call: columns with
258
- * counts, the board's labels, and the cards sitting in the column(s) waiting
259
- * on a decision, flagged by whether they already got one.
439
+ * counts, the board's labels, and — when asked — the cards of the columns
440
+ * named in `cardsFrom`, flagged by whether they carry `highlightLabel`.
441
+ *
442
+ * With `cardsFrom: []` (or with neither argument nor
443
+ * PLANKA_SUMMARY_DECISION_LISTS) it answers the shape of the board and no
444
+ * cards at all: that is what `planka_list_lists` now is.
260
445
  */
261
446
  export async function getBoardSummary(input) {
262
447
  const { boardId, cardsFrom, highlightLabel, limit } = parseInput(BoardSummarySchema, input);
263
- const { details, listById, labelNamesByCard, cards } = await indexBoard(boardId);
448
+ const policy = getPolicy();
449
+ const { bundle, details } = await boardBundle(boardId);
450
+ const index = indexBundle(bundle);
451
+ const warnings = [];
264
452
  const countByList = new Map();
265
453
  for (const card of details.cards) {
266
454
  countByList.set(card.listId, (countByList.get(card.listId) ?? 0) + 1);
267
455
  }
268
456
  const visibleLists = details.lists.filter((list) => list.name !== null);
269
- const wanted = cardsFrom ?? DECISION_LIST_HINTS;
457
+ const wanted = cardsFrom ?? policy.summaryDecisionLists ?? [];
270
458
  const selected = selectLists(visibleLists, wanted);
459
+ if (wanted.length > 0) {
460
+ const missed = unmatchedHints(visibleLists, wanted);
461
+ if (missed.length > 0) {
462
+ warnings.push(`cardsFrom matched no column for: ${missed.join(", ")}. Columns on this board: ` +
463
+ visibleLists.map((l) => l.name).join(", "));
464
+ }
465
+ }
466
+ const highlight = highlightLabel ?? policy.summaryHighlightLabel ?? null;
467
+ if (highlight !== null &&
468
+ !details.labels.some((l) => normalizeText(l.name ?? l.color) === normalizeText(highlight))) {
469
+ warnings.push(`No label named "${highlight}" on this board, so no card is highlighted. Labels: ` +
470
+ details.labels.map((l) => l.name ?? l.color).join(", "));
471
+ }
271
472
  const selectedIds = new Set(selected.map((l) => l.id));
272
- const highlight = normalizeText(highlightLabel);
273
- const matching = cards
274
- .filter((card) => selectedIds.has(card.listId))
275
- .map((card) => {
276
- const labels = labelNamesByCard.get(card.id) ?? [];
277
- return {
278
- ...digest(card, listById.get(card.listId)?.name ?? "", labels, false),
279
- highlighted: labels.some((l) => normalizeText(l) === highlight),
280
- };
281
- });
473
+ const options = {
474
+ detail: "digest",
475
+ descriptionChars: 0,
476
+ withLabels: true,
477
+ withListName: true,
478
+ };
479
+ const matching = selectedIds.size === 0
480
+ ? []
481
+ : bundle.cards
482
+ .filter((card) => selectedIds.has(card.listId))
483
+ .map((card) => {
484
+ const labels = index.labelNamesByCard.get(card.id) ?? [];
485
+ const digest = toDigest(card, index, options);
486
+ return highlight !== null &&
487
+ labels.some((l) => normalizeText(l) === normalizeText(highlight))
488
+ ? { ...digest, highlighted: true }
489
+ : digest;
490
+ });
282
491
  // Unblocked first: that is what gets picked up at the start of a session.
283
- matching.sort((a, b) => Number(b.highlighted) - Number(a.highlighted));
492
+ matching.sort((a, b) => Number("highlighted" in b && b.highlighted) -
493
+ Number("highlighted" in a && a.highlighted));
284
494
  const page = matching.slice(0, limit);
285
495
  return {
286
496
  board: details.board,
@@ -291,77 +501,151 @@ export async function getBoardSummary(input) {
291
501
  cardCount: countByList.get(list.id) ?? 0,
292
502
  })),
293
503
  labels: details.labels.map((l) => ({ id: l.id, name: l.name ?? l.color })),
294
- highlightLabel,
504
+ totalCards: details.cards.length,
505
+ ...(highlight !== null ? { highlightLabel: highlight } : {}),
295
506
  cardsFrom: selected.map((l) => l.name ?? l.id),
296
507
  cards: page,
297
508
  highlightedCount: matching.filter((c) => c.highlighted).length,
298
509
  returned: page.length,
299
510
  total: matching.length,
300
511
  hasMore: page.length < matching.length,
512
+ warnings,
301
513
  };
302
514
  }
303
515
  /**
304
- * Searches a board's cards by label and/or text. Text matches both name and
305
- * description, case- and accent-insensitive.
516
+ * The one search over a board's cards.
517
+ *
518
+ * With `listId` it reads that column alone (`GET /api/lists/:id`): one call,
519
+ * unpaginated, so `total` is the real size of the column and nothing can be
520
+ * silently clipped. Without it, it filters over a single board read.
306
521
  */
307
522
  export async function findCards(input) {
308
- const { boardId, labelName, query, listId, withDescription, limit, offset } = parseInput(FindCardsSchema, input);
309
- const { details, listById, labelById, labelNamesByCard, cards } = await indexBoard(boardId);
523
+ const params = parseInput(FindCardsSchema, input);
524
+ const { boardId, listId, listName, labelIds, userIds, query, withLabels, detail, limit, offset, } = params;
525
+ const labelName = params.labelName ?? params.labelFilter;
310
526
  const warnings = [];
527
+ const descriptionChars = params.descriptionChars ??
528
+ (params.withDescription ? DEFAULT_DESCRIPTION_CHARS : 0);
529
+ // Label names are needed for the filter, and for anything but a bare digest.
530
+ const needLabels = withLabels || labelName !== undefined || detail === "full";
531
+ let bundle;
532
+ let scopedList = null;
533
+ let source;
534
+ let listIdsFilter = null;
535
+ if (listId) {
536
+ const read = await listBundle(listId, needLabels);
537
+ bundle = read.bundle;
538
+ scopedList = read.list;
539
+ source = "list";
540
+ }
541
+ else {
542
+ const read = await boardBundle(boardId);
543
+ bundle = read.bundle;
544
+ source = "board";
545
+ if (listName) {
546
+ const hits = selectLists(bundle.lists, [listName]);
547
+ if (hits.length === 0) {
548
+ warnings.push(`No column matching "${listName}" on this board. Columns: ` +
549
+ bundle.lists
550
+ .filter((l) => l.name !== null)
551
+ .map((l) => l.name)
552
+ .join(", "));
553
+ }
554
+ if (hits.length === 1)
555
+ scopedList = hits[0];
556
+ listIdsFilter = new Set(hits.map((l) => l.id));
557
+ }
558
+ }
559
+ const index = indexBundle(bundle);
311
560
  // labelName -> ids. A board can carry the same name on two labels.
312
- let labelIds = null;
561
+ let labelNameIds = null;
313
562
  if (labelName) {
563
+ if (!bundle.labelsResolved) {
564
+ // Cannot happen through the tools (needLabels covers it), but a direct
565
+ // caller must not get a silent zero.
566
+ throw new PlankaError("labelName needs the board's labels; do not pass withLabels: false with it.", "INVALID_ARGUMENTS", 400);
567
+ }
314
568
  const wanted = normalizeText(labelName);
315
- const hits = details.labels.filter((l) => normalizeText(l.name ?? l.color) === wanted);
569
+ const hits = bundle.labels.filter((l) => normalizeText(l.name ?? l.color) === wanted);
316
570
  if (hits.length === 0) {
317
571
  warnings.push(`No label named "${labelName}" on this board. Existing labels: ` +
318
- details.labels.map((l) => l.name ?? l.color).join(", "));
572
+ bundle.labels.map((l) => l.name ?? l.color).join(", "));
319
573
  }
320
- labelIds = new Set(hits.map((l) => l.id));
321
- }
322
- if (listId && !listById.has(listId)) {
323
- warnings.push(`No list ${listId} on this board.`);
574
+ labelNameIds = new Set(hits.map((l) => l.id));
324
575
  }
325
- const needle = query ? normalizeText(query) : null;
326
- const matched = cards.filter((card) => {
327
- if (listId && card.listId !== listId)
576
+ const wantedLabelIds = labelIds ? new Set(labelIds) : null;
577
+ const wantedUserIds = userIds ? new Set(userIds) : null;
578
+ const matches = query ? makeMatcher(query) : null;
579
+ const matched = bundle.cards.filter((card) => {
580
+ if (listIdsFilter && !listIdsFilter.has(card.listId))
581
+ return false;
582
+ const cardLabelIds = index.labelIdsByCard.get(card.id) ?? [];
583
+ if (labelNameIds && !cardLabelIds.some((id) => labelNameIds.has(id))) {
584
+ return false;
585
+ }
586
+ if (wantedLabelIds && !cardLabelIds.some((id) => wantedLabelIds.has(id))) {
328
587
  return false;
329
- if (labelIds) {
330
- const cardLabelIds = details.cardLabels
331
- .filter((cl) => cl.cardId === card.id)
332
- .map((cl) => cl.labelId);
333
- if (!cardLabelIds.some((id) => labelIds.has(id)))
334
- return false;
335
588
  }
336
- if (needle) {
337
- const haystack = normalizeText(`${card.name}\n${card.description ?? ""}`);
338
- if (!haystack.includes(needle))
589
+ if (wantedUserIds) {
590
+ const cardUserIds = index.memberIdsByCard.get(card.id);
591
+ if (!cardUserIds)
339
592
  return false;
593
+ let hit = false;
594
+ for (const id of cardUserIds)
595
+ if (wantedUserIds.has(id))
596
+ hit = true;
597
+ if (!hit)
598
+ return false;
599
+ }
600
+ if (matches && !matches(`${card.name}\n${card.description ?? ""}`)) {
601
+ return false;
340
602
  }
341
603
  return true;
342
604
  });
343
605
  const page = matched.slice(offset, offset + limit);
344
606
  const hasMore = offset + page.length < matched.length;
607
+ // The column name is hoisted out of every card when they all share it.
608
+ const singleColumn = source === "list" || (listIdsFilter !== null && listIdsFilter.size === 1);
609
+ const options = {
610
+ detail,
611
+ descriptionChars,
612
+ withLabels: needLabels,
613
+ withListName: !singleColumn,
614
+ };
345
615
  return {
346
- boardId,
616
+ boardId: bundle.boardId,
617
+ ...(scopedList
618
+ ? { list: { id: scopedList.id, name: scopedList.name } }
619
+ : {}),
347
620
  filters: {
621
+ ...(listId ? { listId } : {}),
622
+ ...(listName ? { listName } : {}),
348
623
  ...(labelName ? { labelName } : {}),
624
+ ...(labelIds ? { labelIds } : {}),
625
+ ...(userIds ? { userIds } : {}),
349
626
  ...(query ? { query } : {}),
350
- ...(listId ? { listId } : {}),
351
627
  },
628
+ detail,
629
+ source,
630
+ labelsResolved: needLabels,
631
+ ...(source === "list" ? { truncated: false } : {}),
632
+ ...(source === "board" ? { excludesArchived: true } : {}),
352
633
  total: matched.length,
353
634
  returned: page.length,
354
635
  offset,
355
636
  hasMore,
356
637
  ...(hasMore ? { nextOffset: offset + page.length } : {}),
357
- matches: page.map((card) => digest(card, listById.get(card.listId)?.name ?? "", labelNamesByCard.get(card.id) ?? [], withDescription)),
358
- ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}),
638
+ matches: page.map((card) => toDigest(card, index, options)),
639
+ warnings,
359
640
  };
360
641
  }
361
642
  /**
362
- * Cards of ONE list, paginated outward. `limit`/`offset` are output-side:
363
- * Planka's API accepts neither, so the whole list is resolved through its
364
- * cursor and clipped here — the only way to report an honest `total`.
643
+ * Cards of ONE list, paginated outward, through the cursor of
644
+ * `GET /api/lists/:id/cards`.
645
+ *
646
+ * Kept for callers that want the raw card records; `planka_list_cards` no
647
+ * longer goes through here — it is an alias of findCards({ listId }), which
648
+ * gets the same cards in ONE request via the list show.
365
649
  */
366
650
  export async function listCards(input) {
367
651
  const { listId, withLabels, labelFilter, limit, offset } = parseInput(ListCardsSchema, input);