@starlove/orbit 1.1.2 → 1.1.11
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/.dockerignore +0 -0
- package/Dockerfile +0 -0
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/SKILL-ORBIT.md +2 -0
- package/build.mjs +0 -0
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +160 -15
- package/dist/full/js/app.55b38e12.js +698 -0
- package/dist/full/js/app.55b38e12.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/{styles.836872e7.css → styles.273fc37a.css} +79 -39
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +0 -0
- package/docs/MCP_SETUP.md +0 -0
- package/docs/ORBIT_DISPATCH.md +68 -8
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -0
- package/docs/setup/orbit-init-wizard.md +0 -0
- package/package.json +1 -1
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +158 -13
- package/public/js/actor-labels.js +0 -0
- package/public/js/api.js +0 -0
- package/public/js/app.js +0 -0
- package/public/js/board-menu.js +0 -0
- package/public/js/card-actions.js +19 -0
- package/public/js/config.js +0 -0
- package/public/js/create-card.js +0 -0
- package/public/js/dom.js +0 -0
- package/public/js/drawer.js +17 -43
- package/public/js/format.js +5 -5
- package/public/js/kanban.js +157 -92
- package/public/js/local-backend.js +0 -0
- package/public/js/main.js +2 -1
- package/public/js/modal.js +0 -0
- package/public/js/motion-preference.js +14 -3
- package/public/js/router.js +0 -0
- package/public/js/search.js +0 -0
- package/public/js/settings-tabs.js +0 -0
- package/public/js/settings.js +131 -27
- package/public/js/sse.js +0 -0
- package/public/js/starscape.js +0 -0
- package/public/js/state.js +0 -0
- package/public/js/theme-preference.js +0 -0
- package/public/js/ticket-detail.js +88 -20
- package/public/js/toast.js +0 -0
- package/public/js/transport.js +0 -0
- package/public/js/unread.js +0 -0
- package/public/js/url-routes.js +0 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +79 -39
- package/src/cli/orbit.js +22 -0
- package/src/core/agent.js +0 -0
- package/src/core/agents-md.js +0 -0
- package/src/core/attachments.js +0 -0
- package/src/core/auth.js +0 -0
- package/src/core/backups.js +0 -0
- package/src/core/board.js +1 -0
- package/src/core/boards.js +0 -0
- package/src/core/bootstrap.js +0 -0
- package/src/core/db.js +19 -0
- package/src/core/delete-board.js +0 -0
- package/src/core/dispatch.js +176 -11
- package/src/core/events.js +0 -0
- package/src/core/http.js +0 -0
- package/src/core/migrate.js +0 -0
- package/src/core/paths.js +0 -0
- package/src/core/provision-repo-board.js +0 -0
- package/src/core/queries.js +0 -0
- package/src/core/registry.js +2 -2
- package/src/core/relations.js +0 -0
- package/src/core/review-verdicts.js +138 -0
- package/src/core/router.js +32 -3
- package/src/core/search.js +0 -0
- package/src/core/seed.js +0 -0
- package/src/core/snapshots.js +429 -9
- package/src/core/states.js +0 -0
- package/src/core/system-picker.js +0 -0
- package/src/core/tickets.js +0 -0
- package/src/core/util.js +0 -0
- package/src/mcp/orbit-client.js +45 -19
- package/src/mcp-server.js +102 -27
- package/src/server.js +0 -0
- package/dist/full/js/app.a4f045fc.js +0 -676
- package/dist/full/js/app.a4f045fc.js.map +0 -7
package/src/core/snapshots.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { resetBoard, tx } from "./db.js";
|
|
1
|
+
import { resetBoard, tx, getRegistry } from "./db.js";
|
|
2
2
|
import { backupBoardDatabase } from "./backups.js";
|
|
3
3
|
import { boardById, labelsForTicket } from "./queries.js";
|
|
4
4
|
import { requireBoardAccess } from "./auth.js";
|
|
5
5
|
import { recordEvent } from "./events.js";
|
|
6
|
-
import { syncRegistryFromBoardDb } from "./registry.js";
|
|
6
|
+
import { computeNewBoardDbPath, ensureBoardDbFileAndSchema, syncRegistryFromBoardDb } from "./registry.js";
|
|
7
7
|
import {
|
|
8
8
|
httpError,
|
|
9
|
+
id,
|
|
9
10
|
normalizePriority,
|
|
10
11
|
normalizeProjectEntryType,
|
|
11
12
|
normalizeTicketType,
|
|
12
13
|
now,
|
|
13
|
-
|
|
14
|
+
normalizePath,
|
|
15
|
+
requiredString,
|
|
16
|
+
slugify
|
|
14
17
|
} from "./util.js";
|
|
15
18
|
import { reindexAllTickets } from "./tickets.js";
|
|
16
19
|
import {
|
|
@@ -37,6 +40,11 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
37
40
|
.prepare("SELECT * FROM comments WHERE ticket_id IN (SELECT id FROM tickets WHERE board_id = ?) ORDER BY created_at ASC")
|
|
38
41
|
.all(boardId)
|
|
39
42
|
: [];
|
|
43
|
+
const reviewVerdicts = ticketIds.length
|
|
44
|
+
? db
|
|
45
|
+
.prepare("SELECT * FROM review_verdicts WHERE ticket_id IN (SELECT id FROM tickets WHERE board_id = ?) ORDER BY created_at ASC")
|
|
46
|
+
.all(boardId)
|
|
47
|
+
: [];
|
|
40
48
|
const relations = ticketIds.length
|
|
41
49
|
? db
|
|
42
50
|
.prepare(
|
|
@@ -67,7 +75,7 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
67
75
|
|
|
68
76
|
return {
|
|
69
77
|
format: "orbit-board-export",
|
|
70
|
-
version:
|
|
78
|
+
version: 7,
|
|
71
79
|
exported_at: now(),
|
|
72
80
|
include_attachments: Boolean(options.includeAttachments),
|
|
73
81
|
board: { ...innerBoard },
|
|
@@ -75,6 +83,7 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
75
83
|
labels,
|
|
76
84
|
tickets,
|
|
77
85
|
comments,
|
|
86
|
+
review_verdicts: reviewVerdicts,
|
|
78
87
|
relations,
|
|
79
88
|
attachments,
|
|
80
89
|
board_entries: entries,
|
|
@@ -84,7 +93,7 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
84
93
|
|
|
85
94
|
export function importBoardSnapshot(body, ctx) {
|
|
86
95
|
const { db, actor, board } = ctx;
|
|
87
|
-
const snapshot = body?.snapshot || body;
|
|
96
|
+
const snapshot = normalizeImportSnapshot(body?.snapshot || body, ctx);
|
|
88
97
|
const format = snapshot?.format;
|
|
89
98
|
if (!snapshot || format !== "orbit-board-export") throw httpError(400, "invalid_import_snapshot");
|
|
90
99
|
if (!snapshot.board) throw httpError(400, "snapshot_missing_board");
|
|
@@ -160,8 +169,8 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
160
169
|
`INSERT INTO tickets
|
|
161
170
|
(id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
|
|
162
171
|
implementation_summary, implementation_updates, state_id, priority,
|
|
163
|
-
created_by, created_at, updated_at)
|
|
164
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
172
|
+
created_by, created_at, updated_at, archived_at)
|
|
173
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
165
174
|
).run(
|
|
166
175
|
ticket.id,
|
|
167
176
|
targetBoardId,
|
|
@@ -177,7 +186,8 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
177
186
|
normalizePriority(ticket.priority),
|
|
178
187
|
ticket.created_by || "import",
|
|
179
188
|
ticket.created_at || now(),
|
|
180
|
-
ticket.updated_at || now()
|
|
189
|
+
ticket.updated_at || now(),
|
|
190
|
+
ticket.archived_at || null
|
|
181
191
|
);
|
|
182
192
|
|
|
183
193
|
for (const label of ticket.labels || []) {
|
|
@@ -198,6 +208,31 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
198
208
|
);
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
for (const review of snapshot.review_verdicts || []) {
|
|
212
|
+
db.prepare(
|
|
213
|
+
`INSERT INTO review_verdicts
|
|
214
|
+
(id, ticket_id, verdict, blocking_findings_json, optional_findings_json,
|
|
215
|
+
evidence_commands_json, reviewer_profile, reviewer_session_id,
|
|
216
|
+
reviewed_commit_sha, dispatch_run_id, supersedes_prior_review_id,
|
|
217
|
+
created_by, created_at)
|
|
218
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
219
|
+
).run(
|
|
220
|
+
review.id,
|
|
221
|
+
review.ticket_id,
|
|
222
|
+
review.verdict,
|
|
223
|
+
review.blocking_findings_json || "[]",
|
|
224
|
+
review.optional_findings_json || "[]",
|
|
225
|
+
review.evidence_commands_json || "[]",
|
|
226
|
+
review.reviewer_profile || "",
|
|
227
|
+
review.reviewer_session_id || "",
|
|
228
|
+
review.reviewed_commit_sha || "",
|
|
229
|
+
review.dispatch_run_id || "",
|
|
230
|
+
review.supersedes_prior_review_id || null,
|
|
231
|
+
review.created_by || "import",
|
|
232
|
+
review.created_at || now()
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
201
236
|
for (const relation of snapshot.relations || []) {
|
|
202
237
|
db.prepare(
|
|
203
238
|
"INSERT OR IGNORE INTO relations (id, source_ticket_id, target_ticket_id, type, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
@@ -256,7 +291,14 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
256
291
|
ok: true,
|
|
257
292
|
imported_board_id: targetBoardId,
|
|
258
293
|
imported_board_slug: importedBoard.slug,
|
|
259
|
-
replace_existing: replaceExisting
|
|
294
|
+
replace_existing: replaceExisting,
|
|
295
|
+
source_format: snapshot.source_format || "orbit-board-export",
|
|
296
|
+
imported_counts: snapshot.imported_counts || {
|
|
297
|
+
states: (snapshot.states || []).length,
|
|
298
|
+
tickets: (snapshot.tickets || []).length,
|
|
299
|
+
comments: (snapshot.comments || []).length,
|
|
300
|
+
labels: (snapshot.labels || []).length
|
|
301
|
+
}
|
|
260
302
|
};
|
|
261
303
|
});
|
|
262
304
|
|
|
@@ -265,3 +307,381 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
265
307
|
syncRegistryFromBoardDb(board, db);
|
|
266
308
|
return result;
|
|
267
309
|
}
|
|
310
|
+
|
|
311
|
+
export function importBoardSnapshotAsNewBoard(body, ctx) {
|
|
312
|
+
const rawSnapshot = body?.snapshot || body;
|
|
313
|
+
const actor = ctx.actor;
|
|
314
|
+
const sourceBoard = ctx.board || {};
|
|
315
|
+
const repoPath = normalizePath(body?.repo_path || body?.system_path || sourceBoard.repo_path || sourceBoard.system_path || process.cwd());
|
|
316
|
+
const sourceFormatSnapshot = normalizeImportSnapshot(rawSnapshot, {
|
|
317
|
+
...ctx,
|
|
318
|
+
board: {
|
|
319
|
+
id: id(),
|
|
320
|
+
slug: "",
|
|
321
|
+
name: "",
|
|
322
|
+
repo_path: repoPath,
|
|
323
|
+
system_path: repoPath,
|
|
324
|
+
repo_url: sourceBoard.repo_url || "",
|
|
325
|
+
default_branch: sourceBoard.default_branch || "main"
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
const format = sourceFormatSnapshot?.format;
|
|
329
|
+
if (!sourceFormatSnapshot || format !== "orbit-board-export") throw httpError(400, "invalid_import_snapshot");
|
|
330
|
+
if (!sourceFormatSnapshot.board) throw httpError(400, "snapshot_missing_board");
|
|
331
|
+
|
|
332
|
+
const boardName = cleanText(sourceFormatSnapshot.board.name) || cleanText(rawSnapshot?.name) || "Imported Board";
|
|
333
|
+
const boardSlug = uniqueImportedBoardSlug(body?.slug || sourceFormatSnapshot.board.slug || rawSnapshot?.name || boardName);
|
|
334
|
+
const boardId = id();
|
|
335
|
+
const dbPath = computeNewBoardDbPath(repoPath, boardSlug);
|
|
336
|
+
const db = ensureBoardDbFileAndSchema(dbPath);
|
|
337
|
+
const newBoardCtx = {
|
|
338
|
+
actor,
|
|
339
|
+
board: {
|
|
340
|
+
id: boardId,
|
|
341
|
+
slug: boardSlug,
|
|
342
|
+
name: boardName,
|
|
343
|
+
repo_path: repoPath,
|
|
344
|
+
db_path: dbPath,
|
|
345
|
+
system_path: repoPath,
|
|
346
|
+
repo_url: sourceBoard.repo_url || sourceFormatSnapshot.board.repo_url || "",
|
|
347
|
+
default_branch: sourceBoard.default_branch || sourceFormatSnapshot.board.default_branch || "main"
|
|
348
|
+
},
|
|
349
|
+
db
|
|
350
|
+
};
|
|
351
|
+
const normalizedSnapshot = normalizeImportSnapshot(rawSnapshot, newBoardCtx);
|
|
352
|
+
const snapshotForNewBoard = {
|
|
353
|
+
...normalizedSnapshot,
|
|
354
|
+
board: {
|
|
355
|
+
...normalizedSnapshot.board,
|
|
356
|
+
id: boardId,
|
|
357
|
+
slug: boardSlug,
|
|
358
|
+
name: cleanText(normalizedSnapshot.board?.name) || boardName,
|
|
359
|
+
repo_url: normalizedSnapshot.board?.repo_url || sourceBoard.repo_url || "",
|
|
360
|
+
system_path: normalizedSnapshot.board?.system_path || repoPath,
|
|
361
|
+
default_branch: normalizedSnapshot.board?.default_branch || sourceBoard.default_branch || "main"
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...importBoardSnapshot({ snapshot: snapshotForNewBoard, replace_existing: false }, newBoardCtx),
|
|
367
|
+
created_new_board: true
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function uniqueImportedBoardSlug(value) {
|
|
372
|
+
const base = slugify(value) || `imported-board-${id().slice(0, 8)}`;
|
|
373
|
+
const reg = getRegistry();
|
|
374
|
+
if (!reg.prepare("SELECT id FROM boards WHERE slug = ?").get(base)) return base;
|
|
375
|
+
for (let i = 2; i < 1000; i += 1) {
|
|
376
|
+
const candidate = `${base}-${i}`;
|
|
377
|
+
if (!reg.prepare("SELECT id FROM boards WHERE slug = ?").get(candidate)) return candidate;
|
|
378
|
+
}
|
|
379
|
+
return `${base}-${id().slice(0, 8)}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function normalizeImportSnapshot(snapshot, ctx) {
|
|
383
|
+
if (snapshot?.format === "orbit-board-export") return snapshot;
|
|
384
|
+
if (looksLikeTrelloExport(snapshot)) return trelloExportToOrbitSnapshot(snapshot, ctx);
|
|
385
|
+
return snapshot;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function looksLikeTrelloExport(value) {
|
|
389
|
+
return Boolean(
|
|
390
|
+
value &&
|
|
391
|
+
typeof value === "object" &&
|
|
392
|
+
Array.isArray(value.cards) &&
|
|
393
|
+
Array.isArray(value.lists) &&
|
|
394
|
+
(typeof value.id === "string" || typeof value.name === "string")
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const TRELLO_LABEL_COLORS = {
|
|
399
|
+
green: "#61bd4f",
|
|
400
|
+
yellow: "#f2d600",
|
|
401
|
+
orange: "#ff9f1a",
|
|
402
|
+
red: "#eb5a46",
|
|
403
|
+
purple: "#c377e0",
|
|
404
|
+
blue: "#0079bf",
|
|
405
|
+
sky: "#00c2e0",
|
|
406
|
+
lime: "#51e898",
|
|
407
|
+
pink: "#ff78cb",
|
|
408
|
+
black: "#344563"
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
function trelloExportToOrbitSnapshot(trello, ctx) {
|
|
412
|
+
const time = now();
|
|
413
|
+
const targetBoard = ctx.board || {};
|
|
414
|
+
const boardName = cleanText(trello.name) || "Imported Trello Board";
|
|
415
|
+
const boardSlug = targetBoard.slug || slugify(boardName) || `trello-${id().slice(0, 8)}`;
|
|
416
|
+
const labelsById = buildTrelloLabelMap(trello, time);
|
|
417
|
+
const checklistsByCard = groupTrelloChecklists(trello);
|
|
418
|
+
const commentsByCard = groupTrelloComments(trello);
|
|
419
|
+
const sortedLists = [...(trello.lists || [])].sort(compareTrelloPosition);
|
|
420
|
+
const states = buildTrelloStates(sortedLists, time);
|
|
421
|
+
const fallbackStateId = states[0]?.id;
|
|
422
|
+
const stateIdsByTrelloListId = new Map(sortedLists.map((list, index) => [list.id, states[index]?.id || fallbackStateId]));
|
|
423
|
+
const sortedCards = [...(trello.cards || [])].sort(compareTrelloPosition);
|
|
424
|
+
const tickets = [];
|
|
425
|
+
const comments = [];
|
|
426
|
+
|
|
427
|
+
sortedCards.forEach((card, index) => {
|
|
428
|
+
const ticketId = trelloScopedId("card", card.id || `${index + 1}`);
|
|
429
|
+
const stateId = stateIdsByTrelloListId.get(card.idList) || fallbackStateId;
|
|
430
|
+
if (!stateId) return;
|
|
431
|
+
const cardUpdatedAt = cleanText(card.dateLastActivity) || time;
|
|
432
|
+
const cardCreatedAt = dateFromTrelloObjectId(card.id) || cardUpdatedAt;
|
|
433
|
+
const cardComments = commentsByCard.get(card.id) || [];
|
|
434
|
+
const ticketLabels = trelloLabelsForCard(card, labelsById);
|
|
435
|
+
const importedDescription = renderTrelloCardDescription(card, {
|
|
436
|
+
listName: listNameForCard(trello.lists, card.idList),
|
|
437
|
+
checklists: checklistsByCard.get(card.id) || []
|
|
438
|
+
});
|
|
439
|
+
tickets.push({
|
|
440
|
+
id: ticketId,
|
|
441
|
+
number: index + 1,
|
|
442
|
+
title: cleanText(card.name) || `Untitled Trello card ${index + 1}`,
|
|
443
|
+
description: importedDescription,
|
|
444
|
+
type: "task",
|
|
445
|
+
parent_ticket_id: null,
|
|
446
|
+
ai_plan: "",
|
|
447
|
+
implementation_summary: "",
|
|
448
|
+
implementation_updates: "",
|
|
449
|
+
state_id: stateId,
|
|
450
|
+
priority: priorityFromTrelloCard(card),
|
|
451
|
+
created_by: "trello-import",
|
|
452
|
+
created_at: cardCreatedAt,
|
|
453
|
+
updated_at: cardUpdatedAt,
|
|
454
|
+
archived_at: card.closed ? cardUpdatedAt : null,
|
|
455
|
+
labels: ticketLabels
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
cardComments.forEach((action, commentIndex) => {
|
|
459
|
+
const commentId = trelloScopedId("comment", action.id || `${card.id}-${commentIndex + 1}`);
|
|
460
|
+
comments.push({
|
|
461
|
+
id: commentId,
|
|
462
|
+
ticket_id: ticketId,
|
|
463
|
+
author: trelloMemberName(action.memberCreator),
|
|
464
|
+
kind: "human_comment",
|
|
465
|
+
body: cleanText(action.data?.text) || "",
|
|
466
|
+
created_at: cleanText(action.date) || cardUpdatedAt
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
format: "orbit-board-export",
|
|
473
|
+
source_format: "trello-board-export",
|
|
474
|
+
version: 7,
|
|
475
|
+
exported_at: time,
|
|
476
|
+
include_attachments: false,
|
|
477
|
+
imported_counts: {
|
|
478
|
+
states: states.length,
|
|
479
|
+
tickets: tickets.length,
|
|
480
|
+
comments: comments.length,
|
|
481
|
+
labels: labelsById.size
|
|
482
|
+
},
|
|
483
|
+
board: {
|
|
484
|
+
id: targetBoard.id || trelloScopedId("board", trello.id || id()),
|
|
485
|
+
slug: boardSlug,
|
|
486
|
+
name: boardName,
|
|
487
|
+
repo_url: targetBoard.repo_url || "",
|
|
488
|
+
system_path: targetBoard.system_path || targetBoard.repo_path || "",
|
|
489
|
+
default_branch: targetBoard.default_branch || "main",
|
|
490
|
+
project_notes: cleanText(trello.desc) ? `Imported from Trello.\n\n${cleanText(trello.desc)}` : "Imported from Trello.",
|
|
491
|
+
agent_instructions: "",
|
|
492
|
+
ai_enabled: 1,
|
|
493
|
+
created_at: cleanText(trello.dateLastActivity) || time,
|
|
494
|
+
updated_at: time
|
|
495
|
+
},
|
|
496
|
+
states,
|
|
497
|
+
labels: [...labelsById.values()],
|
|
498
|
+
tickets,
|
|
499
|
+
comments: comments.filter((comment) => comment.body),
|
|
500
|
+
review_verdicts: [],
|
|
501
|
+
relations: [],
|
|
502
|
+
attachments: [],
|
|
503
|
+
board_entries: [],
|
|
504
|
+
events: []
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function buildTrelloStates(lists = [], time) {
|
|
509
|
+
const sorted = [...lists].sort(compareTrelloPosition);
|
|
510
|
+
const rolesSeen = new Set();
|
|
511
|
+
const firstOpenIndex = sorted.findIndex((list) => !list.closed);
|
|
512
|
+
const defaultIndex = firstOpenIndex >= 0 ? firstOpenIndex : 0;
|
|
513
|
+
return sorted.map((list, index) => {
|
|
514
|
+
const role = uniqueTrelloStateRole(inferTrelloStateRole(list.name), rolesSeen);
|
|
515
|
+
return {
|
|
516
|
+
id: trelloScopedId("list", list.id || `${index + 1}`),
|
|
517
|
+
name: cleanText(list.name) || `Trello List ${index + 1}`,
|
|
518
|
+
position: index,
|
|
519
|
+
is_default: index === defaultIndex ? 1 : 0,
|
|
520
|
+
role,
|
|
521
|
+
created_at: dateFromTrelloObjectId(list.id) || time
|
|
522
|
+
};
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildTrelloLabelMap(trello, time) {
|
|
527
|
+
const labels = new Map();
|
|
528
|
+
const addLabel = (raw) => {
|
|
529
|
+
if (!raw) return;
|
|
530
|
+
const key = raw.id || `${raw.color || "label"}-${raw.name || ""}`;
|
|
531
|
+
const name = trelloLabelName(raw, trello.labelNames);
|
|
532
|
+
if (!name || labels.has(key)) return;
|
|
533
|
+
labels.set(key, {
|
|
534
|
+
id: trelloScopedId("label", key),
|
|
535
|
+
name,
|
|
536
|
+
color: TRELLO_LABEL_COLORS[String(raw.color || "").toLowerCase()] || "#64748b",
|
|
537
|
+
created_at: dateFromTrelloObjectId(raw.id) || time
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
for (const label of trello.labels || []) addLabel(label);
|
|
541
|
+
for (const card of trello.cards || []) for (const label of card.labels || []) addLabel(label);
|
|
542
|
+
return labels;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function groupTrelloChecklists(trello) {
|
|
546
|
+
const grouped = new Map();
|
|
547
|
+
const add = (cardId, checklist) => {
|
|
548
|
+
if (!cardId || !checklist) return;
|
|
549
|
+
if (!grouped.has(cardId)) grouped.set(cardId, []);
|
|
550
|
+
grouped.get(cardId).push(checklist);
|
|
551
|
+
};
|
|
552
|
+
for (const checklist of trello.checklists || []) add(checklist.idCard, checklist);
|
|
553
|
+
for (const card of trello.cards || []) for (const checklist of card.checklists || []) add(card.id, checklist);
|
|
554
|
+
return grouped;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function groupTrelloComments(trello) {
|
|
558
|
+
const grouped = new Map();
|
|
559
|
+
for (const action of trello.actions || []) {
|
|
560
|
+
if (action.type !== "commentCard") continue;
|
|
561
|
+
const cardId = action.data?.card?.id;
|
|
562
|
+
if (!cardId) continue;
|
|
563
|
+
if (!grouped.has(cardId)) grouped.set(cardId, []);
|
|
564
|
+
grouped.get(cardId).push(action);
|
|
565
|
+
}
|
|
566
|
+
for (const actions of grouped.values()) actions.sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")));
|
|
567
|
+
return grouped;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function trelloLabelsForCard(card, labelsById) {
|
|
571
|
+
const result = [];
|
|
572
|
+
const seen = new Set();
|
|
573
|
+
const addByRawId = (rawId) => {
|
|
574
|
+
const label = labelsById.get(rawId);
|
|
575
|
+
if (label && !seen.has(label.id)) {
|
|
576
|
+
seen.add(label.id);
|
|
577
|
+
result.push(label);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
for (const label of card.labels || []) addByRawId(label.id || `${label.color || "label"}-${label.name || ""}`);
|
|
581
|
+
for (const labelId of card.idLabels || []) addByRawId(labelId);
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderTrelloCardDescription(card, { listName, checklists }) {
|
|
586
|
+
const parts = [];
|
|
587
|
+
const desc = cleanText(card.desc);
|
|
588
|
+
if (desc) parts.push(desc);
|
|
589
|
+
const meta = [];
|
|
590
|
+
if (card.idShort) meta.push(`Trello card #${card.idShort}`);
|
|
591
|
+
if (listName) meta.push(`List: ${listName}`);
|
|
592
|
+
if (card.shortUrl || card.url) meta.push(`URL: ${card.shortUrl || card.url}`);
|
|
593
|
+
if (card.due) meta.push(`Due: ${card.due}${card.dueComplete ? " (complete)" : ""}`);
|
|
594
|
+
if (card.closed) meta.push("Archived in Trello: yes");
|
|
595
|
+
if (meta.length) parts.push(["Imported from Trello:", ...meta.map((line) => `- ${line}`)].join("\n"));
|
|
596
|
+
|
|
597
|
+
const checklistText = renderTrelloChecklists(checklists);
|
|
598
|
+
if (checklistText) parts.push(checklistText);
|
|
599
|
+
const attachmentText = renderTrelloAttachments(card.attachments || []);
|
|
600
|
+
if (attachmentText) parts.push(attachmentText);
|
|
601
|
+
return parts.join("\n\n");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function renderTrelloChecklists(checklists = []) {
|
|
605
|
+
const rendered = checklists
|
|
606
|
+
.map((checklist) => {
|
|
607
|
+
const items = [...(checklist.checkItems || [])].sort(compareTrelloPosition);
|
|
608
|
+
if (!items.length) return "";
|
|
609
|
+
return [`Checklist: ${cleanText(checklist.name) || "Untitled"}`, ...items.map((item) => `- [${item.state === "complete" ? "x" : " "}] ${cleanText(item.name) || "Untitled item"}`)].join("\n");
|
|
610
|
+
})
|
|
611
|
+
.filter(Boolean);
|
|
612
|
+
return rendered.join("\n\n");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderTrelloAttachments(attachments = []) {
|
|
616
|
+
const rendered = attachments
|
|
617
|
+
.map((attachment) => {
|
|
618
|
+
const name = cleanText(attachment.name) || cleanText(attachment.fileName) || "Attachment";
|
|
619
|
+
const url = cleanText(attachment.url);
|
|
620
|
+
return url ? `- ${name}: ${url}` : `- ${name}`;
|
|
621
|
+
})
|
|
622
|
+
.filter(Boolean);
|
|
623
|
+
return rendered.length ? ["Trello attachments:", ...rendered].join("\n") : "";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function trelloLabelName(label, labelNames = {}) {
|
|
627
|
+
const explicit = cleanText(label.name);
|
|
628
|
+
if (explicit) return explicit;
|
|
629
|
+
const color = cleanText(label.color);
|
|
630
|
+
const namedColor = color ? cleanText(labelNames[color]) : "";
|
|
631
|
+
if (namedColor) return namedColor;
|
|
632
|
+
return color ? `Trello ${color}` : "";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function listNameForCard(lists = [], idList) {
|
|
636
|
+
return cleanText(lists.find((list) => list.id === idList)?.name);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function priorityFromTrelloCard(card) {
|
|
640
|
+
const names = (card.labels || []).map((label) => cleanText(label.name || label.color).toLowerCase()).join(" ");
|
|
641
|
+
if (/urgent|critical|blocker|high|red/.test(names)) return 4;
|
|
642
|
+
if (/low|someday|backlog/.test(names)) return 1;
|
|
643
|
+
return 2;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function inferTrelloStateRole(name) {
|
|
647
|
+
const normalized = cleanText(name).toLowerCase();
|
|
648
|
+
if (!normalized) return null;
|
|
649
|
+
if (/^ai ready$|ai[- ]ready/.test(normalized)) return "ai_ready";
|
|
650
|
+
if (/review|qa|verify|validation/.test(normalized)) return "review";
|
|
651
|
+
if (/doing|in progress|active|working|wip/.test(normalized)) return "in_progress";
|
|
652
|
+
if (/done|complete|completed|shipped|closed/.test(normalized)) return "done";
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function uniqueTrelloStateRole(role, rolesSeen) {
|
|
657
|
+
if (!role || rolesSeen.has(role)) return null;
|
|
658
|
+
rolesSeen.add(role);
|
|
659
|
+
return role;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function trelloMemberName(member) {
|
|
663
|
+
return cleanText(member?.fullName) || cleanText(member?.username) || "Trello";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function compareTrelloPosition(a, b) {
|
|
667
|
+
const pa = Number(a?.pos ?? Number.MAX_SAFE_INTEGER);
|
|
668
|
+
const pb = Number(b?.pos ?? Number.MAX_SAFE_INTEGER);
|
|
669
|
+
if (pa !== pb) return pa - pb;
|
|
670
|
+
return String(a?.id || "").localeCompare(String(b?.id || ""));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function dateFromTrelloObjectId(value) {
|
|
674
|
+
const idValue = String(value || "");
|
|
675
|
+
if (!/^[0-9a-fA-F]{8}/.test(idValue)) return null;
|
|
676
|
+
const seconds = Number.parseInt(idValue.slice(0, 8), 16);
|
|
677
|
+
if (!Number.isFinite(seconds)) return null;
|
|
678
|
+
return new Date(seconds * 1000).toISOString();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function cleanText(value) {
|
|
682
|
+
return String(value ?? "").replace(/\r\n/g, "\n").trim();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function trelloScopedId(prefix, value) {
|
|
686
|
+
return `trello-${prefix}-${String(value || id()).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
687
|
+
}
|
package/src/core/states.js
CHANGED
|
File without changes
|
|
File without changes
|
package/src/core/tickets.js
CHANGED
|
File without changes
|
package/src/core/util.js
CHANGED
|
File without changes
|
package/src/mcp/orbit-client.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function createOrbitClient(env = process.env) {
|
|
|
20
20
|
|
|
21
21
|
export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.fetch) {
|
|
22
22
|
const apiUrl = String(env.ORBIT_API_URL || "").trim().replace(/\/+$/, "");
|
|
23
|
-
|
|
23
|
+
let defaultBoard = String(env.ORBIT_DEFAULT_BOARD || "").trim();
|
|
24
24
|
if (!apiUrl) throw rpcError(-32602, "ORBIT_MODE=remote requires ORBIT_API_URL.");
|
|
25
25
|
if (typeof fetchImpl !== "function") throw rpcError(-32603, "ORBIT_MODE=remote requires global fetch support.");
|
|
26
26
|
|
|
@@ -57,6 +57,10 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const ticketQuery = (args = {}) => ({ board_id: args.board_id, board: args.board_slug || args.board || defaultBoard });
|
|
60
|
+
const stripBoardSelectors = (args = {}) => {
|
|
61
|
+
const { board_id, board_slug, board, ...rest } = args;
|
|
62
|
+
return rest;
|
|
63
|
+
};
|
|
60
64
|
const searchQuery = (args = {}) => ({
|
|
61
65
|
q: args.q,
|
|
62
66
|
limit: args.limit,
|
|
@@ -84,7 +88,7 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
|
|
|
84
88
|
return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/context`, { query: { include_struck: args.include_struck ? "true" : undefined } });
|
|
85
89
|
},
|
|
86
90
|
async boardList() { return request("GET", "/api/boards"); },
|
|
87
|
-
async boardSetActive(args = {}) { if (!args.slug) throw rpcError(-32602, "slug is required.");
|
|
91
|
+
async boardSetActive(args = {}) { if (!args.slug) throw rpcError(-32602, "slug is required."); defaultBoard = String(args.slug); env.ORBIT_DEFAULT_BOARD = defaultBoard; return { ok: true, slug: defaultBoard, mode: "remote" }; },
|
|
88
92
|
async claimNext(args = {}) { return request("POST", "/api/agent/claim-next", { body: { ...args, board: requireBoard(args) } }); },
|
|
89
93
|
async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...contextQuery(args), depth: args.depth || 1 } }); },
|
|
90
94
|
async getTicketContextFull(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context/full`, { query: { ...contextQuery(args), depth: args.depth || 1 } }); },
|
|
@@ -114,11 +118,14 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
|
|
|
114
118
|
async getTicketBlockers(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/blockers`, { query: ticketQuery(args) }); },
|
|
115
119
|
async search(args = {}) { return request("GET", "/api/search", { query: searchQuery(args) }); },
|
|
116
120
|
async createTicket(args = {}) { return request("POST", "/api/tickets", { body: { ...args, board: requireBoard(args) }, ok: [201] }); },
|
|
117
|
-
async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: patch }); },
|
|
118
|
-
async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { body, ok: [201] }); },
|
|
119
|
-
async
|
|
120
|
-
async
|
|
121
|
-
async
|
|
121
|
+
async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: stripBoardSelectors(patch) }); },
|
|
122
|
+
async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { query: ticketQuery(body), body: stripBoardSelectors(body), ok: [201] }); },
|
|
123
|
+
async createReviewVerdict({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/review-verdicts`, { query: ticketQuery(body), body: stripBoardSelectors(body), ok: [201] }); },
|
|
124
|
+
async listReviewVerdicts(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/review-verdicts`, { query: { ...ticketQuery(args), limit: args.limit } }); },
|
|
125
|
+
async getReviewVerdict(args = {}) { return request("GET", `/api/review-verdicts/${encodeURIComponent(args.review_id)}`, { query: ticketQuery(args) }); },
|
|
126
|
+
async addBoardEntry(args = {}) { return request("POST", `/api/boards/${encodeURIComponent(requireBoard(args))}/entries`, { body: stripBoardSelectors(args), ok: [201] }); },
|
|
127
|
+
async checkpoint(args = {}) { const fallback = boardArg(args); return request("POST", "/api/agent/checkpoint", { body: fallback ? { ...args, board: args.board || args.board_slug || fallback } : args }); },
|
|
128
|
+
async complete(args = {}) { const fallback = boardArg(args); return request("POST", "/api/agent/complete", { body: fallback ? { ...args, board: args.board || args.board_slug || fallback } : args }); },
|
|
122
129
|
async archiveTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/archive`, { query: ticketQuery(args) }); },
|
|
123
130
|
async restoreTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/restore`, { query: ticketQuery(args) }); },
|
|
124
131
|
async deleteTicket(args = {}) { return request("DELETE", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) }); },
|
|
@@ -199,10 +206,26 @@ export async function createLocalOrbitClient(env = process.env) {
|
|
|
199
206
|
if (!row) throw rpcError(-32602, "No board in session; pass board_id or board_slug, or call board_set_active first.");
|
|
200
207
|
return { actor, board: row, db: registry.openBoardDb(row) };
|
|
201
208
|
};
|
|
202
|
-
const sessionCtx = (actor) => ctxFor(getSessionBoard(), actor);
|
|
203
209
|
const backup = (ctx, result) => { backups.scheduleAutomaticBoardBackup(ctx.board, ctx.db); return result; };
|
|
204
|
-
const explicit = (args = {}) =>
|
|
210
|
+
const explicit = (args = {}) => {
|
|
211
|
+
if (args.board_id) {
|
|
212
|
+
const row = registry.getBoardByRegistryId(args.board_id);
|
|
213
|
+
if (!row) throw rpcError(-32004, `Board id not found: ${args.board_id}`);
|
|
214
|
+
return row;
|
|
215
|
+
}
|
|
216
|
+
if (args.board_slug || args.board) {
|
|
217
|
+
const slug = args.board_slug || args.board;
|
|
218
|
+
const row = registry.getBoardBySlug(slug);
|
|
219
|
+
if (!row) throw rpcError(-32004, `Board slug not found: ${slug}`);
|
|
220
|
+
return row;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
};
|
|
205
224
|
const rowOrSession = (args = {}) => explicit(args) || getSessionBoard();
|
|
225
|
+
const stripBoardSelectors = (args = {}) => {
|
|
226
|
+
const { board_id, board_slug, board: _board, ...rest } = args;
|
|
227
|
+
return rest;
|
|
228
|
+
};
|
|
206
229
|
const actor = () => board.localAgentActor();
|
|
207
230
|
|
|
208
231
|
return {
|
|
@@ -224,15 +247,18 @@ export async function createLocalOrbitClient(env = process.env) {
|
|
|
224
247
|
getTicketRelations: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getTicketRelations(args.ticket_id, ctx); },
|
|
225
248
|
getTicketBlockers: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getTicketBlockers(args.ticket_id, ctx); },
|
|
226
249
|
search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets(args, ctx); },
|
|
227
|
-
createTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const
|
|
228
|
-
updateTicket: ({ ticket_id, ...patch }) => { const ctx =
|
|
229
|
-
addComment: ({ ticket_id, ...body }) => { const ctx =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
250
|
+
createTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const body = stripBoardSelectors(args); body.board_id = ctx.board.id; return backup(ctx, board.createTicket(body, ctx)); },
|
|
251
|
+
updateTicket: ({ ticket_id, ...patch }) => { const ctx = ctxFor(rowOrSession(patch), actor()); return backup(ctx, board.updateTicket(ticket_id, stripBoardSelectors(patch), ctx)); },
|
|
252
|
+
addComment: ({ ticket_id, ...body }) => { const ctx = ctxFor(rowOrSession(body), actor()); return backup(ctx, board.createComment(ticket_id, stripBoardSelectors(body), ctx)); },
|
|
253
|
+
createReviewVerdict: ({ ticket_id, ...body }) => { const ctx = ctxFor(rowOrSession(body), actor()); return backup(ctx, board.createReviewVerdict(ticket_id, stripBoardSelectors(body), ctx)); },
|
|
254
|
+
listReviewVerdicts: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return { review_verdicts: board.listReviewVerdicts(args.ticket_id, ctx, args) }; },
|
|
255
|
+
getReviewVerdict: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getReviewVerdict(args.review_id, ctx); },
|
|
256
|
+
addBoardEntry: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.createBoardEntry(ctx.board.id, stripBoardSelectors(args), ctx)); },
|
|
257
|
+
checkpoint: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.checkpointTicket(stripBoardSelectors(args), ctx)); },
|
|
258
|
+
complete: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.completeTicket(stripBoardSelectors(args), ctx)); },
|
|
259
|
+
archiveTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.archiveTicket(args.ticket_id, ctx)); },
|
|
260
|
+
restoreTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.restoreTicket(args.ticket_id, ctx)); },
|
|
261
|
+
deleteTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.deleteTicket(args.ticket_id, ctx)); },
|
|
236
262
|
listArchive: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return { tickets: board.archivedTicketsForBoard(ctx.db, ctx.board.id) }; },
|
|
237
263
|
exportBoard: (args = {}) => {
|
|
238
264
|
const ctx = ctxFor(rowOrSession(args), actor());
|
|
@@ -240,6 +266,6 @@ export async function createLocalOrbitClient(env = process.env) {
|
|
|
240
266
|
includeAttachments: Boolean(args.include_attachments || args.include_images)
|
|
241
267
|
});
|
|
242
268
|
},
|
|
243
|
-
updateSettings: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const
|
|
269
|
+
updateSettings: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const patch = stripBoardSelectors(args); return backup(ctx, board.updateBoard(ctx.board.id, patch, ctx)); }
|
|
244
270
|
};
|
|
245
271
|
}
|