@anchrd/intel-api 0.12.0 → 0.12.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/dist/adapters/cloudflare-api/cloudflare-api.js +11 -3
- package/dist/adapters/db/db-indexing.js +18 -7
- package/dist/adapters/db/db.js +61 -7
- package/dist/adapters/openid/openid.js +51 -7
- package/dist/auth/auth.js +11 -3
- package/dist/auth/auth.types.d.ts +1 -0
- package/dist/bundle/bundle.js +57 -2
- package/dist/http/http.js +49 -1
- package/dist/indexing/indexing.js +38 -3
- package/dist/mcp/mcp.js +89 -1
- package/dist/nodes/board/board.d.ts +15 -0
- package/dist/nodes/board/board.js +359 -0
- package/dist/nodes/board/board.types.d.ts +31 -0
- package/dist/nodes/board/board.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +9 -2
- package/dist/nodes/document-links/document-links.js +22 -5
- package/dist/nodes/nodes.js +308 -2
- package/dist/nodes/nodes.types.d.ts +53 -9
- package/migrations/0016_boards_in_the_tree.sql +80 -0
- package/package.json +2 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BoardDeps, BoardOperations } from "./board.types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every board operation, applied to the document rather than to the file (#285).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ This module is where a board's rules live, and it is deliberately pure: it takes a document
|
|
6
|
+
* and gives back the next one. The version, R2 and the retry against a racing write are the
|
|
7
|
+
* service's business (`nodes.ts`), and the surfaces have no rules of their own — HTTP, MCP and the
|
|
8
|
+
* UI all arrive here. A second implementation of "may this task depend on that one" is the thing
|
|
9
|
+
* this shape exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ What it does NOT check is anything that needs the tree: whether a `references` id names a node
|
|
12
|
+
* the writer may see, and whether an `agent` assignee is really an agent. Those are reads against
|
|
13
|
+
* D1 and the ACL, so they happen in the service before the document is touched at all.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createBoard(deps: BoardDeps): BoardOperations;
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
|
|
2
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
3
|
+
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
4
|
+
/**
|
|
5
|
+
* Every board operation, applied to the document rather than to the file (#285).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ This module is where a board's rules live, and it is deliberately pure: it takes a document
|
|
8
|
+
* and gives back the next one. The version, R2 and the retry against a racing write are the
|
|
9
|
+
* service's business (`nodes.ts`), and the surfaces have no rules of their own — HTTP, MCP and the
|
|
10
|
+
* UI all arrive here. A second implementation of "may this task depend on that one" is the thing
|
|
11
|
+
* this shape exists to prevent.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ What it does NOT check is anything that needs the tree: whether a `references` id names a node
|
|
14
|
+
* the writer may see, and whether an `agent` assignee is really an agent. Those are reads against
|
|
15
|
+
* D1 and the ACL, so they happen in the service before the document is touched at all.
|
|
16
|
+
*/
|
|
17
|
+
export function createBoard(deps) {
|
|
18
|
+
function byOrder(left, right) {
|
|
19
|
+
// The id breaks a tie that cannot happen: two keys are distinct by construction. It is here so
|
|
20
|
+
// the sort is total, because an unstable order would make two readers of the same board
|
|
21
|
+
// disagree about which card comes first.
|
|
22
|
+
if (left.order === right.order)
|
|
23
|
+
return left.id < right.id ? -1 : 1;
|
|
24
|
+
return left.order < right.order ? -1 : 1;
|
|
25
|
+
}
|
|
26
|
+
function sorted(tasks) {
|
|
27
|
+
return [...tasks].sort(byOrder);
|
|
28
|
+
}
|
|
29
|
+
function taskOf(board, taskId) {
|
|
30
|
+
const task = board.tasks.find((candidate) => candidate.id === taskId);
|
|
31
|
+
if (!task)
|
|
32
|
+
throw new IntelError(404, "board_task_not_found", "Task was not found");
|
|
33
|
+
return task;
|
|
34
|
+
}
|
|
35
|
+
function requireStatus(board, statusId) {
|
|
36
|
+
if (!board.statuses.some((status) => status.id === statusId)) {
|
|
37
|
+
throw new IntelError(422, "board_status_unknown", `This board has no status “${statusId}”`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// The column a task lands in when nobody says otherwise: the first one that is not the shelf.
|
|
41
|
+
function firstOpenStatus(board) {
|
|
42
|
+
const open = [...board.statuses]
|
|
43
|
+
.sort((left, right) => left.order - right.order)
|
|
44
|
+
.find((status) => status.id !== ArchivedBoardStatusId);
|
|
45
|
+
if (!open) {
|
|
46
|
+
throw new IntelError(409, "board_status_unknown", "This board has no status a task could be added to");
|
|
47
|
+
}
|
|
48
|
+
return open.id;
|
|
49
|
+
}
|
|
50
|
+
function childrenOf(tasks) {
|
|
51
|
+
const children = new Map();
|
|
52
|
+
for (const task of tasks) {
|
|
53
|
+
const siblings = children.get(task.parentId);
|
|
54
|
+
if (siblings)
|
|
55
|
+
siblings.push(task);
|
|
56
|
+
else
|
|
57
|
+
children.set(task.parentId, [task]);
|
|
58
|
+
}
|
|
59
|
+
return children;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The task and everything under it, breadth first.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ The walk is bounded by the number of tasks rather than by the depth, so a board whose
|
|
65
|
+
* `parentId` chain somehow closed on itself — one written before the cycle check, or restored
|
|
66
|
+
* from a bundle — is counted once and does not spin here.
|
|
67
|
+
*/
|
|
68
|
+
function withDescendants(tasks, rootId) {
|
|
69
|
+
const children = childrenOf(tasks);
|
|
70
|
+
const removed = new Set([rootId]);
|
|
71
|
+
const queue = [rootId];
|
|
72
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
73
|
+
const current = queue[index];
|
|
74
|
+
if (current === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
for (const child of children.get(current) ?? []) {
|
|
77
|
+
if (removed.has(child.id))
|
|
78
|
+
continue;
|
|
79
|
+
removed.add(child.id);
|
|
80
|
+
queue.push(child.id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return removed;
|
|
84
|
+
}
|
|
85
|
+
function depthOf(tasks, taskId) {
|
|
86
|
+
let depth = 0;
|
|
87
|
+
let current = taskId;
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
while (current !== null) {
|
|
90
|
+
if (seen.has(current))
|
|
91
|
+
break;
|
|
92
|
+
seen.add(current);
|
|
93
|
+
depth += 1;
|
|
94
|
+
current = tasks.find((task) => task.id === current)?.parentId ?? null;
|
|
95
|
+
}
|
|
96
|
+
return depth;
|
|
97
|
+
}
|
|
98
|
+
function heightOf(tasks, rootId) {
|
|
99
|
+
const children = childrenOf(tasks);
|
|
100
|
+
let height = 1;
|
|
101
|
+
let level = [rootId];
|
|
102
|
+
const seen = new Set(level);
|
|
103
|
+
while (level.length > 0) {
|
|
104
|
+
const next = [];
|
|
105
|
+
for (const id of level) {
|
|
106
|
+
for (const child of children.get(id) ?? []) {
|
|
107
|
+
if (seen.has(child.id))
|
|
108
|
+
continue;
|
|
109
|
+
seen.add(child.id);
|
|
110
|
+
next.push(child.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (next.length > 0)
|
|
114
|
+
height += 1;
|
|
115
|
+
level = next;
|
|
116
|
+
}
|
|
117
|
+
return height;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether this task may hang under that parent (#285).
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ Two refusals, not one, because they are two different mistakes. Hanging a task under its own
|
|
123
|
+
* descendant is a cycle — a subtree that has left the board without being deleted, invisible in
|
|
124
|
+
* every view. Hanging a legal subtree too deep is a bound: `BoardMaxTaskDepth` counts the whole
|
|
125
|
+
* chain, so moving an epic under another epic is measured with its own children, not as if it
|
|
126
|
+
* were a single card.
|
|
127
|
+
*/
|
|
128
|
+
function requireParent(tasks, taskId, parentId) {
|
|
129
|
+
if (parentId === null)
|
|
130
|
+
return;
|
|
131
|
+
if (parentId === taskId) {
|
|
132
|
+
throw new IntelError(409, "board_parent_cycle", "A task cannot be its own parent");
|
|
133
|
+
}
|
|
134
|
+
if (!tasks.some((task) => task.id === parentId)) {
|
|
135
|
+
throw new IntelError(422, "board_parent_unknown", "The parent task is not on this board");
|
|
136
|
+
}
|
|
137
|
+
if (withDescendants(tasks, taskId).has(parentId)) {
|
|
138
|
+
throw new IntelError(409, "board_parent_cycle", "A task cannot be moved under one of its own subtasks");
|
|
139
|
+
}
|
|
140
|
+
const depth = depthOf(tasks, parentId) + heightOf(tasks, taskId);
|
|
141
|
+
if (depth > BoardMaxTaskDepth) {
|
|
142
|
+
throw new IntelError(422, "board_depth_exceeded", `Tasks nest at most ${BoardMaxTaskDepth} deep, and this would be ${depth}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether these dependencies exist here and describe no circle (#285).
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ A dependency on a task this board does not hold is refused BECAUSE it might be a task in
|
|
149
|
+
* another board. Accepting it would hang this node on a file that can be edited, moved or deleted
|
|
150
|
+
* without anything here noticing — across boards the link is `references`, which names the board
|
|
151
|
+
* NODE and is a link the graph can see. "Unknown" and "in another board" are deliberately the same
|
|
152
|
+
* refusal: telling them apart would mean confirming that a task id exists somewhere the caller may
|
|
153
|
+
* have no access to.
|
|
154
|
+
*
|
|
155
|
+
* ⚠️ Own check, run over the whole graph and separate from the `parentId` walk. The two describe
|
|
156
|
+
* different things — where a card sits and what it waits for — and a subtask that waits for its
|
|
157
|
+
* own epic is legal in one and would be a cycle in the other.
|
|
158
|
+
*/
|
|
159
|
+
function requireDependencies(tasks, taskId) {
|
|
160
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
161
|
+
for (const task of tasks) {
|
|
162
|
+
for (const dependency of task.dependsOn) {
|
|
163
|
+
if (!byId.has(dependency)) {
|
|
164
|
+
throw new IntelError(422, "board_dependency_unknown", `A task can only depend on a task of the same board, and “${dependency}” is not one`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Depth-first from the changed task alone: everything else was acyclic before this write, so a
|
|
169
|
+
// circle that exists now has to run through the one task that changed.
|
|
170
|
+
const stack = [taskId];
|
|
171
|
+
const path = new Set();
|
|
172
|
+
const done = new Set();
|
|
173
|
+
const visit = (id) => {
|
|
174
|
+
if (done.has(id))
|
|
175
|
+
return;
|
|
176
|
+
if (path.has(id)) {
|
|
177
|
+
throw new IntelError(409, "board_dependency_cycle", "These dependencies would wait for each other in a circle");
|
|
178
|
+
}
|
|
179
|
+
path.add(id);
|
|
180
|
+
for (const dependency of byId.get(id)?.dependsOn ?? [])
|
|
181
|
+
visit(dependency);
|
|
182
|
+
path.delete(id);
|
|
183
|
+
done.add(id);
|
|
184
|
+
};
|
|
185
|
+
for (const id of stack)
|
|
186
|
+
visit(id);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The order key for a task's new place.
|
|
190
|
+
*
|
|
191
|
+
* ⚠️ Neighbours are read from the board's ONE key space rather than from the target column. A key
|
|
192
|
+
* minted between two cards is between them globally, and a filtered view of a sorted list is still
|
|
193
|
+
* sorted, so a column shows exactly what the caller asked for while no second key space has to be
|
|
194
|
+
* kept in step with the first.
|
|
195
|
+
*
|
|
196
|
+
* With no neighbour named, the task goes last in the column it is landing in — which is what
|
|
197
|
+
* "add a task" means without further instruction, and what a drop onto an empty column means.
|
|
198
|
+
*/
|
|
199
|
+
function placementKey(board, placement, movingTaskId) {
|
|
200
|
+
const others = sorted(board.tasks.filter((task) => task.id !== movingTaskId));
|
|
201
|
+
const indexOf = (taskId) => {
|
|
202
|
+
const index = others.findIndex((task) => task.id === taskId);
|
|
203
|
+
if (index < 0) {
|
|
204
|
+
throw new IntelError(422, "board_neighbour_unknown", "The neighbour task is not on this board");
|
|
205
|
+
}
|
|
206
|
+
return index;
|
|
207
|
+
};
|
|
208
|
+
let afterIndex = placement.afterTaskId === null ? null : indexOf(placement.afterTaskId);
|
|
209
|
+
const beforeIndex = placement.beforeTaskId === null ? null : indexOf(placement.beforeTaskId);
|
|
210
|
+
if (afterIndex !== null && beforeIndex !== null && afterIndex >= beforeIndex) {
|
|
211
|
+
throw new IntelError(400, "board_neighbour_order", "The task named as the predecessor does not come before the one named as the successor");
|
|
212
|
+
}
|
|
213
|
+
if (afterIndex === null && beforeIndex === null) {
|
|
214
|
+
const column = others.filter((task) => task.status === placement.status && task.parentId === placement.parentId);
|
|
215
|
+
const last = column.at(-1);
|
|
216
|
+
if (!last)
|
|
217
|
+
return generateKeyBetween(others.at(-1)?.order ?? null, null);
|
|
218
|
+
afterIndex = others.indexOf(last);
|
|
219
|
+
}
|
|
220
|
+
const lower = afterIndex === null ? null : (others[afterIndex]?.order ?? null);
|
|
221
|
+
const upper = beforeIndex !== null
|
|
222
|
+
? (others[beforeIndex]?.order ?? null)
|
|
223
|
+
: afterIndex !== null
|
|
224
|
+
? (others[afterIndex + 1]?.order ?? null)
|
|
225
|
+
: null;
|
|
226
|
+
return generateKeyBetween(lower, upper);
|
|
227
|
+
}
|
|
228
|
+
function replaced(board, task) {
|
|
229
|
+
return {
|
|
230
|
+
statuses: board.statuses,
|
|
231
|
+
tasks: board.tasks.map((candidate) => (candidate.id === task.id ? task : candidate)),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
defaultBoard() {
|
|
236
|
+
return { statuses: [...BoardDefaultStatuses], tasks: [] };
|
|
237
|
+
},
|
|
238
|
+
/**
|
|
239
|
+
* The status list, written whole.
|
|
240
|
+
*
|
|
241
|
+
* ⚠️ `archived` is asserted here as well as at the boundary, and that is not the usual
|
|
242
|
+
* duplication. It is the rule the whole "sweep it away instead of deleting it" promise rests
|
|
243
|
+
* on, and a board that lost its shelf cannot be repaired by the caller who lost it — every task
|
|
244
|
+
* sitting there would point at a status that no longer exists.
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ A status that tasks still sit in cannot be dropped. The alternative — moving those tasks
|
|
247
|
+
* somewhere — is a decision about somebody's work, and this call is not the place to make it
|
|
248
|
+
* silently.
|
|
249
|
+
*/
|
|
250
|
+
configure(board, statuses) {
|
|
251
|
+
if (!statuses.some((status) => status.id === ArchivedBoardStatusId)) {
|
|
252
|
+
throw new IntelError(422, "board_status_archived_required", `The “${ArchivedBoardStatusId}” status cannot be removed`);
|
|
253
|
+
}
|
|
254
|
+
const kept = new Set(statuses.map((status) => status.id));
|
|
255
|
+
const orphaned = board.tasks.find((task) => !kept.has(task.status));
|
|
256
|
+
if (orphaned) {
|
|
257
|
+
throw new IntelError(409, "board_status_in_use", `Tasks still sit in “${orphaned.status}”, so it cannot be removed`);
|
|
258
|
+
}
|
|
259
|
+
const next = statuses.map((status, index) => ({
|
|
260
|
+
id: status.id,
|
|
261
|
+
label: status.label,
|
|
262
|
+
order: index,
|
|
263
|
+
}));
|
|
264
|
+
return { statuses: next, tasks: board.tasks };
|
|
265
|
+
},
|
|
266
|
+
addTask(board, input) {
|
|
267
|
+
if (board.tasks.length >= 5_000) {
|
|
268
|
+
throw new IntelError(409, "board_task_limit", "This board holds as many tasks as it can");
|
|
269
|
+
}
|
|
270
|
+
const status = input.status ?? firstOpenStatus(board);
|
|
271
|
+
requireStatus(board, status);
|
|
272
|
+
const task = {
|
|
273
|
+
id: deps.id(),
|
|
274
|
+
title: input.title,
|
|
275
|
+
status,
|
|
276
|
+
assignee: input.assignee,
|
|
277
|
+
labels: input.labels,
|
|
278
|
+
startDate: input.startDate,
|
|
279
|
+
dueDate: input.dueDate,
|
|
280
|
+
parentId: input.parentId,
|
|
281
|
+
dependsOn: input.dependsOn,
|
|
282
|
+
order: placementKey(board, {
|
|
283
|
+
status,
|
|
284
|
+
parentId: input.parentId,
|
|
285
|
+
afterTaskId: input.afterTaskId,
|
|
286
|
+
beforeTaskId: input.beforeTaskId,
|
|
287
|
+
}, null),
|
|
288
|
+
description: input.description,
|
|
289
|
+
references: input.references,
|
|
290
|
+
};
|
|
291
|
+
const tasks = [...board.tasks, task];
|
|
292
|
+
requireParent(tasks, task.id, task.parentId);
|
|
293
|
+
requireDependencies(tasks, task.id);
|
|
294
|
+
return { board: { statuses: board.statuses, tasks }, task };
|
|
295
|
+
},
|
|
296
|
+
updateTask(board, input) {
|
|
297
|
+
const current = taskOf(board, input.taskId);
|
|
298
|
+
const task = {
|
|
299
|
+
...current,
|
|
300
|
+
...(input.title !== undefined && { title: input.title }),
|
|
301
|
+
...(input.assignee !== undefined && { assignee: input.assignee }),
|
|
302
|
+
...(input.labels !== undefined && { labels: input.labels }),
|
|
303
|
+
...(input.startDate !== undefined && { startDate: input.startDate }),
|
|
304
|
+
...(input.dueDate !== undefined && { dueDate: input.dueDate }),
|
|
305
|
+
...(input.dependsOn !== undefined && { dependsOn: input.dependsOn }),
|
|
306
|
+
...(input.description !== undefined && { description: input.description }),
|
|
307
|
+
...(input.references !== undefined && { references: input.references }),
|
|
308
|
+
};
|
|
309
|
+
const next = replaced(board, task);
|
|
310
|
+
requireDependencies(next.tasks, task.id);
|
|
311
|
+
return { board: next, task };
|
|
312
|
+
},
|
|
313
|
+
moveTask(board, input) {
|
|
314
|
+
const current = taskOf(board, input.taskId);
|
|
315
|
+
const status = input.status ?? current.status;
|
|
316
|
+
requireStatus(board, status);
|
|
317
|
+
const parentId = input.parentId === undefined ? current.parentId : input.parentId;
|
|
318
|
+
// The parent is checked against the board as it stands, before the order key is minted: a
|
|
319
|
+
// refused move must not have spent one, and `placementKey` is the only step that cannot be
|
|
320
|
+
// undone by simply not returning the document.
|
|
321
|
+
requireParent(board.tasks, current.id, parentId);
|
|
322
|
+
const task = {
|
|
323
|
+
...current,
|
|
324
|
+
status,
|
|
325
|
+
parentId,
|
|
326
|
+
order: placementKey(board, {
|
|
327
|
+
status,
|
|
328
|
+
parentId,
|
|
329
|
+
afterTaskId: input.afterTaskId,
|
|
330
|
+
beforeTaskId: input.beforeTaskId,
|
|
331
|
+
}, current.id),
|
|
332
|
+
};
|
|
333
|
+
return { board: replaced(board, task), task };
|
|
334
|
+
},
|
|
335
|
+
/**
|
|
336
|
+
* The task and everything under it (#285).
|
|
337
|
+
*
|
|
338
|
+
* ⚠️ Deleting cascades rather than orphaning: a subtask whose epic is gone has no place on any
|
|
339
|
+
* board, and leaving it behind under a `parentId` that names nothing is a card no view can file.
|
|
340
|
+
* The count comes back so a surface can say what it is about to do.
|
|
341
|
+
*
|
|
342
|
+
* ⚠️ The dependencies of what survives are cleaned up in the same act. A `dependsOn` pointing at
|
|
343
|
+
* a deleted task is an edge to nothing — it would draw a line into empty space in the graph view
|
|
344
|
+
* and make a task wait forever for something that cannot happen.
|
|
345
|
+
*/
|
|
346
|
+
deleteTask(board, taskId) {
|
|
347
|
+
taskOf(board, taskId);
|
|
348
|
+
const removed = withDescendants(board.tasks, taskId);
|
|
349
|
+
const tasks = board.tasks
|
|
350
|
+
.filter((task) => !removed.has(task.id))
|
|
351
|
+
.map((task) => {
|
|
352
|
+
const dependsOn = task.dependsOn.filter((dependency) => !removed.has(dependency));
|
|
353
|
+
return dependsOn.length === task.dependsOn.length ? task : { ...task, dependsOn };
|
|
354
|
+
});
|
|
355
|
+
return { board: { statuses: board.statuses, tasks }, deleted: removed.size };
|
|
356
|
+
},
|
|
357
|
+
sorted,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AddBoardTaskInput, BoardDocument, BoardStatusInput, BoardTask, MoveBoardTaskInput, UpdateBoardTaskInput } from "@anchrd/intel-contract";
|
|
2
|
+
export interface BoardDeps {
|
|
3
|
+
id(): string;
|
|
4
|
+
}
|
|
5
|
+
export interface BoardPlacement {
|
|
6
|
+
status: string;
|
|
7
|
+
parentId: string | null;
|
|
8
|
+
afterTaskId: string | null;
|
|
9
|
+
beforeTaskId: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface BoardOperations {
|
|
12
|
+
defaultBoard(): BoardDocument;
|
|
13
|
+
configure(board: BoardDocument, statuses: BoardStatusInput[]): BoardDocument;
|
|
14
|
+
addTask(board: BoardDocument, input: AddBoardTaskInput): {
|
|
15
|
+
board: BoardDocument;
|
|
16
|
+
task: BoardTask;
|
|
17
|
+
};
|
|
18
|
+
updateTask(board: BoardDocument, input: UpdateBoardTaskInput): {
|
|
19
|
+
board: BoardDocument;
|
|
20
|
+
task: BoardTask;
|
|
21
|
+
};
|
|
22
|
+
moveTask(board: BoardDocument, input: MoveBoardTaskInput): {
|
|
23
|
+
board: BoardDocument;
|
|
24
|
+
task: BoardTask;
|
|
25
|
+
};
|
|
26
|
+
deleteTask(board: BoardDocument, taskId: string): {
|
|
27
|
+
board: BoardDocument;
|
|
28
|
+
deleted: number;
|
|
29
|
+
};
|
|
30
|
+
sorted(tasks: readonly BoardTask[]): BoardTask[];
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* newer editor wrote; a strict parse would turn "I do not recognise this block" into "this document
|
|
10
10
|
* cannot be saved". What it does not recognise contributes no link, which is the safe half.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* ⚠️ A board reaches the same link graph, through the same `text` origin (#285). Its links are not
|
|
13
|
+
* inline elements but the `references` of its tasks, which is a difference in where they are
|
|
14
|
+
* written and not in what they are: somebody put a node id into a body, and the graph has to see it
|
|
15
|
+
* from the other side. Reading it here rather than in the board service is what keeps ONE answer to
|
|
16
|
+
* "what does this body point at" — the writer (`reconcileTextLinks`) asks this function and does
|
|
17
|
+
* not care which kind it is holding.
|
|
18
|
+
*
|
|
19
|
+
* Content that is neither has no links at all: an attachment, a CSV table or plain markdown carries
|
|
20
|
+
* nothing to read.
|
|
14
21
|
*/
|
|
15
22
|
export declare function documentLinkTargets(mediaType: string, content: string): string[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BlockNoteDocument, BlockNoteMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
|
|
1
|
+
import { BlockNoteDocument, BlockNoteMediaType, BoardDocument, BoardMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
|
|
2
2
|
function isRecord(value) {
|
|
3
3
|
return typeof value === "object" && value !== null;
|
|
4
4
|
}
|
|
@@ -30,11 +30,18 @@ function collect(value, found) {
|
|
|
30
30
|
* newer editor wrote; a strict parse would turn "I do not recognise this block" into "this document
|
|
31
31
|
* cannot be saved". What it does not recognise contributes no link, which is the safe half.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* ⚠️ A board reaches the same link graph, through the same `text` origin (#285). Its links are not
|
|
34
|
+
* inline elements but the `references` of its tasks, which is a difference in where they are
|
|
35
|
+
* written and not in what they are: somebody put a node id into a body, and the graph has to see it
|
|
36
|
+
* from the other side. Reading it here rather than in the board service is what keeps ONE answer to
|
|
37
|
+
* "what does this body point at" — the writer (`reconcileTextLinks`) asks this function and does
|
|
38
|
+
* not care which kind it is holding.
|
|
39
|
+
*
|
|
40
|
+
* Content that is neither has no links at all: an attachment, a CSV table or plain markdown carries
|
|
41
|
+
* nothing to read.
|
|
35
42
|
*/
|
|
36
43
|
export function documentLinkTargets(mediaType, content) {
|
|
37
|
-
if (mediaType !== BlockNoteMediaType)
|
|
44
|
+
if (mediaType !== BlockNoteMediaType && mediaType !== BoardMediaType)
|
|
38
45
|
return [];
|
|
39
46
|
let parsed;
|
|
40
47
|
try {
|
|
@@ -43,10 +50,20 @@ export function documentLinkTargets(mediaType, content) {
|
|
|
43
50
|
catch {
|
|
44
51
|
return [];
|
|
45
52
|
}
|
|
53
|
+
const found = new Set();
|
|
54
|
+
if (mediaType === BoardMediaType) {
|
|
55
|
+
const board = BoardDocument.safeParse(parsed);
|
|
56
|
+
if (!board.success)
|
|
57
|
+
return [];
|
|
58
|
+
for (const task of board.data.tasks) {
|
|
59
|
+
for (const reference of task.references)
|
|
60
|
+
found.add(reference);
|
|
61
|
+
}
|
|
62
|
+
return [...found];
|
|
63
|
+
}
|
|
46
64
|
const document = BlockNoteDocument.safeParse(parsed);
|
|
47
65
|
if (!document.success)
|
|
48
66
|
return [];
|
|
49
|
-
const found = new Set();
|
|
50
67
|
collect(document.data.blocks, found);
|
|
51
68
|
return [...found];
|
|
52
69
|
}
|