@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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +45 -0
- package/.codex-plugin/mcp.json +16 -0
- package/.codex-plugin/plugin.json +27 -0
- package/.mcp.json +17 -0
- package/CHANGELOG.md +510 -0
- package/README.es.md +294 -55
- package/README.md +293 -55
- package/dist/cli/init.d.ts +101 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +481 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/client.d.ts +32 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +106 -32
- package/dist/client.js.map +1 -1
- package/dist/config/policy.d.ts +82 -0
- package/dist/config/policy.d.ts.map +1 -0
- package/dist/config/policy.js +226 -0
- package/dist/config/policy.js.map +1 -0
- package/dist/errors.d.ts +5 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +62 -5
- package/dist/errors.js.map +1 -1
- package/dist/identity.generated.d.ts +2 -1
- package/dist/identity.generated.d.ts.map +1 -1
- package/dist/identity.generated.js +2 -1
- package/dist/identity.generated.js.map +1 -1
- package/dist/index.js +85 -10
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +21 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +37 -0
- package/dist/instructions.js.map +1 -0
- package/dist/operations/actions.d.ts +654 -0
- package/dist/operations/actions.d.ts.map +1 -0
- package/dist/operations/actions.js +154 -0
- package/dist/operations/actions.js.map +1 -0
- package/dist/operations/archive.d.ts +28 -0
- package/dist/operations/archive.d.ts.map +1 -0
- package/dist/operations/archive.js +74 -0
- package/dist/operations/archive.js.map +1 -0
- package/dist/operations/attachments.d.ts +1 -1
- package/dist/operations/attachments.d.ts.map +1 -1
- package/dist/operations/attachments.js +3 -1
- package/dist/operations/attachments.js.map +1 -1
- package/dist/operations/board-id.d.ts +1 -1
- package/dist/operations/board-id.d.ts.map +1 -1
- package/dist/operations/board-id.js +13 -7
- package/dist/operations/board-id.js.map +1 -1
- package/dist/operations/boards.d.ts +96 -19
- package/dist/operations/boards.d.ts.map +1 -1
- package/dist/operations/boards.js +377 -93
- package/dist/operations/boards.js.map +1 -1
- package/dist/operations/card-brief.d.ts +91 -0
- package/dist/operations/card-brief.d.ts.map +1 -0
- package/dist/operations/card-brief.js +79 -0
- package/dist/operations/card-brief.js.map +1 -0
- package/dist/operations/cards.d.ts +34 -9
- package/dist/operations/cards.d.ts.map +1 -1
- package/dist/operations/cards.js +60 -14
- package/dist/operations/cards.js.map +1 -1
- package/dist/operations/comments.d.ts +61 -4
- package/dist/operations/comments.d.ts.map +1 -1
- package/dist/operations/comments.js +91 -8
- package/dist/operations/comments.js.map +1 -1
- package/dist/operations/duplicate.d.ts +16 -0
- package/dist/operations/duplicate.d.ts.map +1 -0
- package/dist/operations/duplicate.js +43 -0
- package/dist/operations/duplicate.js.map +1 -0
- package/dist/operations/labels.d.ts +1 -1
- package/dist/operations/labels.d.ts.map +1 -1
- package/dist/operations/labels.js +7 -4
- package/dist/operations/labels.js.map +1 -1
- package/dist/operations/lists.d.ts +63 -1
- package/dist/operations/lists.d.ts.map +1 -1
- package/dist/operations/lists.js +97 -2
- package/dist/operations/lists.js.map +1 -1
- package/dist/operations/members.d.ts +39 -0
- package/dist/operations/members.d.ts.map +1 -0
- package/dist/operations/members.js +107 -0
- package/dist/operations/members.js.map +1 -0
- package/dist/operations/projects.d.ts +16 -0
- package/dist/operations/projects.d.ts.map +1 -1
- package/dist/operations/projects.js +54 -9
- package/dist/operations/projects.js.map +1 -1
- package/dist/operations/tasks.d.ts +1 -1
- package/dist/operations/tasks.d.ts.map +1 -1
- package/dist/operations/tasks.js +5 -3
- package/dist/operations/tasks.js.map +1 -1
- package/dist/operations/users.d.ts +123 -0
- package/dist/operations/users.d.ts.map +1 -0
- package/dist/operations/users.js +180 -0
- package/dist/operations/users.js.map +1 -0
- package/dist/operations/verify.d.ts +84 -0
- package/dist/operations/verify.d.ts.map +1 -0
- package/dist/operations/verify.js +124 -0
- package/dist/operations/verify.js.map +1 -0
- package/dist/prompts.d.ts +48 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +155 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resources.d.ts +38 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +127 -0
- package/dist/resources.js.map +1 -0
- package/dist/schemas/entities.d.ts +115 -24
- package/dist/schemas/entities.d.ts.map +1 -1
- package/dist/schemas/entities.js +48 -0
- package/dist/schemas/entities.js.map +1 -1
- package/dist/schemas/requests.d.ts +121 -46
- package/dist/schemas/requests.d.ts.map +1 -1
- package/dist/schemas/requests.js +57 -12
- package/dist/schemas/requests.js.map +1 -1
- package/dist/schemas/responses.d.ts +541 -186
- package/dist/schemas/responses.d.ts.map +1 -1
- package/dist/schemas/responses.js +13 -2
- package/dist/schemas/responses.js.map +1 -1
- package/dist/tools/activity.d.ts +150 -0
- package/dist/tools/activity.d.ts.map +1 -0
- package/dist/tools/activity.js +198 -0
- package/dist/tools/activity.js.map +1 -0
- package/dist/tools/annotations.d.ts +52 -0
- package/dist/tools/annotations.d.ts.map +1 -0
- package/dist/tools/annotations.js +214 -0
- package/dist/tools/annotations.js.map +1 -0
- package/dist/tools/attachments.d.ts +28 -4
- package/dist/tools/attachments.d.ts.map +1 -1
- package/dist/tools/attachments.js +53 -34
- package/dist/tools/attachments.js.map +1 -1
- package/dist/tools/card-ops.d.ts +232 -0
- package/dist/tools/card-ops.d.ts.map +1 -0
- package/dist/tools/card-ops.js +333 -0
- package/dist/tools/card-ops.js.map +1 -0
- package/dist/tools/cards.d.ts +90 -8
- package/dist/tools/cards.d.ts.map +1 -1
- package/dist/tools/cards.js +411 -128
- package/dist/tools/cards.js.map +1 -1
- package/dist/tools/comments.d.ts +226 -22
- package/dist/tools/comments.d.ts.map +1 -1
- package/dist/tools/comments.js +163 -134
- package/dist/tools/comments.js.map +1 -1
- package/dist/tools/dispatch.d.ts +47 -0
- package/dist/tools/dispatch.d.ts.map +1 -0
- package/dist/tools/dispatch.js +63 -0
- package/dist/tools/dispatch.js.map +1 -0
- package/dist/tools/guard.d.ts +9 -0
- package/dist/tools/guard.d.ts.map +1 -0
- package/dist/tools/guard.js +20 -0
- package/dist/tools/guard.js.map +1 -0
- package/dist/tools/index.d.ts +748 -450
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +136 -17
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/labels.d.ts +213 -18
- package/dist/tools/labels.d.ts.map +1 -1
- package/dist/tools/labels.js +218 -203
- package/dist/tools/labels.js.map +1 -1
- package/dist/tools/lists.d.ts +222 -15
- package/dist/tools/lists.d.ts.map +1 -1
- package/dist/tools/lists.js +175 -156
- package/dist/tools/lists.js.map +1 -1
- package/dist/tools/members.d.ts +128 -0
- package/dist/tools/members.d.ts.map +1 -0
- package/dist/tools/members.js +150 -0
- package/dist/tools/members.js.map +1 -0
- package/dist/tools/navigation.d.ts +22 -2
- package/dist/tools/navigation.d.ts.map +1 -1
- package/dist/tools/navigation.js +60 -15
- package/dist/tools/navigation.js.map +1 -1
- package/dist/tools/queries.d.ts +196 -166
- package/dist/tools/queries.d.ts.map +1 -1
- package/dist/tools/queries.js +125 -155
- package/dist/tools/queries.js.map +1 -1
- package/dist/tools/tasks.d.ts +26 -6
- package/dist/tools/tasks.d.ts.map +1 -1
- package/dist/tools/tasks.js +110 -55
- package/dist/tools/tasks.js.map +1 -1
- package/dist/tools/users.d.ts +130 -0
- package/dist/tools/users.d.ts.map +1 -0
- package/dist/tools/users.js +165 -0
- package/dist/tools/users.js.map +1 -0
- package/docs/planka-2x-gotchas.md +121 -5
- package/docs/tools.md +771 -187
- package/docs/troubleshooting.md +137 -5
- package/hooks/hooks.json +15 -0
- package/hooks/preflight.mjs +100 -0
- package/package.json +6 -1
- package/scripts/setup.sh +8 -26
- package/scripts/sync-identity.mjs +55 -1
- package/server.json +87 -6
- package/tests/smoke/planka-smoke.mjs +512 -72
- package/workflow/skills/planka-close-card/SKILL.md +18 -5
- 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
|
|
76
|
-
* configurable: `limit` is ignored and `before` is a
|
|
77
|
-
* (`before[id]` + `before[listChangedAt]`).
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
259
|
-
*
|
|
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
|
|
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 ??
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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) -
|
|
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
|
-
|
|
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
|
-
*
|
|
305
|
-
*
|
|
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
|
|
309
|
-
const {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
572
|
+
bundle.labels.map((l) => l.name ?? l.color).join(", "));
|
|
319
573
|
}
|
|
320
|
-
|
|
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
|
|
326
|
-
const
|
|
327
|
-
|
|
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 (
|
|
337
|
-
const
|
|
338
|
-
if (!
|
|
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) =>
|
|
358
|
-
|
|
638
|
+
matches: page.map((card) => toDigest(card, index, options)),
|
|
639
|
+
warnings,
|
|
359
640
|
};
|
|
360
641
|
}
|
|
361
642
|
/**
|
|
362
|
-
* Cards of ONE list, paginated outward
|
|
363
|
-
*
|
|
364
|
-
*
|
|
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);
|